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"`.
|
||||
- **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
|
||||
|
||||
### ✅ DO
|
||||
|
||||
Reference in New Issue
Block a user