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:
@@ -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<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 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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user