From 8bed9f6b4e02ad191d669786aea5cde96e756c2a Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Sun, 12 Jul 2026 20:16:12 +0100 Subject: [PATCH] refactor(billing): system email + single-query backfill for the student cap (#735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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} --- AGENTS.md | 17 ++++ .../services/organization/student-limit.ts | 77 ++++++++++++------- .../lib/features/app/app-mode-watcher.svelte | 2 +- .../src/queries/organization/organization.ts | 40 +++++++++- packages/db/src/schema.ts | 4 + .../src/scripts/notify-students-over-limit.ts | 59 +++++++++----- packages/email/src/emails/index.ts | 3 + .../src/emails/student-limit-approaching.ts | 26 +++++++ .../email/src/emails/student-limit-reached.ts | 8 +- packages/email/src/utils/constants.ts | 1 + 10 files changed, 181 insertions(+), 56 deletions(-) create mode 100644 packages/email/src/emails/student-limit-approaching.ts diff --git a/AGENTS.md b/AGENTS.md index a3691949..67252006 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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"`. - **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(' (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 ### ✅ DO diff --git a/apps/api/src/services/organization/student-limit.ts b/apps/api/src/services/organization/student-limit.ts index c43e2d0e..e1eddd02 100644 --- a/apps/api/src/services/organization/student-limit.ts +++ b/apps/api/src/services/organization/student-limit.ts @@ -1,6 +1,5 @@ import { AppError, ErrorCodes } from '@api/utils/errors'; import { enqueueTransactionalEmail } from '@api/services/jobs'; -import { buildEmailBranding } from '@cio/email'; import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; import { getStudentLimit } from '@cio/utils/plans'; import { env } from '@cio/core/config/env'; @@ -8,44 +7,56 @@ import { countActiveStudents, getActiveOrganizationPlan, getOrganizationAdminEmails, - getOrganizationById + getOrganizationById, + updateOrganization } from '@cio/db/queries/organization'; +type StudentMilestone = 'half' | 'reached'; + /** - * Notifies org admins that the org has hit its plan's student limit. Enqueued - * with a per-day idempotency key so repeated blocked attempts don't spam admins. + * Emails org admins once when the org crosses a student-count milestone (50% + * 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, + milestone: StudentMilestone, studentCount: number, studentLimit: number ): Promise { - 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 upgradeUrl = `${getDashboardBaseUrl()}/org/${org.siteName}?upgrade=true`; + const admins = await getOrganizationAdminEmails(orgId); + if (admins.length) { + const upgradeUrl = `${getDashboardBaseUrl()}/org/${org.siteName}?upgrade=true`; + const template = milestone === 'reached' ? 'studentLimitReached' : 'studentLimitApproaching'; - await enqueueTransactionalEmail('studentLimitReached', { - to: admins.map((admin) => admin.email), - fields: { - orgName: org.name, - studentCount, - studentLimit, - upgradeUrl, - branding: buildEmailBranding({ name: org.name, avatarUrl: org.avatarUrl, theme: org.theme }) - }, - idempotencyKey: `student-limit-reached:${orgId}:${todayKey}` - }); + await enqueueTransactionalEmail(template, { + to: admins.map((admin) => admin.email), + fields: { orgName: org.name, studentCount, studentLimit, upgradeUrl }, + idempotencyKey: `student-limit-${milestone}:${orgId}` + }); + } + + // Reaching the limit implies the halfway mark was passed too, so a bulk jump + // straight past 50% never fires the halfway email afterwards. + 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 - * with the student role would push the org past its plan's student limit. - * Self-hosted orgs and paid plans with an unlimited allowance are exempt. + * Throws `UPGRADE_REQUIRED` when adding `additionalStudents` new student-role + * members would push the org past its plan's student limit. Self-hosted orgs + * 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 { if (additionalStudents <= 0) return; @@ -57,16 +68,24 @@ export async function assertStudentCapacityOrThrow(orgId: string, additionalStud if (!Number.isFinite(limit)) return; const currentCount = await countActiveStudents(orgId); + const newCount = currentCount + additionalStudents; - if (currentCount + additionalStudents > limit) { - notifyAdminsStudentLimitReached(orgId, currentCount, limit).catch((error) => { - console.error('notifyAdminsStudentLimitReached error:', error); - }); - + if (newCount > limit) { throw new AppError( `This organization has reached its ${limit}-student limit on the Free plan`, ErrorCodes.UPGRADE_REQUIRED, 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); + }); + } } diff --git a/apps/dashboard/src/lib/features/app/app-mode-watcher.svelte b/apps/dashboard/src/lib/features/app/app-mode-watcher.svelte index 202b9645..ad7d10a4 100644 --- a/apps/dashboard/src/lib/features/app/app-mode-watcher.svelte +++ b/apps/dashboard/src/lib/features/app/app-mode-watcher.svelte @@ -18,4 +18,4 @@ }); - + diff --git a/packages/db/src/queries/organization/organization.ts b/packages/db/src/queries/organization/organization.ts index 657fac6c..d94c7b72 100644 --- a/packages/db/src/queries/organization/organization.ts +++ b/packages/db/src/queries/organization/organization.ts @@ -8,8 +8,9 @@ import type { TOrganization, TOrganizationPlan } 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 { db, type DbOrTxClient } from '@db/drizzle'; 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> => { + 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 * @returns First organization by createdAt, or null if none exist diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index f357f5a7..dad8a257 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -2009,6 +2009,10 @@ export const organization = pgTable( inviteOnly?: boolean; }; internalEnrollmentOnly?: boolean; + studentLimitNotified?: { + half?: boolean; + reached?: boolean; + }; }>(), landingpage: jsonb().default({}).$type<{ header?: { diff --git a/packages/db/src/scripts/notify-students-over-limit.ts b/packages/db/src/scripts/notify-students-over-limit.ts index adef0a3f..795aa03b 100644 --- a/packages/db/src/scripts/notify-students-over-limit.ts +++ b/packages/db/src/scripts/notify-students-over-limit.ts @@ -9,57 +9,76 @@ */ import 'dotenv/config'; -import { buildEmailBranding, sendEmail } from '@cio/email'; -import { PLAN, getStudentLimit, isOrgOnFreePlan } from '@cio/utils/plans'; +import { sendEmail } from '@cio/email'; +import { PLAN, getStudentLimit } from '@cio/utils/plans'; import { - countActiveStudents, + getFreePlanOrganizationsOverStudentLimit, getOrganizationAdminEmails, - getOrganizations + updateOrganization } from '../queries/organization/organization'; async function main() { const execute = process.argv.includes('--execute'); - const orgs = await getOrganizations(); 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) { - const isFree = isOrgOnFreePlan({ plans: org.plans, isSelfHosted: false, orgId: org.id }); - if (!isFree) continue; - - 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`); + // Skip orgs already emailed the "reached" milestone (runtime or a prior run). + if (org.settings?.studentLimitNotified?.reached) { + alreadyNotified++; 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( - `${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) { - const branding = buildEmailBranding({ name: org.name, avatarUrl: org.avatarUrl, theme: org.theme }); const upgradeUrl = `https://app.classroomio.com/org/${org.siteName ?? ''}?upgrade=true`; await Promise.all( admins.map((admin) => sendEmail('studentLimitReached', { 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() diff --git a/packages/email/src/emails/index.ts b/packages/email/src/emails/index.ts index 41ee21fc..cef35525 100644 --- a/packages/email/src/emails/index.ts +++ b/packages/email/src/emails/index.ts @@ -27,6 +27,7 @@ export * from './session'; export * from './submission-received'; export * from './submission-graded'; export * from './student-limit-reached'; +export * from './student-limit-approaching'; import type { newsfeedCommentEmail, newsfeedPostEmail } from './newsfeed'; @@ -46,6 +47,7 @@ import type { sessionReminderEmail, sessionUpdatedEmail } from './session'; import type { submissionReceivedEmail } from './submission-received'; import type { submissionGradedEmail } from './submission-graded'; import type { studentLimitReachedEmail } from './student-limit-reached'; +import type { studentLimitApproachingEmail } from './student-limit-approaching'; import type { teacherCourseWelcomeEmail } from './teacher-course-welcome'; import type { teacherStudentBuyRequestEmail } from './teacher-student-buy-request'; import type { teacherStudentJoinedEmail } from './teacher-student-joined'; @@ -82,4 +84,5 @@ export type EmailSchemas = { submissionReceived: typeof submissionReceivedEmail.template.schema; submissionGraded: typeof submissionGradedEmail.template.schema; studentLimitReached: typeof studentLimitReachedEmail.template.schema; + studentLimitApproaching: typeof studentLimitApproachingEmail.template.schema; }; diff --git a/packages/email/src/emails/student-limit-approaching.ts b/packages/email/src/emails/student-limit-approaching.ts new file mode 100644 index 00000000..2545f22d --- /dev/null +++ b/packages/email/src/emails/student-limit-approaching.ts @@ -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 = ` +

Hi there,

+

${fields.orgName} 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.

+ + `; + + return getDefaultTemplate(content); + } +}); diff --git a/packages/email/src/emails/student-limit-reached.ts b/packages/email/src/emails/student-limit-reached.ts index 715a1093..ae000db8 100644 --- a/packages/email/src/emails/student-limit-reached.ts +++ b/packages/email/src/emails/student-limit-reached.ts @@ -2,7 +2,6 @@ import * as z from 'zod'; import { defineEmail } from '../send'; import { getDefaultTemplate } from '../templates'; -import { ZEmailBranding } from '../core/branding'; export const studentLimitReachedEmail = defineEmail({ id: 'studentLimitReached', @@ -11,18 +10,17 @@ export const studentLimitReachedEmail = defineEmail({ orgName: z.string().min(1), studentCount: z.number(), studentLimit: z.number(), - upgradeUrl: z.string().url(), - branding: ZEmailBranding + upgradeUrl: z.url() }), render: (fields) => { const content = `

Hi there,

-

${fields.orgName} 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.

+

${fields.orgName} 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.

`; - return getDefaultTemplate(content, fields.branding); + return getDefaultTemplate(content); } }); diff --git a/packages/email/src/utils/constants.ts b/packages/email/src/utils/constants.ts index 969447a3..ce43eef7 100644 --- a/packages/email/src/utils/constants.ts +++ b/packages/email/src/utils/constants.ts @@ -13,6 +13,7 @@ export const EMAIL_IDS = [ 'submissionGraded', 'submissionReceived', 'studentLimitReached', + 'studentLimitApproaching', 'studentCourseInvite', 'studentCourseCompletion', 'studentCourseWelcome',