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:
Rotimi Best
2026-07-12 20:16:12 +01:00
committed by GitHub
parent 7392be04d7
commit 8bed9f6b4e
10 changed files with 181 additions and 56 deletions
@@ -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()