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
@@ -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<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
* @returns First organization by createdAt, or null if none exist