Files
classroomio/packages/email
Rotimi Best fd8bec3e70 feat(billing): enforce free-plan student cap across the app (#733)
* 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.
2026-07-12 11:33:04 +01:00
..

@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: '[email protected]',
  fields: {
    name: 'John Doe',
    email: '[email protected]',
    link: 'https://app.classroomio.com/reset?token=abc123'
  }
});

With Overrides

await send('forgotPassword', {
  to: '[email protected]',
  fields: {
    name: 'John Doe',
    email: '[email protected]',
    link: 'https://app.classroomio.com/reset?token=abc123'
  },
  from: '"Support Team" <[email protected]>',
  replyTo: '[email protected]'
});

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:

  1. The EMAIL_IDS array in utils/constants.ts
  2. The EmailSchemas type mapping in emails/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: '[email protected]',
    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 templates
  • core/base.ts: Base send function that validates and sends
  • send.ts: defineEmail() and send() functions
  • utils/constants.ts: EMAIL_IDS constant array with all email IDs
  • utils/types.ts: EmailId type derived from EMAIL_IDS for type safety, and EmailSchemaFor conditional type
  • emails/*.ts: Individual email definitions
  • emails/index.ts: Auto-exports all emails for registration and defines EmailSchemas type mapping
  • index.ts: Main export point

Flow

  1. Email definitions use defineEmail() which automatically registers them
  2. All emails are imported via emails/index.ts when the package is loaded
  3. send() looks up the email by ID, validates fields, and sends via existing infrastructure
  4. Existing deliverEmail() function from send.ts is reused for actual delivery

Adding New Emails

When adding a new email template:

  1. Create a new email file in emails/ directory using defineEmail()
  2. Export it from emails/index.ts
  3. Important: Add the email ID to the EMAIL_IDS array in utils/constants.ts
  4. Important: Add the schema mapping to EmailSchemas type in emails/index.ts for 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;