* feat(billing): enforce free-plan student cap across the app
Free (BASIC) plan orgs were able to exceed the advertised 20-student
limit because the cap existed only as dashboard cosmetic copy with no
server-side enforcement.
Add a single source of truth (getStudentLimit / STUDENT_LIMITS) and a
central guard (assertStudentCapacityOrThrow) wired into every path that
creates a new student-role org member: self-enrolment, course-invite
acceptance, bulk audience import, and org-invite acceptance. The guard
throws UPGRADE_REQUIRED (403) and notifies org admins by email (throttled
to once per org per day) when the limit is hit.
Surface the limit in the UI: disable the audience import button and the
course new-student invite section with upgrade banners for admins, and
show a "contact your administrator" message on the public enrol page for
students (who cannot upgrade). Expose a studentLimitReached flag from the
public course endpoint so the enrol button can be disabled proactively.
Add a one-time (manual, non-cron) db script,
db:notify-students-over-limit, to email admins of orgs already over the
limit at rollout.
* feat(billing): guard remaining student-creating paths against the cap
The initial cap enforcement missed several service entry points that also
create a new student-role org member. Add the capacity guard to:
- autoEnrollStudent (public org self-join on a tenant site)
- cohort enrollment (bulk group enrollment and single member accept)
- ensureProgramCourseAccess (program/cohort-granted course access)
For the cohort and program paths, only genuinely new members are counted
(existing org members are skipped) so re-enrolling current students is
never falsely blocked. The core path enforces via the shared limit check;
it throws UPGRADE_REQUIRED without the admin email since the queue lives
in the api layer.
* fix(billing): don't show cap block to existing org members on enroll page
The public course-by-slug endpoint is unauthenticated, so the enroll
page's studentLimitReached flag is org-wide and can't tell whether the
current viewer is already an org member. An existing student of a full
BASIC org would see Join disabled even though the backend correctly lets
existing members enroll without consuming a new seat.
Only pre-block anonymous visitors (would-be new joiners). Logged-in users
are governed by the membership-aware backend, which allows existing
members and returns UPGRADE_REQUIRED (surfaced via the enroll onError
mapping) for genuinely new students.
* feat(billing): deliver org plan-limits via /account with an extensible registry
Replace the ad-hoc, per-surface audience fetches (each calling getOrgAudience
just to read a count) with a single, future-proof delivery of org usage +
limits on the /account payload, gated to admin/tutor members (students never
receive limit data; a plain COUNT, no student rows).
Model it generically so future org limits (courses, etc.) drop in without
reshaping the payload or re-plumbing the frontend:
- New packages/utils plan-limits registry: PLAN_LIMIT_RESOURCES, PLAN_LIMITS
(reuses STUDENT_LIMITS), getPlanLimit, and a JSON-safe ResourceUsage /
OrgUsageLimits shape with isResourceLimitReached / resourceLimitRemaining.
- getAccountData attaches a per-org `limits` map for admin/tutor orgs.
- Store selectors currentOrgLimits / studentUsage / isStudentLimitReached;
sidebars, upgrade card, People pages, and invite modals read these instead
of fetching audience rows.
Bring cohort surfaces to parity with course: cohort People page banner and
cohort sidebar People upgrade icon, matching the course equivalents.
Also: close the upgrade modal that opened over the invite modal (drop the
`add` param via openUpgradeModalOver), redesign the sidebar upgrade card
(progress toward the student limit), add a Progress `variant`, and document
the no-complex-expressions-in-object-literals rule in AGENTS.md.
* refactor(billing): address PR review on the limits work
- Dedupe new student profile IDs before the cohort seat check so duplicate
batch entries can't overcount against the cap (Greptile P1).
- Drive the org-sidebar upgrade indicator from a dynamic nav-item `upgrade`
flag (audience config declares `upgradeResource: 'students'`; groups set it
from a `limitsReached` map) instead of a hardcoded path check.
- Collapse the audience Import button into a single button with ternary
`disabled`/`href`.
- Simplify the notify-students-over-limit upgrade link to app.classroomio.com.
- Drop the comment on the org `limits` field.
- Add .greptile/rules.md documenting the manual one-time db/src/scripts
convention (operator-controlled reruns) so idempotency isn't reflagged.
* fix(billing): name the org in the enroll capacity message
Change the enroll-page student-limit message from "This course has reached
its student capacity" to "{orgName} has reached its student capacity", since
the cap is org-wide, not course-specific. Pass orgName from the loaded org on
the page and from the currentOrg store in the enroll onError snackbar.
* style(billing): use PremiumIcon for the Authentication upgrade indicator
Replace the RocketIcon on paid settings sub-items (e.g. Authentication) with
the PremiumIcon used on the Audience item, right-aligned (ml-auto) to match.
@cio/emails
Overview
A unified email system using a single send() function that accepts an email ID string with full runtime validation via Zod schemas. All emails are defined declaratively with automatic registration.
Key Features
- Minimal imports: Single
import { send } from '@cio/email' - Type safety: Type-safe email IDs with autocomplete (TypeScript enforces valid IDs)
- Runtime validation: Zod schemas validate fields at runtime
- Auto-registration: Emails are automatically registered when defined
- Discoverability: All emails accessible via string ID
- Consistency: Unified pattern for all email templates
- Validation: Automatic Zod validation of fields with helpful error messages
Usage
Sending an Email
import { sendEmail } from '@cio/email';
await send('forgotPassword', {
to: 'user@example.com',
fields: {
name: 'John Doe',
email: 'user@example.com',
link: 'https://app.classroomio.com/reset?token=abc123'
}
});
With Overrides
await send('forgotPassword', {
to: 'user@example.com',
fields: {
name: 'John Doe',
email: 'user@example.com',
link: 'https://app.classroomio.com/reset?token=abc123'
},
from: '"Support Team" <support@classroomio.com>',
replyTo: 'help@classroomio.com'
});
Creating Email Templates
Basic Example
// packages/email/src/emails/forgot-password.ts
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
export const forgotPasswordEmail = defineEmail({
id: 'forgotPassword',
subject: 'Password reset notification - ClassroomIO',
schema: z.object({
name: z.string().min(1),
email: z.string().email(),
link: z.url()
}),
render: (fields) => {
const content = `Hello ${fields.name},
<p>You are receiving this email because you have requested a password reset.</p>
<div>
<a class="button" href="${fields.link}">Reset my password</a>
</div>`;
return getDefaultTemplate(content);
}
});
TypeScript will provide autocomplete for valid email IDs and show errors for invalid ones. The EmailId type is defined in utils/types.ts based on the EMAIL_IDS constant in utils/constants.ts.
Note: When adding new emails, update both:
- The
EMAIL_IDSarray inutils/constants.ts - The
EmailSchemastype mapping inemails/index.ts
This ensures full type safety and schema inference.
Error Handling
The send() function automatically validates fields against the email's schema. If validation fails, a helpful error is thrown:
try {
await send('forgotPassword', {
to: 'user@example.com',
fields: {
name: '', // Invalid: empty string
link: 'not-a-url' // Invalid: not a URL
}
});
} catch (error) {
// Error: Invalid fields for email "forgotPassword": name: String must contain at least 1 character(s), link: Invalid url
}
If an email ID doesn't exist:
try {
await send('nonexistentEmail', { ... });
} catch (error) {
// Error: Email template "nonexistentEmail" not found. Available emails: forgotPassword, welcome, verifyEmail
}
Architecture
Core Components
core/types.ts: Type definitions (EmailTemplate, SendConfig, etc.)core/registry.ts: Email registry that stores all templatescore/base.ts: Base send function that validates and sendssend.ts:defineEmail()andsend()functionsutils/constants.ts:EMAIL_IDSconstant array with all email IDsutils/types.ts:EmailIdtype derived fromEMAIL_IDSfor type safety, andEmailSchemaForconditional typeemails/*.ts: Individual email definitionsemails/index.ts: Auto-exports all emails for registration and definesEmailSchemastype mappingindex.ts: Main export point
Flow
- Email definitions use
defineEmail()which automatically registers them - All emails are imported via
emails/index.tswhen the package is loaded send()looks up the email by ID, validates fields, and sends via existing infrastructure- Existing
deliverEmail()function fromsend.tsis reused for actual delivery
Adding New Emails
When adding a new email template:
- Create a new email file in
emails/directory usingdefineEmail() - Export it from
emails/index.ts - Important: Add the email ID to the
EMAIL_IDSarray inutils/constants.ts - Important: Add the schema mapping to
EmailSchemastype inemails/index.tsfor type inference
Example:
// 1. Create emails/new-email.ts
export const newEmail = defineEmail({
id: 'newEmail',
subject: 'New Email',
schema: z.object({
name: z.string(),
code: z.string()
}),
render: (fields) => { ... }
});
// 2. Export from emails/index.ts
export * from './new-email';
// Import the email definition for type mapping
import type { newEmail } from './new-email';
// Add to EmailSchemas type mapping in emails/index.ts
export type EmailSchemas = {
forgotPassword: typeof forgotPasswordEmail.template.schema;
welcome: typeof welcomeEmail.template.schema;
verifyEmail: typeof verifyEmailEmail.template.schema;
newEmail: typeof newEmail.template.schema; // ← Add here for type inference
};
// 3. Add to utils/constants.ts
export const EMAIL_IDS = [
'forgotPassword',
'welcome',
'verifyEmail',
'newEmail' // ← Add here for type safety
] as const;