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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user