refactor(billing): system email + single-query backfill for the student cap (#735)
* refactor(billing): make student-limit email a system email; tighten backfill query
- Fetch free-plan orgs over the student limit in a single query
(getFreePlanOrganizationsOverStudentLimit) instead of loading all orgs and
counting each in memory; the backfill script now just fans out admin emails.
- Report a dry-run summary: how many free orgs are over the limit, how many
with admins, total admin emails, and how many were skipped for having none.
- Reclassify the student-limit email as a system (ClassroomIO-branded) email
rather than org-branded — it's the platform telling an admin about their
plan. Drop the `branding` field and org masthead/theme; both callers updated.
- Reword the email to explain why it's sent (new students are being turned
away) and that it's throttled to once a day, so it doesn't read as spam.
- Document the system-vs-org-branded email distinction in AGENTS.md.
* feat(billing): send student-limit emails on milestone crossings, once each
Stop emailing admins on every blocked attempt. Instead notify once when the
org crosses a student-count milestone:
- 50% of the limit → new studentLimitApproaching email (heads-up).
- 100% (limit reached) → studentLimitReached email.
Crossings are detected in assertStudentCapacityOrThrow on the allowed path
(blocked attempts no longer email). "Already notified" is persisted on
organization.settings.studentLimitNotified so each milestone fires at most
once, ever — surviving restarts, no per-day approximation. The backfill script
uses the same flag, making reruns idempotent (skips already-notified orgs and
marks them after sending). Email copy updated to explain the trigger and that
it's a one-time-per-milestone message.
* fix(email): use z.url() and drop the frequency footnotes on student-limit emails
Address PR review: `upgradeUrl` uses `z.url()`; remove the "once a day / once
per milestone" footnote lines from both the reached and approaching emails.
* set track={false}
This commit is contained in:
@@ -400,6 +400,23 @@ Use `.server.ts` files for server-side code to isolate API keys.
|
|||||||
- **Icon-only buttons** (a `Button` whose content is just an icon, e.g. `size="icon"`) must use `variant="secondary"`.
|
- **Icon-only buttons** (a `Button` whose content is just an icon, e.g. `size="icon"`) must use `variant="secondary"`.
|
||||||
- **Theme color classes:** Classes that use colors from `packages/ui/src/index.css` (e.g. `text-muted-foreground`, `text-primary`) must be prefixed with `ui:` in dashboard code so they resolve against the UI theme (e.g. `ui:text-muted-foreground`, `ui:text-primary`). Only color-related utilities need the prefix; layout/sizing classes like `rounded`, `border`, `p-4` stay unprefixed (Tailwind defaults).
|
- **Theme color classes:** Classes that use colors from `packages/ui/src/index.css` (e.g. `text-muted-foreground`, `text-primary`) must be prefixed with `ui:` in dashboard code so they resolve against the UI theme (e.g. `ui:text-muted-foreground`, `ui:text-primary`). Only color-related utilities need the prefix; layout/sizing classes like `rounded`, `border`, `p-4` stay unprefixed (Tailwind defaults).
|
||||||
|
|
||||||
|
## Emails: system vs org-branded
|
||||||
|
|
||||||
|
Every transactional email in `packages/email/src/emails` is one of two kinds — decide deliberately, because it changes the branding, the schema, and the `from` address.
|
||||||
|
|
||||||
|
- **System emails** — ClassroomIO (the platform) addressing a user/account owner/admin about their *ClassroomIO account*: auth (verify/reset password), billing, **plan limits & usage**, license, workspace. These are **ClassroomIO-branded**:
|
||||||
|
- No `branding` field in the schema.
|
||||||
|
- Render with `getDefaultTemplate(content)` (no branding arg) — falls back to the ClassroomIO logo + default button color.
|
||||||
|
- Send from the default `EMAIL_FROM`; do **not** override `from` with the org name.
|
||||||
|
- Examples: `welcome`, `forgot-password`, `on-password-reset`, `studentLimitReached`.
|
||||||
|
- **Org-branded emails** — an *organization* addressing its own members (students/tutors): course invites, welcome/completion, newsfeed, cohort, session reminders. These carry the org's identity:
|
||||||
|
- Add a `branding: ZEmailBranding` field.
|
||||||
|
- Render with `getDefaultTemplate(content, fields.branding)`.
|
||||||
|
- Callers build branding via `buildEmailBranding({ name, avatarUrl, theme })` and send with `from: buildEmailFromName('<Org> (via ClassroomIO.com)')`.
|
||||||
|
- Examples: `teacherStudentJoined`, `studentCourseWelcome`, `studentCourseInvite`, `verifyEmail` (org-scoped signup).
|
||||||
|
|
||||||
|
Rule of thumb: if the recipient is being addressed **as a ClassroomIO customer** (account/billing/limits), it's a system email → ClassroomIO branding. If they're addressed **as a member of a specific org**, it's org-branded.
|
||||||
|
|
||||||
## Best Practices Summary
|
## Best Practices Summary
|
||||||
|
|
||||||
### ✅ DO
|
### ✅ DO
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||||
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||||
import { buildEmailBranding } from '@cio/email';
|
|
||||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||||
import { getStudentLimit } from '@cio/utils/plans';
|
import { getStudentLimit } from '@cio/utils/plans';
|
||||||
import { env } from '@cio/core/config/env';
|
import { env } from '@cio/core/config/env';
|
||||||
@@ -8,44 +7,56 @@ import {
|
|||||||
countActiveStudents,
|
countActiveStudents,
|
||||||
getActiveOrganizationPlan,
|
getActiveOrganizationPlan,
|
||||||
getOrganizationAdminEmails,
|
getOrganizationAdminEmails,
|
||||||
getOrganizationById
|
getOrganizationById,
|
||||||
|
updateOrganization
|
||||||
} from '@cio/db/queries/organization';
|
} from '@cio/db/queries/organization';
|
||||||
|
|
||||||
|
type StudentMilestone = 'half' | 'reached';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifies org admins that the org has hit its plan's student limit. Enqueued
|
* Emails org admins once when the org crosses a student-count milestone (50%
|
||||||
* with a per-day idempotency key so repeated blocked attempts don't spam admins.
|
* of the limit, or the limit itself). The "already notified" state is persisted
|
||||||
|
* on `organization.settings` so each milestone fires at most once, ever — no
|
||||||
|
* repeat emails on every subsequent blocked attempt.
|
||||||
*/
|
*/
|
||||||
async function notifyAdminsStudentLimitReached(
|
async function notifyStudentMilestone(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
|
milestone: StudentMilestone,
|
||||||
studentCount: number,
|
studentCount: number,
|
||||||
studentLimit: number
|
studentLimit: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const [admins, org] = await Promise.all([getOrganizationAdminEmails(orgId), getOrganizationById(orgId)]);
|
const org = await getOrganizationById(orgId);
|
||||||
|
if (!org) return;
|
||||||
|
|
||||||
if (!admins.length || !org) return;
|
const notified = org.settings?.studentLimitNotified ?? {};
|
||||||
|
if (notified[milestone]) return;
|
||||||
|
|
||||||
const todayKey = new Date().toISOString().slice(0, 10);
|
const admins = await getOrganizationAdminEmails(orgId);
|
||||||
const upgradeUrl = `${getDashboardBaseUrl()}/org/${org.siteName}?upgrade=true`;
|
if (admins.length) {
|
||||||
|
const upgradeUrl = `${getDashboardBaseUrl()}/org/${org.siteName}?upgrade=true`;
|
||||||
|
const template = milestone === 'reached' ? 'studentLimitReached' : 'studentLimitApproaching';
|
||||||
|
|
||||||
await enqueueTransactionalEmail('studentLimitReached', {
|
await enqueueTransactionalEmail(template, {
|
||||||
to: admins.map((admin) => admin.email),
|
to: admins.map((admin) => admin.email),
|
||||||
fields: {
|
fields: { orgName: org.name, studentCount, studentLimit, upgradeUrl },
|
||||||
orgName: org.name,
|
idempotencyKey: `student-limit-${milestone}:${orgId}`
|
||||||
studentCount,
|
});
|
||||||
studentLimit,
|
}
|
||||||
upgradeUrl,
|
|
||||||
branding: buildEmailBranding({ name: org.name, avatarUrl: org.avatarUrl, theme: org.theme })
|
// Reaching the limit implies the halfway mark was passed too, so a bulk jump
|
||||||
},
|
// straight past 50% never fires the halfway email afterwards.
|
||||||
idempotencyKey: `student-limit-reached:${orgId}:${todayKey}`
|
const updatedNotified = milestone === 'reached' ? { half: true, reached: true } : { ...notified, half: true };
|
||||||
});
|
|
||||||
|
await updateOrganization(orgId, { settings: { ...org.settings, studentLimitNotified: updatedNotified } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Throws `UPGRADE_REQUIRED` when adding `additionalStudents` new org members
|
* Throws `UPGRADE_REQUIRED` when adding `additionalStudents` new student-role
|
||||||
* with the student role would push the org past its plan's student limit.
|
* members would push the org past its plan's student limit. Self-hosted orgs
|
||||||
* Self-hosted orgs and paid plans with an unlimited allowance are exempt.
|
* and plans with an unlimited allowance are exempt.
|
||||||
*
|
*
|
||||||
* Fires an admin-notification email (fire-and-forget) the moment the limit is hit.
|
* When the addition is allowed and crosses a milestone (50% or the limit), it
|
||||||
|
* fires a one-time admin email (fire-and-forget). Blocked attempts do NOT email.
|
||||||
*/
|
*/
|
||||||
export async function assertStudentCapacityOrThrow(orgId: string, additionalStudents: number): Promise<void> {
|
export async function assertStudentCapacityOrThrow(orgId: string, additionalStudents: number): Promise<void> {
|
||||||
if (additionalStudents <= 0) return;
|
if (additionalStudents <= 0) return;
|
||||||
@@ -57,16 +68,24 @@ export async function assertStudentCapacityOrThrow(orgId: string, additionalStud
|
|||||||
if (!Number.isFinite(limit)) return;
|
if (!Number.isFinite(limit)) return;
|
||||||
|
|
||||||
const currentCount = await countActiveStudents(orgId);
|
const currentCount = await countActiveStudents(orgId);
|
||||||
|
const newCount = currentCount + additionalStudents;
|
||||||
|
|
||||||
if (currentCount + additionalStudents > limit) {
|
if (newCount > limit) {
|
||||||
notifyAdminsStudentLimitReached(orgId, currentCount, limit).catch((error) => {
|
|
||||||
console.error('notifyAdminsStudentLimitReached error:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
`This organization has reached its ${limit}-student limit on the Free plan`,
|
`This organization has reached its ${limit}-student limit on the Free plan`,
|
||||||
ErrorCodes.UPGRADE_REQUIRED,
|
ErrorCodes.UPGRADE_REQUIRED,
|
||||||
403
|
403
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const halfway = Math.ceil(limit / 2);
|
||||||
|
const crossedReached = currentCount < limit && newCount >= limit;
|
||||||
|
const crossedHalf = currentCount < halfway && newCount >= halfway;
|
||||||
|
|
||||||
|
if (crossedReached || crossedHalf) {
|
||||||
|
const milestone: StudentMilestone = crossedReached ? 'reached' : 'half';
|
||||||
|
notifyStudentMilestone(orgId, milestone, newCount, limit).catch((error) => {
|
||||||
|
console.error('notifyStudentMilestone error:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,4 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ModeWatcher defaultMode="light" />
|
<ModeWatcher defaultMode="light" track={false} />
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import type {
|
|||||||
TOrganization,
|
TOrganization,
|
||||||
TOrganizationPlan
|
TOrganizationPlan
|
||||||
} from '@db/types';
|
} from '@db/types';
|
||||||
import { and, asc, count, desc, eq, ilike, inArray, isNotNull, or, sql } from 'drizzle-orm';
|
import { and, asc, count, desc, eq, gt, ilike, inArray, isNotNull, notInArray, or, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { PLAN } from '@cio/utils/plans';
|
||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
import { db, type DbOrTxClient } from '@db/drizzle';
|
||||||
import type { TAudienceSortBy, TAudienceSortOrder } from '@cio/utils/validation/organization';
|
import type { TAudienceSortBy, TAudienceSortOrder } from '@cio/utils/validation/organization';
|
||||||
@@ -850,6 +851,43 @@ export const getOrganizations = async (filters?: {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets free (BASIC) plan organizations whose active student count exceeds
|
||||||
|
* `limit`, in a single query. Free = no active paid plan. Used by maintenance
|
||||||
|
* jobs so they don't load every org (or every org's count) and filter in memory.
|
||||||
|
*/
|
||||||
|
export const getFreePlanOrganizationsOverStudentLimit = async (
|
||||||
|
limit: number
|
||||||
|
): Promise<Array<TOrganization & { studentCount: number }>> => {
|
||||||
|
try {
|
||||||
|
const paidPlans = [PLAN.EARLY_ADOPTER, PLAN.ENTERPRISE] as ('EARLY_ADOPTER' | 'ENTERPRISE')[];
|
||||||
|
const paidOrgIds = db
|
||||||
|
.select({ orgId: schema.organizationPlan.orgId })
|
||||||
|
.from(schema.organizationPlan)
|
||||||
|
.where(and(eq(schema.organizationPlan.isActive, true), inArray(schema.organizationPlan.planName, paidPlans)));
|
||||||
|
|
||||||
|
const studentCount = count(schema.organizationmember.id);
|
||||||
|
const rows = await db
|
||||||
|
.select({ organization: schema.organization, studentCount })
|
||||||
|
.from(schema.organization)
|
||||||
|
.innerJoin(
|
||||||
|
schema.organizationmember,
|
||||||
|
and(
|
||||||
|
eq(schema.organizationmember.organizationId, schema.organization.id),
|
||||||
|
eq(schema.organizationmember.roleId, ROLE.STUDENT)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(notInArray(schema.organization.id, paidOrgIds))
|
||||||
|
.groupBy(schema.organization.id)
|
||||||
|
.having(gt(studentCount, limit));
|
||||||
|
|
||||||
|
return rows.map((row) => ({ ...(row.organization as TOrganization), studentCount: Number(row.studentCount) }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getFreePlanOrganizationsOverStudentLimit error:', error);
|
||||||
|
throw new Error('Failed to load free-plan organizations over the student limit');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the first (and typically only) organization - used for self-hosted single-org mode
|
* Gets the first (and typically only) organization - used for self-hosted single-org mode
|
||||||
* @returns First organization by createdAt, or null if none exist
|
* @returns First organization by createdAt, or null if none exist
|
||||||
|
|||||||
@@ -2009,6 +2009,10 @@ export const organization = pgTable(
|
|||||||
inviteOnly?: boolean;
|
inviteOnly?: boolean;
|
||||||
};
|
};
|
||||||
internalEnrollmentOnly?: boolean;
|
internalEnrollmentOnly?: boolean;
|
||||||
|
studentLimitNotified?: {
|
||||||
|
half?: boolean;
|
||||||
|
reached?: boolean;
|
||||||
|
};
|
||||||
}>(),
|
}>(),
|
||||||
landingpage: jsonb().default({}).$type<{
|
landingpage: jsonb().default({}).$type<{
|
||||||
header?: {
|
header?: {
|
||||||
|
|||||||
@@ -9,57 +9,76 @@
|
|||||||
*/
|
*/
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
import { buildEmailBranding, sendEmail } from '@cio/email';
|
import { sendEmail } from '@cio/email';
|
||||||
import { PLAN, getStudentLimit, isOrgOnFreePlan } from '@cio/utils/plans';
|
import { PLAN, getStudentLimit } from '@cio/utils/plans';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
countActiveStudents,
|
getFreePlanOrganizationsOverStudentLimit,
|
||||||
getOrganizationAdminEmails,
|
getOrganizationAdminEmails,
|
||||||
getOrganizations
|
updateOrganization
|
||||||
} from '../queries/organization/organization';
|
} from '../queries/organization/organization';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const execute = process.argv.includes('--execute');
|
const execute = process.argv.includes('--execute');
|
||||||
const orgs = await getOrganizations();
|
|
||||||
const limit = getStudentLimit(PLAN.BASIC);
|
const limit = getStudentLimit(PLAN.BASIC);
|
||||||
|
const orgs = await getFreePlanOrganizationsOverStudentLimit(limit);
|
||||||
|
|
||||||
let notified = 0;
|
let orgsToNotify = 0;
|
||||||
|
let adminsToNotify = 0;
|
||||||
|
let orgsOverWithoutAdmins = 0;
|
||||||
|
let alreadyNotified = 0;
|
||||||
|
|
||||||
for (const org of orgs) {
|
for (const org of orgs) {
|
||||||
const isFree = isOrgOnFreePlan({ plans: org.plans, isSelfHosted: false, orgId: org.id });
|
// Skip orgs already emailed the "reached" milestone (runtime or a prior run).
|
||||||
if (!isFree) continue;
|
if (org.settings?.studentLimitNotified?.reached) {
|
||||||
|
alreadyNotified++;
|
||||||
const studentCount = await countActiveStudents(org.id);
|
|
||||||
if (studentCount <= limit) continue;
|
|
||||||
|
|
||||||
const admins = await getOrganizationAdminEmails(org.id);
|
|
||||||
if (!admins.length) {
|
|
||||||
console.log(`Skipping ${org.name} (${org.id}): ${studentCount}/${limit} but no admin emails`);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const admins = await getOrganizationAdminEmails(org.id);
|
||||||
|
if (!admins.length) {
|
||||||
|
orgsOverWithoutAdmins++;
|
||||||
|
console.log(`Skipping ${org.name} (${org.id}): ${org.studentCount}/${limit} but no admin emails`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
orgsToNotify++;
|
||||||
|
adminsToNotify += admins.length;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`${execute ? 'Notifying' : '[dry-run] Would notify'} ${org.name} (${org.id}): ${studentCount}/${limit} students → ${admins.length} admin(s)`
|
`${execute ? 'Notifying' : '[dry-run] Would notify'} ${org.name} (${org.id}): ${org.studentCount}/${limit} students → ${admins.length} admin(s)`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (execute) {
|
if (execute) {
|
||||||
const branding = buildEmailBranding({ name: org.name, avatarUrl: org.avatarUrl, theme: org.theme });
|
|
||||||
const upgradeUrl = `https://app.classroomio.com/org/${org.siteName ?? ''}?upgrade=true`;
|
const upgradeUrl = `https://app.classroomio.com/org/${org.siteName ?? ''}?upgrade=true`;
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
admins.map((admin) =>
|
admins.map((admin) =>
|
||||||
sendEmail('studentLimitReached', {
|
sendEmail('studentLimitReached', {
|
||||||
to: admin.email,
|
to: admin.email,
|
||||||
fields: { orgName: org.name, studentCount, studentLimit: limit, upgradeUrl, branding }
|
fields: { orgName: org.name, studentCount: org.studentCount, studentLimit: limit, upgradeUrl }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
notified++;
|
await updateOrganization(org.id, {
|
||||||
|
settings: { ...org.settings, studentLimitNotified: { half: true, reached: true } }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(execute ? `Done. Notified ${notified} org(s).` : 'Dry run complete — re-run with --execute to send.');
|
console.log(
|
||||||
|
`\nFound ${orgs.length} free-plan org(s) over the ${limit}-student limit: ` +
|
||||||
|
`${orgsToNotify} to notify (${adminsToNotify} admin email(s) total)` +
|
||||||
|
(alreadyNotified ? `, ${alreadyNotified} already notified` : '') +
|
||||||
|
(orgsOverWithoutAdmins ? `, ${orgsOverWithoutAdmins} skipped for having no admins` : '') +
|
||||||
|
'.'
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
execute
|
||||||
|
? `Done. Sent ${adminsToNotify} email(s) across ${orgsToNotify} org(s).`
|
||||||
|
: 'Dry run — no emails sent. Re-run with --execute to send.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export * from './session';
|
|||||||
export * from './submission-received';
|
export * from './submission-received';
|
||||||
export * from './submission-graded';
|
export * from './submission-graded';
|
||||||
export * from './student-limit-reached';
|
export * from './student-limit-reached';
|
||||||
|
export * from './student-limit-approaching';
|
||||||
|
|
||||||
import type { newsfeedCommentEmail, newsfeedPostEmail } from './newsfeed';
|
import type { newsfeedCommentEmail, newsfeedPostEmail } from './newsfeed';
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ import type { sessionReminderEmail, sessionUpdatedEmail } from './session';
|
|||||||
import type { submissionReceivedEmail } from './submission-received';
|
import type { submissionReceivedEmail } from './submission-received';
|
||||||
import type { submissionGradedEmail } from './submission-graded';
|
import type { submissionGradedEmail } from './submission-graded';
|
||||||
import type { studentLimitReachedEmail } from './student-limit-reached';
|
import type { studentLimitReachedEmail } from './student-limit-reached';
|
||||||
|
import type { studentLimitApproachingEmail } from './student-limit-approaching';
|
||||||
import type { teacherCourseWelcomeEmail } from './teacher-course-welcome';
|
import type { teacherCourseWelcomeEmail } from './teacher-course-welcome';
|
||||||
import type { teacherStudentBuyRequestEmail } from './teacher-student-buy-request';
|
import type { teacherStudentBuyRequestEmail } from './teacher-student-buy-request';
|
||||||
import type { teacherStudentJoinedEmail } from './teacher-student-joined';
|
import type { teacherStudentJoinedEmail } from './teacher-student-joined';
|
||||||
@@ -82,4 +84,5 @@ export type EmailSchemas = {
|
|||||||
submissionReceived: typeof submissionReceivedEmail.template.schema;
|
submissionReceived: typeof submissionReceivedEmail.template.schema;
|
||||||
submissionGraded: typeof submissionGradedEmail.template.schema;
|
submissionGraded: typeof submissionGradedEmail.template.schema;
|
||||||
studentLimitReached: typeof studentLimitReachedEmail.template.schema;
|
studentLimitReached: typeof studentLimitReachedEmail.template.schema;
|
||||||
|
studentLimitApproaching: typeof studentLimitApproachingEmail.template.schema;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
import { defineEmail } from '../send';
|
||||||
|
import { getDefaultTemplate } from '../templates';
|
||||||
|
|
||||||
|
export const studentLimitApproachingEmail = defineEmail({
|
||||||
|
id: 'studentLimitApproaching',
|
||||||
|
subject: "You're approaching your Free plan student limit",
|
||||||
|
schema: z.object({
|
||||||
|
orgName: z.string().min(1),
|
||||||
|
studentCount: z.number(),
|
||||||
|
studentLimit: z.number(),
|
||||||
|
upgradeUrl: z.url()
|
||||||
|
}),
|
||||||
|
render: (fields) => {
|
||||||
|
const content = `
|
||||||
|
<p>Hi there,</p>
|
||||||
|
<p><strong>${fields.orgName}</strong> now has ${fields.studentCount} of the ${fields.studentLimit} students included on the Free plan — about halfway to the limit. Once you reach it, new students can't enrol, accept invites, or be added until you upgrade.</p>
|
||||||
|
<div>
|
||||||
|
<a class="button" href="${fields.upgradeUrl}">Upgrade your plan</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return getDefaultTemplate(content);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2,7 +2,6 @@ import * as z from 'zod';
|
|||||||
|
|
||||||
import { defineEmail } from '../send';
|
import { defineEmail } from '../send';
|
||||||
import { getDefaultTemplate } from '../templates';
|
import { getDefaultTemplate } from '../templates';
|
||||||
import { ZEmailBranding } from '../core/branding';
|
|
||||||
|
|
||||||
export const studentLimitReachedEmail = defineEmail({
|
export const studentLimitReachedEmail = defineEmail({
|
||||||
id: 'studentLimitReached',
|
id: 'studentLimitReached',
|
||||||
@@ -11,18 +10,17 @@ export const studentLimitReachedEmail = defineEmail({
|
|||||||
orgName: z.string().min(1),
|
orgName: z.string().min(1),
|
||||||
studentCount: z.number(),
|
studentCount: z.number(),
|
||||||
studentLimit: z.number(),
|
studentLimit: z.number(),
|
||||||
upgradeUrl: z.string().url(),
|
upgradeUrl: z.url()
|
||||||
branding: ZEmailBranding
|
|
||||||
}),
|
}),
|
||||||
render: (fields) => {
|
render: (fields) => {
|
||||||
const content = `
|
const content = `
|
||||||
<p>Hi there,</p>
|
<p>Hi there,</p>
|
||||||
<p><strong>${fields.orgName}</strong> has reached the ${fields.studentLimit}-student limit on the Free plan (currently ${fields.studentCount} students). New students can't enrol or be invited until you upgrade.</p>
|
<p><strong>${fields.orgName}</strong> has reached its Free plan limit of ${fields.studentLimit} students. From now on, new students can't enrol, accept invites, or be added until you upgrade.</p>
|
||||||
<div>
|
<div>
|
||||||
<a class="button" href="${fields.upgradeUrl}">Upgrade your plan</a>
|
<a class="button" href="${fields.upgradeUrl}">Upgrade your plan</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return getDefaultTemplate(content, fields.branding);
|
return getDefaultTemplate(content);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const EMAIL_IDS = [
|
|||||||
'submissionGraded',
|
'submissionGraded',
|
||||||
'submissionReceived',
|
'submissionReceived',
|
||||||
'studentLimitReached',
|
'studentLimitReached',
|
||||||
|
'studentLimitApproaching',
|
||||||
'studentCourseInvite',
|
'studentCourseInvite',
|
||||||
'studentCourseCompletion',
|
'studentCourseCompletion',
|
||||||
'studentCourseWelcome',
|
'studentCourseWelcome',
|
||||||
|
|||||||
Reference in New Issue
Block a user