From b246937701bbca2d300556e851b65da62652a6e4 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 14 Nov 2026 18:50:39 +0530 Subject: [PATCH 01/27] docs: plan tiers, trial and limits design --- docs/wip/plan-limits.md | 1015 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 1015 insertions(+) create mode 100644 docs/wip/plan-limits.md diff --git a/docs/wip/plan-limits.md b/docs/wip/plan-limits.md new file mode 100644 index 000000000..91c806044 --- /dev/null +++ b/docs/wip/plan-limits.md @@ -0,0 +1,1015 @@ +# Plans, trials and limits on courselit.app + +Status: ready for development, 2026-11-14, after three security reviews. +Only the `learner_sso` feature gate exists so far. Everything else here is +to be built. Security requirements are tagged S1–S31. The IDs are stable, so +some numbers are skipped or merged; see +[Security requirements](#7-security-requirements). + +## 1. Decisions at a glance + +| Topic | Decision | +|---|---| +| Modes | OSS (self-hosted) unlocks every feature with no limits. Cloud (courselit.app) has two paid plans and no free plan. | +| Prices | Pro $19 a month or $190 a year. Business $79 a month or $790 a year. No fee on sales on either plan. | +| Payment provider | CourseLit's own plans are paid through LemonSqueezy (replacing Dodo), set up the same way as MediaLit. | +| Trial | 14 days, no card, one per account. It gets every Business feature except removing branding, public code injection (the owner alone sees injected code) and priority support. No marketing email; 500 MB of storage. | +| End of trial or subscription | 15 days read-only, then the school is hard-deleted. The deletion itself is a separate change. | +| Limits | Marketing email per month and storage only: Pro 10,000 emails and 20 GB; Business 50,000 emails and 200 GB. No seat limit. | +| Business-only features | Learner SSO, SCORM, the audit log screen, priority support. | +| Branding | A "Powered by CourseLit" badge in four places. Any paid plan or OSS can remove it; a trial can't. | +| Email routing | Learner mail goes through CourseLit's own SendLit organization, in three streams: sign-in codes, transactional mail and community notifications, each with its own budget. The school's SendLit team carries only marketing (broadcasts, sequences, drip). | +| Sender name | The school's name, once SendLit supports a sender name per message. Until then, "CourseLit", with the school's name in the subject. | +| Trial safety | Code injection runs only for the school's owner. A custom domain works, but platform mail links to `.courselit.app` until the school pays. | +| Abuse | An acceptable use policy, an `abuse@` address with a "Report this site" link, a suspend-school action. Schools stay on `.courselit.app`. Live payments aren't blocked during a trial. | + +## 2. Modes, plans and prices + +- **OSS:** `@codelitdev/billing` already reports every feature as available + in `oss` mode. Limits must do the same and return unlimited. +- **Cloud:** plans `pro` and `business`, monthly or yearly + (`apps/api/billing.config.ts`). +- **Prices**, in minor units for the catalog variables: + + | Variable | Value | + |---|---| + | `BILLING_PRO_MONTH_AMOUNT_MINOR` | `1900` | + | `BILLING_PRO_YEAR_AMOUNT_MINOR` | `19000` | + | `BILLING_BUSINESS_MONTH_AMOUNT_MINOR` | `7900` | + | `BILLING_BUSINESS_YEAR_AMOUNT_MINOR` | `79000` | + + A price change needs a new `BILLING_CATALOG_REVISION`. The local + `apps/api/.env` and the QA example in `infra` still have $18 and $50. +- **For comparison** (third-party figures, October–November 2026): + + | Plan | Price | + |---|---| + | Teachable Starter | $29–39 plus 7.5% of sales | + | Podia Mover | $42–49 plus 5% | + | Teachable Builder | $69–89 | + | Podia Shaker | $84–99 | + | Circle Professional | $89–99 plus 2% | + | Kajabi Basic | from $143 | + +## 3. The trial + +### What a trial gets + +- A cardless, 14-day trial, as on `main`. People have asked to try Business, + so it covers Business, not just Pro. +- Every Business feature, including learner SSO and the audit log, with + three exceptions: + - the CourseLit badges always show (`remove_branding` is not included); + - injected code runs only for the school's owner (see + [Code injection](#code-injection)); + - no priority support. +- Limits: a marketing email quota of 0, and 500 MB of storage. +- Learner transactional mail (sign-in codes, receipts and so on) still goes + out, because it never uses the school's quota (section 6). + +### How it works + +- **The trial is CourseLit state, not a billing subscription.** The billing + package treats cardless trials as an entitlement the app supplies + ("Application trials are entitlement sources, not plans"), so `hasFeature` + and `getLimit` see no plan during a trial. + +#### Where school state lives + +| Data | Where | +|---|---| +| School state | `schools.status`. Today's values are `active`, `read_only`, `maintenance`, `migrating` and `deleted`. This change adds `pending_checkout`, `imported` and `suspended`, and gives `read_only` its meaning (grace after a lapse). | +| Last known plan | `schools.last_plan` (jsonb, the last `SchoolPlan` that wasn't `unknown`) and `schools.last_plan_at`. Written every time `schoolPlan` reads billing successfully, and used when billing is `unknown` (S23). | +| Drip pause | `school_integrations.marketing_paused_until` (timestamptz) on the `sendlit` row (section 5) | +| Grace | `schools.grace_ends_at` (timestamptz, null unless `read_only` because a plan lapsed) | +| Suspension | `schools.suspended_reason` (text), set by the staff CLI (S26) | +| Trial records | New table `school_trials`: `id`, `user_id`, `normalized_email`, `school_id` (nullable; no foreign key, so the row outlives the school), `started_at`, `ends_at`, `ended_at`, `end_reason` (`purchased`, `expired`). Unique on `user_id` and on `normalized_email`. | +| Upload reservations | New table `media_upload_reservations`: `id`, `school_id`, `school_account_id` (nullable; set for learner uploads), `bytes`, `expires_at` (S1) | +| Learner upload budget | School setting `learnerUploadLimitBytes`. Default 100 MB, maximum 1 GB. Saved through the existing school-settings update operation, which gives it REST and MCP access (S2). | + +Only the lifecycle job and the staff CLI write `status`, `grace_ends_at`, +`suspended_reason` and `school_trials`. No school-update endpoint or MCP tool +accepts them (S27). + +**Offline** means the state is `pending_checkout`, `imported` or +`suspended`. The storefront shows a neutral "This site isn't available" page. +The API refuses every school and learner operation except these: + +| State | The owner may | Everyone else | +|---|---|---| +| `pending_checkout`, `imported` | read in the admin; start checkout and pick a plan | nothing | +| `suspended` | read in the admin; cancel or resume the CourseLit plan | nothing; learners can't sign in | + +`maintenance`, `migrating` and `deleted` keep their current behaviour. + +#### `schoolPlan(school)` + +Feature checks (`schoolHasFeature`), limit checks, the SSO sync, the +lifecycle job and the Plan settings screen all go through `schoolPlan`. +None of them call billing directly. + +```ts +type SchoolPlan = + | { kind: "oss" } // every feature, no limits + | { kind: "paid"; plan: "pro" | "business"; status: string } // entitled billing subscription + | { kind: "trial"; endsAt: Date } // TRIAL features and limits + | { kind: "none"; reason: "expired" | "pending_checkout" | "imported" } + | { kind: "unknown" }; // billing couldn't be read (S23) + +const PRO_FEATURES = ["remove_branding", "code_injection"]; +const BUSINESS_FEATURES = [...PRO_FEATURES, "learner_sso", "scorm", "audit_log"]; +const TRIAL = { + features: ["learner_sso", "scorm", "audit_log"], // Business minus remove_branding and code_injection + limits: { marketing_emails_month: 0, storage_bytes: 500 * MB }, +}; +``` + +A trial has no `code_injection` feature. Showing injected code to the +owner during a trial isn't a feature check. It's the separate path for the +owner only, described in section 4 (S25). + +The order of checks: +1. `oss` mode. +2. **Billing**, through `commercialState`, which throws when billing can't + be read. A throw gives `unknown`. An entitled paid plan gives `paid`, and + always wins over the trial (S18). +3. A running trial (a `school_trials` row for this school with + `ends_at > now` and no `ended_at`) gives `trial`. +4. Otherwise `none`, with the reason taken from `schools.status`: + `pending_checkout`, `imported`, or else `expired`. + +How callers treat each result: +- `unknown`: + - feature checks use the school's last known result, which is cached on + the school when the plan is read; + - limit checks refuse new usage; + - the lifecycle job does nothing. +- `none`: no features, and every limit is 0. +- A successful purchase sets `ended_at` and `end_reason = "purchased"` on + the trial row, and sets `status` to `active`. + +#### One trial per account + +- Creating a school checks `school_trials` for the owner's `user_id` and + `normalized_email`. The email is lowercased, `+tags` are stripped, and + Gmail dots are ignored. +- **No row:** create one, with `ends_at` 14 days from now. The school is + `active`. +- **A row already exists:** the school is created as `pending_checkout`. It + becomes `active` when its checkout succeeds. + - A `pending_checkout` school that hasn't paid after 7 days is set to + `deleted`. It holds no content, so nothing else is removed. + - These schools count toward the limit of 3 school creations per account + per day. +- Throwaway accounts on disposable email addresses can still get around + this. That's accepted: a trial gives little, with no marketing email and + 500 MB. +- **Onboarding:** a new account's school is created first, and checkout + comes when the creator picks a plan. + +#### The lifecycle job + +`evaluate_school_lifecycle` is one job, safe to run more than once. It +reads `schoolPlan`, then: + +| Result | Current status | Action | +|---|---|---| +| `unknown` | any | nothing (S23) | +| `paid` or `trial` | `read_only` | back to `active`; clear `grace_ends_at`; email the owner | +| `none` (`expired`) | `active` | set `read_only` and `grace_ends_at = now + 15 days`; if the school had a trial, set its `ended_at = now` and `end_reason = "expired"`; email the owner | +| `none` (`expired`) | `read_only` | send the 7-day and 1-day deletion warnings when due (idempotent) | +| `none` (`pending_checkout` or `imported`) | any | nothing; these are handled by their own rules | +| any | `suspended`, `maintenance`, `migrating`, `deleted` | nothing; the job skips these states | + +It's queued: +- when a school is created, set to run at the trial's end; +- by the billing lifecycle hook on every billing change, next to the + existing `sync_plan_features`; +- by a daily sweep over every school that isn't `deleted`. + +`sync_plan_features` (SSO, and the SendLit quota) runs after it, +in the same job chain. + +### When a trial or subscription ends + +This applies to a trial that ends without a purchase, and to a paid +subscription that ends (cancelled, unpaid, or past `paidThroughAt` plus 7 +days; S17). + +1. **15 days `read_only`.** The owner can pick Pro or Business at any time, + which restores everything. Read-only mode is an **allowlist that fails + closed**: every operation not listed below is refused. The allowlist: + - **The owner:** + - every read (`GET`) in the admin and API; + - the billing operations: checkout, plan, cancel and resume; + - deleting the school (with the deletion change). + - **Other team members:** every read (`GET`). + - **Learners:** + - signing in; + - reading content and their own account and purchases; + - updating their own profile; + - cancelling their own subscriptions; + - deleting their own account. + - **Refused:** everything else, including checkout, free plans, join + requests, invitations, posts, comments, reactions and uploads. + - No marketing email goes out; the SendLit quota is set to 0. + - Learners' recurring charges in the school's own gateway continue + during the grace period. The emails to learners (with the deletion + change) tell them to cancel. +2. **Hard delete** of the school and all of its data, including its SendLit + team and MediaLit files. This is a [separate change](#8-out-of-scope). + +The owner is emailed when the school becomes read-only, 7 days before +deletion, and 1 day before. These emails go through the staff stream +(section 6), and they say that learners lose access too. + +Until deletion ships, expired schools stay read-only. Their cost is +bounded: at most 500 MB for a trial, or the plan's storage for a lapsed +paid school, and no marketing email. + +### Schools moved over from `main` + +Decided 2026-11-14: there's no automatic move for existing courselit.app +customers. Each one moves over by hand. The rewrite's importer +(`apps/api/src/migrations/`) brings in content, not CourseLit subscriptions. + +- Staff create an imported school with the staff CLI (S26), not the owner + through onboarding, so no trial is used up. Its status is `imported`. + It's **excluded from the automatic trial, the read-only grace period and + deletion**. +- It gets no trial; the customer has been paying already. It becomes + active when the owner picks Pro or Business through the normal checkout. +- Until then, it's offline and sends no email. Staff finish or remove + imported schools by hand. +- Its first successful checkout sets `status` to `active`. From then on it's + an ordinary school, and the lifecycle job applies to it. +- Cancelling the old subscription on `main` is part of the manual handover. + It is not something this change does. + +## 4. What each plan includes + +The rule: Pro has everything a creator needs to build, sell and market. +Business adds what organisations pay for: identity, compliance and support. + +| Feature | Trial | Pro | Business | +|---|---|---|---| +| Courses, downloads, every lesson type except SCORM, drip, certificates, quizzes | ✓ | ✓ | ✓ | +| Communities, paid plans, bundles, installments | ✓ | ✓ | ✓ | +| Stripe, LemonSqueezy, Razorpay on the school's own account; no fee on sales | ✓ | ✓ | ✓ | +| Page builder, blog | ✓ | ✓ | ✓ | +| Custom domain (during a trial, platform mail still links to `.courselit.app`; S31) | ✓ | ✓ | ✓ | +| Contacts, segments, broadcasts, sequences, drip emails | ✓ (nothing is sent: quota 0) | ✓ | ✓ | +| REST API, API keys, MCP | ✓ | ✓ | ✓ | +| Code injection (`code_injection`) | owner only | ✓ | ✓ | +| Remove CourseLit branding (`remove_branding`) | — | ✓ | ✓ | +| Learner SSO (`learner_sso`, already built) | ✓ | — | ✓ | +| SCORM lessons (`scorm`) | ✓ | — | ✓ | +| Audit log screen (`audit_log`) | ✓ | — | ✓ | +| Priority support | — | — | ✓ | + +OSS has every feature. Feature keys go in `planFeatures` in +`apps/api/src/billing.ts`, next to the existing `business: [SSO_FEATURE]`. +Every check runs through `schoolPlan`, and every check happens when the +action happens or the page renders. Losing a plan therefore takes effect on +its own, without a sync, except for SSO (section 5) and the SendLit quota. + +### Branding badge + +A "Powered by CourseLit" badge, controlled by the school setting +`hideCourseLitBranding`, as on `main`. It appears in four places: + +1. **The website:** a strip owned by CourseLit, under every public page. + - `apps/storefront/components/site-page-renderer.tsx` renders a thin + themed `Section` after `PageRenderer`, built from page-builder + primitives. + - Every public route renders through `PublicSitePage`: home, custom and + sales pages, blog, product, products, checkout, communities, join, + login and auth-complete. + - It sits outside the page layout, so editing the page or the layout + through the API can't remove it. FrontLit's footer block stays + unchanged, because a CourseLit badge doesn't belong in FrontLit. + - The admin's page editor preview shows it too. +2. **Platform emails to learners.** +3. **The footer of the course viewer's sidebar**, as on `main`. +4. **The footer of the learner dashboard's sidebar.** New. + +Rules: +- Every badge link is `rel="nofollow"` and carries + `utm_source=&utm_medium=badge&utm_campaign=powered_by`. +- The setting is honoured only when `schoolPlan` includes `remove_branding`. + During a trial, the checkbox is disabled with a note that removing + branding comes with any paid plan. +- Marketing email gets no badge. SendLit renders it, and we won't change + SendLit for this. CourseLit's SendLit organization must keep + `marketingBranding` off, or every school's marketing email would say + "Sent with SendLit". +- Not covered, as on `main`: the CourseLit logo in loading spinners, the + fallback favicon and title, and the platform sender on learner emails. + Staff emails (team invites, admin sign-in) stay branded on every plan. +- Today the rewrite has no badges. The admin's "Remove CourseLit branding" + checkbox (`apps/admin/components/settings/branding-settings.tsx`) isn't + saved: it's local state only. Store the setting on the school and save it + from that checkbox. +- Hiding the badge with CSS through code injection stays possible, as on + `main`. + +### Code injection + +Pasted scripts are the easiest way to build a phishing page. Creators still +need to test analytics, pixels and chat widgets before they pay. + +- With `code_injection` (Pro, Business, OSS), the storefront renders + `codeInjectionHead` and `codeInjectionBody` for everyone. +- Without it (a trial), the school can save the code, with a note that it + goes live with a paid plan. The storefront renders it **only for the + school's owner** (decided 2026-11-14). It doesn't render for other team + members, because a scammer could invite accomplices. +- How (S25): + - the public site-settings endpoint (`/v1/public/:id/site/settings`) leaves + the code out during a trial; + - the storefront fetches it through a separate authenticated call, with no + caching, using the signed-in learner session; + - the API returns the code only if that session's user is the school's + owner. +- Never decide this from a query parameter, a cookie the visitor controls + or a shared cache. Pages rendered with injected code are never shared + between visitors in a cache. + +### SCORM and the audit log + +- **SCORM:** without the feature, no new SCORM lessons can be added and + existing packages can't be replaced. Lessons that already exist keep + playing. +- **The audit log screen** is a paginated, newest-first list of the + school's `audit_events`: actor, action, resource and time. + - It's for people allowed to manage the school's settings. + - It has a list operation in the contract, with a description and a + read-only MCP tool. + - `billing.*` events are shown only to the owner in a browser session. + They're never included in the MCP tool or API-key responses (S9). + - On Pro, it's refused with the upgrade prompt. + +## 5. Limits + +### Values + +| Limit | Key | Trial | Pro | Business | +|---|---|---|---|---| +| Marketing emails per month | `marketing_emails_month` | 0 | 10,000 | 50,000 | +| Media storage | `storage_bytes` | 500 MB | 20 GB | 200 GB | + +These are starting values for small schools. Raising them later is easy; +lowering them hurts existing customers. +- **Email** costs about $0.10 per 1,000 sends. 10,000 covers a list of + 1,000–2,000 contacts with four or five broadcasts a month, plus drip and + sequences. We limit emails sent, not contacts, because sending is what + costs money. +- **Storage** costs about $0.02 per GB a month, but video bandwidth is the + real cost. The storage cap is our only indirect control on it. +- **No seat limit.** An extra admin costs us nothing. Invite spam is handled + by rate limits (S6). + +### The billing package says what's allowed + +New in `@codelitdev/billing`, next to `planFeatures`: + +```ts +createBilling({ + planFeatures: { + pro: ["remove_branding", "code_injection"], + business: ["remove_branding", "code_injection", SSO_FEATURE, "scorm", "audit_log"], + }, + planLimits: { + pro: { marketing_emails_month: 10_000, storage_bytes: 20 * GB }, + business: { marketing_emails_month: 50_000, storage_bytes: 200 * GB }, + }, +}); + +billing.getLimit(entityId, "storage_bytes"); // number | null (null = unlimited) +billing.limits(entityId); // every limit, for the usage screen +``` + +- In `oss` mode every limit is `null`. With no entitled plan, every limit is + `0`. +- When billing can't be read, the call throws. Callers refuse new usage and + never take away anything that already exists. +- Unlimited is never a default (S7): + - in `cloud` mode, the package checks at startup that every plan declares + every limit key; + - `getLimit` throws on an unknown key; + - only `oss` mode returns `null`. +- The package doesn't count usage. Counting stays in the app, as the + package's architecture doc (§4, §15) intends. +- CourseLit's `checkLimit(school, key, increment)` reads limits through + `schoolPlan`, so a trial gets its own limits. + +### Where each limit is enforced + +**Marketing email: SendLit does it.** +- Every school is its own SendLit team, and each team grant has a + `monthlyLimit`. SendLit reserves quota before each send, commits it when + the send succeeds, and releases it on failure. +- CourseLit sets the quota using SendLit's field names, `quota.monthlyLimit` + and `quota.dailyLimit` (S20). + - Provisioning only accepts positive numbers. So CourseLit provisions with + `monthlyLimit: 1`, then immediately sends `PATCH` with the plan's value + (0 for a trial). + - The integration is marked ready only after that `PATCH` is confirmed. + - Today CourseLit sends `quota.monthly`, which SendLit drops, so the quota + is unlimited (S3). +- The `sync_plan_features` job updates the quota on every plan change, with + `PATCH /provisioning/teams/:teamId`. +- A daily reconciliation compares each team's quota with its school's plan. +- As a backstop, the school teams' SendLit organization has + `defaultMonthlyLimit = 0` and `aggregateMonthlyLimit = 5,000,000`. These + are starting values; review them with real volume. + - We set them as operators, in SendLit's organization settings for + CourseLit's two organizations (`organization/routes.ts`), and record + them in the `infra` runbook. + - The platform organization's limits in section 6 are set the same way. + +**Storage: CourseLit does it, from its own `media` table** (`byte_size`, +per school). +- The check before an upload is only a courtesy. The real check is at + finalize (S1). +- Learner uploads have their own budget (S2). +- CourseLit shares one MediaLit account across all schools. MediaLit + enforces that account's total (`hasEnoughStorage`), so keep the total far + above what all schools use together, and monitor it. Otherwise uploads + fail for every school at once. + +### Going over a limit + +Block new usage; never delete anything. + +- **Email:** + - **Broadcasts and sequences:** SendLit runs them, and it already + reschedules them when the quota runs out + (`process-ongoing-sequence.ts`, using `retryAfterMs`). CourseLit does + nothing. + - **Drip:** CourseLit sends it (`drip-emails.ts`). When SendLit refuses a + send with `organization_team_quota_exhausted`: + - store `retryAfter` as `marketing_paused_until` on the school's + `school_integrations` row (provider `sendlit`); + - release the delivery claim, as the code already does. + - Drip runs skip the school until `marketing_paused_until` has passed, + and they don't log a warning for it. +- **Storage:** new uploads are refused, and existing files stay. +- **The error:** `plan_limit_reached { limit, used, allowed }`. + - The admin shows it as an upgrade prompt. + - Permission is checked first, and the numbers are left out for anyone + who can't see billing (S9). +- **Warnings:** at 80% of a limit, a banner plus one email to the owner, + sent through the staff stream (section 6). + - Email: at most once per calendar month (UTC). + - Storage: once each time usage crosses 80%. It can fire again after usage + drops below 70%. + - A trial gets no email-quota warnings. Instead it shows a note that + sending starts with a paid plan. + +### Showing usage + +- `GET /v1/school/billing/usage` returns `used` and `allowed` for each + limit. Email usage comes from SendLit's + `GET /provisioning/teams/:teamId/usage`. +- Plan settings shows a meter for each limit. +- For MCP, see S9. + +## 6. Email routing + +Learner mail goes out under CourseLit's reputation, so it runs in **CourseLit's +own SendLit organization**, separate from the organization that holds +school teams (S21). One school's marketing therefore can't use up the +platform's sending, and the platform's suppression list can't be filled by +schools' marketing. + +| Stream | Mail | Sent by | Budget | +|---|---|---|---| +| Staff | Admin sign-in, team invites, owner notices (read-only, deletion warnings, 80% limit warnings) | Team `platform-staff` (`sendSystemMail`) | team invites: 50 per school per day (S6) | +| Sign-in codes | Learner sign-in codes | Team `platform-sign-in` | per-address, per-school, per-IP and platform-wide limits | +| Transactional | Receipts, access granted, product and community invites, lead-magnet delivery, newsletter confirmations, SSO notices to the owner | Team `platform-transactional` | the school's daily transactional cap. Product and community invites also count toward the invite limit of 50 per school per day (S6). | +| Notifications | Community notifications | Team `platform-notifications` | **its own** daily cap per school; once it's used up, notifications are in-app only for the rest of the day | +| Marketing | Broadcasts, sequences, drip emails | The school's SendLit team | `marketing_emails_month` | + +- **SendLit limits for the platform organization** (starting values; + review them with real volume): + + | Setting | Value | + |---|---| + | `aggregateMonthlyLimit` | 1,000,000 | + | `platform-staff` | 50,000 a month | + | `platform-sign-in` | 200,000 a month | + | `platform-transactional` | 300,000 a month | + | `platform-notifications` | 450,000 a month | + + The organization total is the sum of the four teams, on purpose: each + team's monthly limit is the ceiling for that stream across all schools. + The daily caps per school (section 7) sit under those limits. Each team + alerts at 80%. +- **Today, learner mail uses the school's team.** `queueSchoolEmail` + (`school-emails.ts`) and notification emails + (`notifications.ts`, then `queueNotificationEmail`) send through it. Both + move to the platform streams above. +- **Sign-in codes get their own team.** They then never share SendLit's + per-team rate limit (120 a minute) or suppression list with receipts and + notifications. A complaint about one school's receipt can't block anyone's + sign-in codes. +- **The sender** (decided 2026-11-14): + - **Target:** the school's name as the From name, at the platform's + address, as on `main`. SendLit pins one sender name per team, so this + needs a generic SendLit feature: a sender name per message, restricted + to the school's name, which is already validated under S14. + - **Until SendLit has it:** the From name is "CourseLit", and the subject + names the school (for example "Sign in to "). + - **Reply-To:** the reply-to address from the school's mailing settings, + if one is set. `main` set no Reply-To on these emails. The rewrite has no + reply-by-email for notifications. +- **Tracking each school (S14):** this needs another generic SendLit + feature, a tag on each message. CourseLit tags every platform email with + its school, so bounces and complaints can be counted per school. +- **Links:** platform mail links to the school's verified custom domain only + once the school is on a paid plan. Until then, it links to + `.courselit.app` (S31). +- **Marketing never falls back to the platform.** Today + `schoolEmailSender` switches to the platform when a school's integration + isn't usable. Marketing mail waits instead. + +## 7. Security requirements + +From three security reviews on 2026-11-14, against the code as it stood that +day. "Exists today" means the bug is in the current code. + +### Rate limits and caps + +Starting values, decided 2026-11-14. Each limit is enforced on the server and +returns the standard rate-limit error. The caps on platform mail alert at +80%. + +| What | Limit | Requirement | +|---|---|---| +| Learner sign-in codes | 5 per address per hour, 50 per school per hour, 20 per IP per hour | S14 | +| Lead-magnet delivery | 3 per address per day, 100 per school per day, 20 per IP per hour | S14 | +| Learner sign-in codes, platform-wide | 10 per address per hour, across all schools | S28 | +| Lead-magnet delivery, platform-wide | 10 per address per day, across all schools | S28 | +| Platform transactional mail per school | trial 200 a day, Pro 2,000, Business 10,000 | S14 | +| Community notification email per school | trial 500 a day, Pro 5,000, Business 25,000; after that, in-app only | S22 | +| Learner uploads per school | at most 25% of `storage_bytes` in total (S24); refused once the school is above 90% of `storage_bytes` (S2) | S2, S24 | +| Invites (team, product and community, counted together) | 50 per school per day; at most 200 pending | S6 | +| School creation | 3 per account per day | S15 | +| Learner uploads | 20 per learner per hour, on top of the 100 MB budget | S2 | + +### Storage + +**S1. The storage check can be bypassed (high, exists today).** +- **The problem:** + - `MediaLitClient.authorizeUpload` (`apps/api/src/media-lit-client.ts`) + throws away the declared size and type. The MediaLit signature covers + only `group: schoolId`. + - MediaLit deletes a signature only when an upload finishes, so one + signature allows several uploads at once. + - This is CourseLit's flaw, not MediaLit's: MediaLit enforces its own + account limits. +- **The fix:** + 1. At finalize, call `getAsset` and check that the asset's group is this + school **before** sealing anything. MediaLit's seal and delete aren't + scoped to a group, so the wrong order lets school A seal or delete + school B's uploads. The admin and learner finalize paths in `media.ts` + currently seal first. + 2. Inside a transaction that locks the school, add MediaLit's reported + size. If it doesn't fit, delete the asset and refuse. + 3. Reserve the declared size when the upload is authorized, and count + open reservations until finalize or expiry. +- **Already handled:** MediaLit deletes uploads that are never finalized + after `TEMP_MEDIA_EXPIRATION_HOURS`. +- **Later:** a generic MediaLit feature where signatures carry `maxBytes` + and allowed MIME types. It's not required. + +**S2. Learners can use up a school's storage (high).** Learners upload +community media (up to 100 MB each) and avatars into the school's `media` +table. +- Each learner gets a budget, **100 MB by default**. Admins can change it in + `/settings`, up to 1 GB. Learner uploads are also rate-limited to 20 per + learner per hour. +- Learner uploads count toward the school's total, but they're refused once + the school passes 90% of `storage_bytes`. The last 10% is kept for admins. +- Errors shown to learners stay generic, with no plan or usage numbers. + +### Email + +**S3. The email limit fails open (high, exists today).** Covered in +section 5: set the quota at provisioning, update it on every plan change, +reconcile daily, and keep the organization-wide backstop limits. + +**S6. Invite spam (medium).** Team invites go through `platform-staff`, and +product and community invites through `platform-transactional` (section 6). +Team, product and community invites are limited to 50 per school per day, +with at most 200 pending. + +**S14. Platform mail carries the school's own text (high).** +- **Fixed templates.** CourseLit writes every platform email. + - The only school-supplied parts are short, plain fields: the school name, + product and community titles, and an invite message if there is one. + They're escaped and length-capped. + - The only links are CourseLit-generated links to the school's own host. + - School names are restricted when they're saved: limited length, and no + URLs or email addresses. Today the sign-in subject is + `Sign in to ${schoolName}`, with up to 200 free characters. +- **Rate limits on anything someone can trigger at an arbitrary address:** + - sign-in codes: per address, per school and per IP. Today the only limit + is per school and address, per hour. + - lead-magnet delivery: per address, per school and per IP; + - a captcha on both, once Turnstile ships. +- **A daily cap per school** on platform transactional mail: trial 200, Pro + 2,000, Business 10,000, with an alert at 80%. Notifications have their own + cap (S22). +- **On the platform teams:** use the limits in section 6, and track bounces + and complaints per school through message tags (S21), so a bad school can + be found and stopped. + - Until SendLit has tags, the daily caps per school and the suspend + command (S26) are the controls. + - CourseLit also logs every platform send with its school and SendLit + message ID, so a complaint can be traced back to its school by hand. + +(S5 and S13 are merged into section 6 and S14.) + +### Plans and billing + +**S7. "Unlimited" must be explicit (medium).** See section 5. +`runtime.ts` defaults `billingMode` to `cloud`. Keep it that way: never +default to `oss`. + +**S8. LemonSqueezy test-mode events (deferred).** LemonSqueezy uses the same +store ID in test and live mode, and the package's provider doesn't check +`meta.test_mode`. Until the package rejects mismatched events, keep the +test and live webhook secrets different. Checkouts are already tied to a +school through a `checkoutAttemptId` made on the server. + +**S9. Billing information over MCP (low).** +- Reading the plan is owner-only, needs a browser session, and isn't + available over MCP (`operation-policy.ts`, `mcp-parity.ts`). +- **Decided 2026-11-14:** the usage endpoint follows the other billing + operations. It's owner-only and needs a browser session. It's kept off + MCP, with a parity exemption in `mcp-parity.ts` and an entry in + `REACHABLE_BUT_EXEMPT` explaining why. +- Limit errors shown to non-owner admins leave out the numbers. +- If limits are cached, keep the cache short and clear it in the sync job. + +**S15. Read-only mode doesn't exist yet (high).** Covered in section 3. + +**S17. The billing package grants too much (medium; fixed in step 1).** +- **The problem,** in `@codelitdev/billing` 0.11.0: + - `past_due` stays entitled with no time limit; + - LemonSqueezy's `paused` maps to `past_due`. + - LemonSqueezy's own retries usually end in `unpaid`, which isn't + entitled. But a lost webhook would leave the school entitled for good. +- **The fix, in the package:** + - `past_due` stays entitled only until `paidThroughAt` plus a grace + period the app sets. CourseLit sets **7 days**, the same past-due grace + that section 3 uses; + - `paused` isn't entitled; + - every paid status, including `active` and `trialing`, stays entitled + only until `paidThroughAt` plus the grace period (S29). A lost webhook + then can't grant access for good. + - The fix is generic: SendLit and MediaLit use the same rules. +- **In our LemonSqueezy store:** no trial on the products, and pausing + turned off. + +**S18. A paid plan wins over the trial.** Covered in section 3. + +### Sign-in and SSO + +**S10. "SSO required" can lock learners out (high, exists today, from +#879).** +- **The problem:** SSO enforcement (`sign-in-policy.ts`) checks only that + the connection is active. SSO sign-in (`learner-sso-sign-in.ts`) also + checks the plan. If the plan says no while the connection is still + active, learners in enforced domains can't sign in at all. +- **The fix:** + - If the plan **can't be read**, follow the stored connection state: keep + enforcing, and allow SSO sign-in. + - Drop enforcement only after a **confirmed** lapse, meaning the plan was + read and doesn't include SSO. + - Never let enforced domains fall back to email codes because of a billing + error. That would let an offboarded employee in. + +**S11. The SSO sync must know about trials (high).** +- `syncPlanFeatures` and its daily sweep call billing directly + (`planIncludesFeatureOrThrow`). They would suspend SSO on every trialing + school, so they must go through `schoolPlan`. +- The lifecycle job queued at school creation (section 3) handles the trial's + end. The daily sweep stays as the backstop. +- Tests: + - SSO can be turned on during a trial; + - it's suspended when the trial ends without Business; + - it stays on when Business is bought before the trial ends. + +### Abuse + +**S12. Abuse by trial schools.** +- **The risk is to our reputation, not our money.** Schools use their own + payment accounts, so the gateway checks identity and absorbs chargebacks. + The real danger is a scam or phishing site on `.courselit.app`. A + phishing page has been reported on a Teachable subdomain. +- **What competitors do:** + - Teachable, Podia and Kajabi publish an acceptable use policy, an abuse + address and a copyright takedown process. + - Teachable, Podia, Thinkific and Circle serve schools on their main + domain, and none of those domains is on the Public Suffix List. +- **What we do:** + 1. An acceptable use policy page. + 2. An `abuse@` address, and a "Report this site" link to a fixed CourseLit + URL on every storefront page, including the certificate page, on every + plan (S30). + 3. A staff CLI command that suspends a school straight away, turning off + its storefront and email (S26). + 4. Owner-only code injection during a trial (section 4). + 5. Schools stay on `.courselit.app`. Admin and learner sessions + already use `__Host-` cookies, so a school's site can't plant cookies + on `app.courselit.app`. + 6. Live payments are **not** blocked during a trial. + 7. A small record kept for 12 months after deletion. This belongs to the + deletion change. + +**S19. Reserved and reused subdomains (medium).** +- **Now:** add a reserved list to the API contract's subdomain validation: + `www`, `app`, `api`, `admin`, `mail`, `login`, `billing`, `support`, + `status`, `docs` and so on. +- **With the deletion change:** hold a deleted school's subdomain for 90 + days before anyone else can claim it. That protects learners' bookmarks, + old email links and SAML traffic. + +### For the deletion change + +**S16. Deleting safely.** +- As on `main`, cancel subscriptions first and delete afterwards: + - the school's CourseLit subscription; + - learners' recurring and installment subscriptions in the school's own + gateway, with an email to those learners. +- Block deletion while checkouts are open, webhooks are unprocessed or plan + changes are in progress (`getBillableEntityBillingBlockers`). Process the + school's pending webhooks while it's locked. +- Only the owner can request or cancel a deletion, after signing in + recently, and never through MCP or API keys. + +### From the final audit (2026-11-14; all decided) + +**S20. The trial's "quota 0" would be unlimited (high, exists today).** +- CourseLit's client sends `quota.monthly` and `quota.daily` + (`sendlit-client.ts`). SendLit expects `monthlyLimit` and `dailyLimit`, and + drops unknown fields. The grant then falls back to the organization's + `defaultMonthlyLimit`, where null means unlimited. +- Provisioning also requires a positive number, so 0 is rejected. Only + `PATCH` accepts 0. +- Fix: + - use SendLit's field names; + - set the organization's `defaultMonthlyLimit` to 0, so a missing quota + fails closed; + - mark the integration ready only after a `PATCH` that sets the quota has + been confirmed; + - add a contract test between CourseLit's client and SendLit's schema. + +**S21. The platform team is shared by every school (high).** +- **Shared limits:** + - SendLit allows 120 sends a minute per team (`transactional/routes.ts`), + so that is the ceiling for every school's sign-in codes together. + - Suppression is per team. One complaint about school A's receipt blocks + that person's sign-in codes from every school, and their admin sign-in + too. +- **Shared organization:** this is why the platform teams get their own + organization. If they shared one with school teams, one school's + marketing could use up the organization's `aggregateMonthlyLimit` and stop + all sign-in mail. +- **Two things section 6 needs that SendLit can't do yet:** + - The sender name is pinned per team, so "From: the school's name" can't + be set per email. + - The send API takes no per-school tag, so bounces and complaints can't be + tracked per school (S14). +- **Decided:** the platform teams live in their own SendLit organization. + Sign-in codes have their own team. SendLit gets two generic features: a + sender name per message and tags per message. Until then, the From name + is "CourseLit". See section 6. + +**S22. Community notifications are platform mail sent in bulk (high).** +- Every new post emails each active member (`communities.ts`, then + `notifications.ts`, then `queueNotificationEmail`). That makes it + platform mail sent to many recipients at once. +- A learner posting in a large community could use up the school's daily + platform cap, which would block that school's sign-in codes and receipts. +- Admins would also gain an unmetered broadcast channel. +- **Decided:** notifications are their own platform stream, with their own + daily cap per school (see the rate-limit table). Once a school's cap is + used up, notifications for the rest of the day are in-app only. Nothing + sent to many recipients shares a cap with sign-in codes or receipts. + +**S23. Billing errors must not start a school's grace period (high).** +- `hasFeature` and `schoolHasFeature` answer no when billing can't be read. + If `schoolPlan` did the same, a billing outage would make paying schools + look expired: read-only, with deletion warnings sent to their owners. +- **Decided:** `schoolPlan` is built on `commercialState` and has an + `unknown` result. The lifecycle job does nothing on `unknown`. See + section 3. + +**S24. Learner uploads can be abused for public hosting (high).** +- `authorizeLearnerCommunityMediaUpload` needs only a learner session, and + anyone can get one with an email code. +- The learner chooses `accessPolicy: "public"` and declares the MIME type + themselves. So any school becomes free public hosting for phishing or + malware files, and throwaway learners can fill the school's storage up to + the 90% cut-off. +- **Decided:** + - learner uploads require an active community membership with permission + to post; + - access is always private, whatever the client asks for; + - uploads that no post uses after 24 hours are deleted; + - learner uploads together can use at most 25% of the school's + `storage_bytes`; + - at finalize, the type MediaLit detects must be an image, video, audio or + PDF. + +**S25. Trial code injection needs its own path (medium; decided: owner only).** +- Injected code is served by the public, unauthenticated + `/v1/public/:id/site/settings` endpoint (`dispatch.ts`). The storefront + caches that response for everyone for 60 seconds, and renders the code in + the layout. +- During a trial: + - leave the code out of the public endpoint; + - the storefront fetches it through an authenticated call, with no + caching, using the signed-in learner session; + - the API returns the code only to the school's owner. See section 4. + +**S26. Staff actions and school states need a home (medium).** +- No platform-staff role exists, and admin sign-in has no second factor. + **Decided:** staff actions are a **CLI** run on the API server + (`bun run staff `), not a web role. Running it requires access to + the server, which is the strongest check we have. The commands are: + - `suspend ` and `unsuspend `; + - `import-school --owner-email --name --subdomain + --main-school `: creates an `imported` school owned by that + account, then runs the existing content importer; + - `remove-imported `. + + Each command is written to a new `platform_audit_events` table: who ran + it, the command, the school's ID and slug, and the time. The table has no + foreign key to schools, so its rows survive deletion. (`audit_events` + rows are deleted along with the school.) +- The offline states and `read_only` are enforced in one central place: + `dispatch`, the worker and the custom-domain lookup. The allowlist is in + section 3. Today the public and learner endpoints can be called directly: + sign-in codes, lead magnets, checkout and uploads. + +**S27. The imported flag's lifecycle (medium).** +- **Decided:** see "Where school state lives" in section 3. + - `imported` becomes `active` at the first checkout. + - Staff create imported schools. + - `pending_checkout` covers an account whose trial is used up, and it + expires after 7 days. + - Only the lifecycle job and the staff CLI write the state fields. + +**S28. Limits that are only per school (medium).** +- Sign-in codes and lead magnets are rate-limited per school + (`rate-limit.ts`). One address could be flooded from many schools. +- **Decided:** add a platform-wide limit per recipient address: 10 sign-in + codes an hour and 10 lead magnets a day, across all schools (see the + rate-limit table). + +**S29. Every paid status needs an end date (medium).** +- The billing package keeps `active` and `trialing` entitled with no limit, + as well as `past_due`. A lost webhook grants access for good, whatever + the status. +- Extend S17: every paid status stays entitled only until `paidThroughAt` + plus the grace period. + +**S30. Smaller fixes (low).** +- **Audit log:** it must hide `billing.*` events from anyone who isn't the + owner, and keep them out of MCP entirely (S9). +- **"Report this site":** it needs a fixed CourseLit URL that works on every + page, including the certificate page. A badge strip can be removed by a + paid plan or hidden with CSS. + +**S31. Custom domains during a trial (medium; decided: keep them, with slug links in mail).** +- The trial includes custom domains, and `schoolSiteUrl` prefers a school's + verified custom host. So platform mail, sent under CourseLit's reputation, + would link to a domain a trial school chose. +- **Decided:** a trial can still connect a custom domain, but platform mail + links to `.courselit.app` until the school is on a paid plan. + +## 8. Out of scope + +### School deletion (a separate change) + +It's irreversible and touches all three products. Deleting a school when its +owner asks will reuse it. + +- **SendLit:** an endpoint that hard-deletes a team and everything in it. + Today `DELETE /provisioning/teams/:teamId` only archives. +- **MediaLit:** delete every file in a group. It can't do this today. +- **CourseLit:** + - a purge job across every school-scoped table, safe to repeat and able + to resume after a partial failure; + - a dry run; + - a final check that the school still has no plan; + - an audit record that outlives the school. +- **Also:** S16, the 90-day subdomain hold (S19), and the 12-month record + (S12). + +### Buying more email or storage (later) + +In v1, a school that hits a limit upgrades or waits for the next month. + +- **Later: prepaid packs,** such as 10,000 more emails, not overage billed + afterwards. Overage would let a spammer send first and never pay. +- **What packs need:** + - one-time offers and a credit ledger in `@codelitdev/billing` (it only + supports recurring plans today); + - support for the `order_created` and `order_refunded` webhooks in its + LemonSqueezy provider. +- **How packs would work:** + - email credits raise the SendLit team's monthly quota; + - packs last 12 months and survive plan changes; + - a refund takes back only the unused credits; + - credits are granted only for a paid order of a known pack at the + expected amount; + - the order ID is the idempotency key. + +## 9. Order of work + +The trial isn't switched on for anyone until these are done: +- steps 0–4; +- S25 (owner-only code injection) from step 5; +- the acceptable use policy, the `abuse@` address and the "Report this + site" link from step 9. + +The rest of steps 5–10 can then follow in any order. + +0. **Payment provider.** Replace Dodo in `apps/api/src/billing.ts` with the + package's `createLemonSqueezyBillingProvider`. Copy MediaLit's setup: + - `medialit/apps/api/src/billing/providers.ts`: + - `LEMONSQUEEZY_API_KEY` + - `LEMONSQUEEZY_STORE_ID` + - `LEMONSQUEEZY_WEBHOOK_SECRET` and `LEMONSQUEEZY_WEBHOOK_SECRET_PREVIOUS` + - `medialit/apps/api/src/billing/catalog.ts`: `BILLING_PROVIDER`, and the + product ID and amount variables. + + Set the new prices, and start a new `BILLING_CATALOG_REVISION`. In our + LemonSqueezy store, give the products no trial, and keep pausing turned + off. +1. **Foundations:** + - **`@codelitdev/billing`,** then release: + - `planLimits`, `getLimit` and `limits`, tested for OSS mode, no plan, + read errors and unknown keys; + - the S17 and S29 fixes. + - **CourseLit:** + - upgrade to that release and declare `planFeatures` and `planLimits`; + - the state columns and tables (section 3); + - `schoolPlan` with the full result union (S23); + - `checkLimit` and `plan_limit_reached`; + - central enforcement of offline and `read_only` states (S26). +2. **SendLit quota (S20, S3).** + - SendLit's field names, and provisioning followed by a `PATCH`. + - The school organization's default of 0. + - A contract test. + - Quota set from `schoolPlan`, synced on plan changes and reconciled + daily. + - Drip pausing with `marketing_paused_until`. +3. **Email routing (section 6), S14, S21, S22 and S28:** + - the platform organization and its four teams; + - learner mail and notifications moved off school teams; + - S14's templates and caps, and the per-address limits; + - links on the slug host during a trial. + - **SendLit, in parallel:** a sender name per message, and tags per + message. Both are generic. Switch the From name to the school's name + once they ship. +4. **The trial and its lifecycle (section 3):** + - `school_trials` and one trial per account; + - `pending_checkout`; + - the onboarding change; + - `evaluate_school_lifecycle` and its triggers; + - the read-only allowlist; + - the owner emails; + - imported schools; + - the staff CLI and `platform_audit_events` (S26); + - the SSO fixes, S10 and S11. +5. **Plan features (section 4):** + - the four badges and a working checkbox; + - code injection for the owner only during a trial, through its own path + (S25); + - SCORM gating; + - the audit log screen, with `billing.*` events for the owner only (S30). +6. **Invite limits (S6).** +7. **Storage:** + - the check at finalize, and reservations (S1); + - `learnerUploadLimitBytes` (S2); + - S24: membership required, private access, deleting unused uploads, + the 25% learner share, and the type allowlist. +8. **Usage:** the usage endpoint (owner only, off MCP with an exemption; + S9), meters and 80% warnings. +9. **Abuse handling (S12, S19, S30):** + - the acceptable use policy; + - the `abuse@` address; + - the fixed "Report this site" link on every page; + - the reserved subdomains. +10. **Docs in `frontlit/docs`:** + - the trial; + - plans and prices; + - limits, and what happens at a limit; + - what happens when a trial or subscription ends. From ef14c0185ea9c1b0236f981e7e9a11d83e4ca53f Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 14 Nov 2026 19:16:59 +0530 Subject: [PATCH 02/27] feat(api): bill school plans through lemon squeezy instead of dodo --- apps/api/.env.example | 44 ++++---- apps/api/package.json | 1 - apps/api/src/billing-config.test.ts | 99 ++++++++++++++++++ apps/api/src/billing-webhook-route.test.ts | 34 ++++++ apps/api/src/billing.ts | 116 ++++++++++++--------- apps/api/src/express-app.ts | 4 +- bun.lock | 9 -- 7 files changed, 224 insertions(+), 83 deletions(-) create mode 100644 apps/api/src/billing-config.test.ts create mode 100644 apps/api/src/billing-webhook-route.test.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 7dc8551b1..f6ff5431d 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -67,35 +67,35 @@ SENDLIT_SERVER=http://127.0.0.1:4101 # Billing. cloud requires a paid plan to create a school; oss allows unlimited schools. BILLING_MODE=cloud # BILLING_MODE=oss -# Cloud billing. With no BILLING_CHECKOUT_PROVIDER, dev uses the Fake provider. -# BILLING_CHECKOUT_PROVIDER=dodo makes everything below required: startup fails -# (e.g. BILLING_PRO_MONTH_AMOUNT_MINOR_required) instead of guessing a price. The -# amounts, currency and product ids must match the Dodo products, or checkout stays -# off. Revision numbers are unique across providers, so raise BILLING_CATALOG_REVISION -# whenever prices, product ids or the provider change. -# BILLING_CHECKOUT_PROVIDER=dodo +# Cloud billing (CourseLit's own Pro and Business plans). With no BILLING_PROVIDER, +# dev uses the Fake provider. BILLING_PROVIDER=lemonsqueezy makes everything below +# required: startup fails (e.g. BILLING_PRO_MONTH_AMOUNT_MINOR_required) instead of +# guessing a price. The amounts, currency and variant ids must match the Lemon Squeezy +# variants, or checkout stays off. The variants must have no trial: the 14-day trial +# is CourseLit's own. Revision numbers are unique across providers, so raise +# BILLING_CATALOG_REVISION whenever prices, variant ids or the provider change. +# BILLING_PROVIDER=lemonsqueezy # BILLING_CATALOG_REVISION=1 # BILLING_CURRENCY=USD -# BILLING_PRO_MONTH_AMOUNT_MINOR= -# BILLING_PRO_YEAR_AMOUNT_MINOR= -# BILLING_BUSINESS_MONTH_AMOUNT_MINOR= -# BILLING_BUSINESS_YEAR_AMOUNT_MINOR= -# DODO_PRO_MONTH_PRODUCT_ID=pdt_... -# DODO_PRO_YEAR_PRODUCT_ID=pdt_... -# DODO_BUSINESS_MONTH_PRODUCT_ID=pdt_... -# DODO_BUSINESS_YEAR_PRODUCT_ID=pdt_... -# Optional. Must match the trial set on the Dodo product. Defaults to 0. -# BILLING_PRO_MONTH_TRIAL_DAYS=0 +# BILLING_PRO_MONTH_AMOUNT_MINOR=1900 +# BILLING_PRO_YEAR_AMOUNT_MINOR=19000 +# BILLING_BUSINESS_MONTH_AMOUNT_MINOR=7900 +# BILLING_BUSINESS_YEAR_AMOUNT_MINOR=79000 +# BILLING_PRO_MONTH_PRODUCT_ID=123456 # Lemon Squeezy variant ids +# BILLING_PRO_YEAR_PRODUCT_ID=123457 +# BILLING_BUSINESS_MONTH_PRODUCT_ID=123458 +# BILLING_BUSINESS_YEAR_PRODUCT_ID=123459 # BILLING_DATA_ENCRYPTION_KEY= # base64 of 32 bytes: openssl rand -base64 32. Keep it per environment. # BILLING_DATA_ENCRYPTION_KEY_PREVIOUS= # optional, comma-separated, during rotation -# DODO_PAYMENTS_API_KEY= -# DODO_PAYMENTS_ENVIRONMENT=test_mode -# DODO_PAYMENTS_WEBHOOK_KEY_CURRENT=whsec_... # endpoint: POST /webhooks/billing/dodo -# DODO_BRAND_ID=brnd_... # optional; ignores other brands' webhooks +# LEMONSQUEEZY_API_KEY= +# LEMONSQUEEZY_STORE_ID= +# LEMONSQUEEZY_WEBHOOK_SECRET= # endpoint: POST /webhooks/billing/lemonsqueezy +# LEMONSQUEEZY_WEBHOOK_SECRET_PREVIOUS= # optional, during rotation +# Use different webhook secrets for test mode and live mode: the store id is the same in both. # Cloud billing safety. Both are optional. # BILLING_WEBHOOK_RATE_LIMIT_PER_MINUTE=300 # per process, for POST /webhooks/billing/:provider -# Production cloud billing refuses to start without BILLING_CHECKOUT_PROVIDER=dodo; set +# Production cloud billing refuses to start without BILLING_PROVIDER=lemonsqueezy; set # BILLING_MODE=oss for a deployment that has no hosted plans. # Optional observability integrations. diff --git a/apps/api/package.json b/apps/api/package.json index 7efd73996..660cc3833 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -53,7 +53,6 @@ "@xmldom/xmldom": "0.8.15", "better-auth": "1.7.22", "better-call": "1.4.5", - "dodopayments": "^2.61.1", "drizzle-orm": "0.45.2", "express": "4.22.3", "express-rate-limit": "^7.5.0", diff --git a/apps/api/src/billing-config.test.ts b/apps/api/src/billing-config.test.ts new file mode 100644 index 000000000..0d5ee2fb1 --- /dev/null +++ b/apps/api/src/billing-config.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "bun:test"; +import { readLemonSqueezyConfig } from "./billing.js"; + +function lsEnv(overrides: Record = {}): NodeJS.ProcessEnv { + return { + NODE_ENV: "production", + BILLING_PROVIDER: "lemonsqueezy", + BILLING_CATALOG_REVISION: "3", + BILLING_CURRENCY: "usd", + BILLING_PRO_MONTH_AMOUNT_MINOR: "1900", + BILLING_PRO_YEAR_AMOUNT_MINOR: "19000", + BILLING_BUSINESS_MONTH_AMOUNT_MINOR: "7900", + BILLING_BUSINESS_YEAR_AMOUNT_MINOR: "79000", + BILLING_PRO_MONTH_PRODUCT_ID: "1001", + BILLING_PRO_YEAR_PRODUCT_ID: "1002", + BILLING_BUSINESS_MONTH_PRODUCT_ID: "1003", + BILLING_BUSINESS_YEAR_PRODUCT_ID: "1004", + BILLING_DATA_ENCRYPTION_KEY: "a2V5LWtleS1rZXkta2V5LWtleS1rZXkta2V5LWtleXk=", + LEMONSQUEEZY_API_KEY: "ls-key", + LEMONSQUEEZY_STORE_ID: "4242", + LEMONSQUEEZY_WEBHOOK_SECRET: "whsec-current", + ...overrides, + } as NodeJS.ProcessEnv; +} + +describe("readLemonSqueezyConfig", () => { + it("uses the fake provider when no provider is set", () => { + expect(readLemonSqueezyConfig(lsEnv({ BILLING_PROVIDER: undefined }))).toBeNull(); + }); + + it("never uses a real provider under test", () => { + expect(readLemonSqueezyConfig(lsEnv({ NODE_ENV: "test" }))).toBeNull(); + }); + + it("rejects an unknown provider, including the removed dodo", () => { + expect(() => readLemonSqueezyConfig(lsEnv({ BILLING_PROVIDER: "dodo" }))).toThrow( + "BILLING_PROVIDER_invalid", + ); + }); + + it("reads every offer, with no provider trial", () => { + const config = readLemonSqueezyConfig(lsEnv()); + expect(config).not.toBeNull(); + expect(config!.revision).toBe(3); + expect(config!.storeId).toBe("4242"); + expect(config!.webhookSecrets).toEqual([{ version: "current", secret: "whsec-current" }]); + expect(config!.offers).toEqual([ + { key: "pro_month", currency: "USD", amountMinor: 1900, providerProductId: "1001", providerTrialDays: 0 }, + { key: "pro_year", currency: "USD", amountMinor: 19000, providerProductId: "1002", providerTrialDays: 0 }, + { key: "business_month", currency: "USD", amountMinor: 7900, providerProductId: "1003", providerTrialDays: 0 }, + { key: "business_year", currency: "USD", amountMinor: 79000, providerProductId: "1004", providerTrialDays: 0 }, + ]); + }); + + it("keeps the previous webhook secret during rotation", () => { + const config = readLemonSqueezyConfig(lsEnv({ LEMONSQUEEZY_WEBHOOK_SECRET_PREVIOUS: "whsec-old" })); + expect(config!.webhookSecrets).toEqual([ + { version: "current", secret: "whsec-current" }, + { version: "previous", secret: "whsec-old" }, + ]); + }); + + it.each([ + "LEMONSQUEEZY_API_KEY", + "LEMONSQUEEZY_STORE_ID", + "LEMONSQUEEZY_WEBHOOK_SECRET", + "BILLING_DATA_ENCRYPTION_KEY", + "BILLING_CATALOG_REVISION", + "BILLING_CURRENCY", + "BILLING_BUSINESS_YEAR_AMOUNT_MINOR", + "BILLING_PRO_MONTH_PRODUCT_ID", + ])("fails at startup without %s", (name) => { + expect(() => readLemonSqueezyConfig(lsEnv({ [name]: undefined }))).toThrow(`${name}_required`); + }); + + it.each(["0", "-1", "19.5", "1e3", "abc"])("rejects the amount %s", (amount) => { + expect(() => readLemonSqueezyConfig(lsEnv({ BILLING_PRO_MONTH_AMOUNT_MINOR: amount }))).toThrow( + "BILLING_PRO_MONTH_AMOUNT_MINOR_invalid", + ); + }); + + it("rejects a variant id that isn't numeric", () => { + expect(() => readLemonSqueezyConfig(lsEnv({ BILLING_PRO_YEAR_PRODUCT_ID: "pdt_123" }))).toThrow( + "BILLING_PRO_YEAR_PRODUCT_ID_invalid", + ); + }); + + it("rejects two offers sharing a variant", () => { + expect(() => readLemonSqueezyConfig(lsEnv({ BILLING_BUSINESS_YEAR_PRODUCT_ID: "1001" }))).toThrow( + "BILLING_PRODUCT_IDS_must_be_unique", + ); + }); + + it("rejects a currency that isn't a three-letter code", () => { + expect(() => readLemonSqueezyConfig(lsEnv({ BILLING_CURRENCY: "dollars" }))).toThrow( + "BILLING_CURRENCY_invalid", + ); + }); +}); diff --git a/apps/api/src/billing-webhook-route.test.ts b/apps/api/src/billing-webhook-route.test.ts new file mode 100644 index 000000000..718265d75 --- /dev/null +++ b/apps/api/src/billing-webhook-route.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "bun:test"; +import { createExpressApp } from "./express-app.js"; +import { createPgliteRuntime, freezeRuntimeClock } from "./runtime.js"; + +describe("platform billing webhook route", () => { + it("accepts Lemon Squeezy webhooks and no longer routes Dodo", async () => { + const clock = freezeRuntimeClock(new Date("2026-03-01T00:00:00.000Z")); + const runtime = await createPgliteRuntime({ clock }); + const app = createExpressApp(runtime); + const server = await new Promise>((resolve) => { + const listening = app.listen(0, () => resolve(listening)); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("listen_failed"); + const base = `http://127.0.0.1:${address.port}/webhooks/billing`; + const post = (provider: string) => + fetch(`${base}/${provider}`, { + method: "POST", + headers: { "content-type": "application/json", "x-signature": "not-a-signature" }, + body: JSON.stringify({ meta: { event_name: "subscription_created" }, data: {} }), + }); + try { + expect((await post("dodo")).status).toBe(404); + // The route reaches the billing engine, which refuses an unsigned body. It is + // never treated as an unknown route. + const lemonsqueezy = await post("lemonsqueezy"); + expect(lemonsqueezy.status).not.toBe(404); + expect(lemonsqueezy.status).toBeGreaterThanOrEqual(400); + } finally { + server.close(); + await runtime.close(); + } + }); +}); diff --git a/apps/api/src/billing.ts b/apps/api/src/billing.ts index e9bc7605c..89f9a9626 100644 --- a/apps/api/src/billing.ts +++ b/apps/api/src/billing.ts @@ -6,7 +6,7 @@ import { createReturnUrlValidator, } from "@codelitdev/platform/billing"; import { drizzleVerificationGrantStore } from "@codelitdev/platform/billing/drizzle"; -import { createDodoBillingProvider } from "@codelitdev/billing/providers/dodo"; +import { createLemonSqueezyBillingProvider } from "@codelitdev/billing/providers/lemonsqueezy"; import { COURSELIT_OFFER_KEYS, REFERENCE_OFFERS } from "@codelitdev/billing/testing"; import { type BillingEngine, @@ -29,59 +29,78 @@ export type BillingBundle = { type OfferKey = (typeof COURSELIT_OFFER_KEYS)[number]; -type DodoConfig = { +export type LemonSqueezyConfig = { apiKey: string; - environment: "test_mode" | "live_mode"; - webhookSecret: string; - brandId?: string; - /** Base64 of 32 bytes; encrypts stored checkout URLs and webhook payloads. */ - dataEncryptionKey: string; + storeId: string; + /** The current signing secret first, then an optional previous one during rotation. */ + webhookSecrets: Array<{ version: string; secret: string }>; revision: number; offers: Array<{ key: OfferKey; currency: string; amountMinor: number; + /** The Lemon Squeezy variant id. */ providerProductId: string; providerTrialDays: number; }>; }; /** - * Dodo is opted into with BILLING_CHECKOUT_PROVIDER=dodo; otherwise cloud mode uses the fake. - * Prices, currency, revision and product ids all come from env and must be set, so a - * deploy can never sell at a price nobody configured. + * Lemon Squeezy is opted into with BILLING_PROVIDER=lemonsqueezy; otherwise cloud + * mode uses the fake. Prices, currency, revision and variant ids all come from env + * and must be set, so a deploy can never sell at a price nobody configured. The + * variants must have no trial: the cardless trial is CourseLit's, not the + * provider's (see docs/wip/plan-limits.md, S17). */ -function readDodoConfig(env: NodeJS.ProcessEnv): DodoConfig | null { - if (env.BILLING_CHECKOUT_PROVIDER !== "dodo" || env.NODE_ENV === "test") return null; +export function readLemonSqueezyConfig(env: NodeJS.ProcessEnv): LemonSqueezyConfig | null { + const provider = env.BILLING_PROVIDER?.trim(); + if (!provider || env.NODE_ENV === "test") return null; + if (provider !== "lemonsqueezy") throw new Error("BILLING_PROVIDER_invalid"); const need = (name: string): string => { const value = env[name]?.trim(); if (!value) throw new Error(`${name}_required`); return value; }; const positiveInt = (name: string): number => { - const value = Number(need(name)); - if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name}_invalid`); + const raw = need(name); + const value = Number(raw); + if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(value)) { + throw new Error(`${name}_invalid`); + } return value; }; - const currency = need("BILLING_CURRENCY"); + // Checked here, though only read by aesGcmSensitiveValuesFromEnv, so a missing key + // fails at startup rather than at the first checkout. + need("BILLING_DATA_ENCRYPTION_KEY"); + const currency = need("BILLING_CURRENCY").toUpperCase(); + if (!/^[A-Z]{3}$/.test(currency)) throw new Error("BILLING_CURRENCY_invalid"); + const previous = env.LEMONSQUEEZY_WEBHOOK_SECRET_PREVIOUS?.trim(); + const offers = COURSELIT_OFFER_KEYS.map((key) => { + const name = key.toUpperCase(); + const providerProductId = need(`BILLING_${name}_PRODUCT_ID`); + if (!/^[0-9]+$/.test(providerProductId)) { + throw new Error(`BILLING_${name}_PRODUCT_ID_invalid`); + } + return { + key, + currency, + amountMinor: positiveInt(`BILLING_${name}_AMOUNT_MINOR`), + providerProductId, + providerTrialDays: 0, + }; + }); + if (new Set(offers.map((offer) => offer.providerProductId)).size !== offers.length) { + throw new Error("BILLING_PRODUCT_IDS_must_be_unique"); + } return { - apiKey: need("DODO_PAYMENTS_API_KEY"), - environment: env.DODO_PAYMENTS_ENVIRONMENT === "live_mode" ? "live_mode" : "test_mode", - webhookSecret: need("DODO_PAYMENTS_WEBHOOK_KEY_CURRENT"), - ...(env.DODO_BRAND_ID?.trim() ? { brandId: env.DODO_BRAND_ID.trim() } : {}), - dataEncryptionKey: need("BILLING_DATA_ENCRYPTION_KEY"), + apiKey: need("LEMONSQUEEZY_API_KEY"), + storeId: need("LEMONSQUEEZY_STORE_ID"), + webhookSecrets: [ + { version: "current", secret: need("LEMONSQUEEZY_WEBHOOK_SECRET") }, + ...(previous ? [{ version: "previous", secret: previous }] : []), + ], revision: positiveInt("BILLING_CATALOG_REVISION"), - offers: COURSELIT_OFFER_KEYS.map((key) => { - const name = key.toUpperCase(); - const trial = env[`BILLING_${name}_TRIAL_DAYS`]?.trim(); - return { - key, - currency, - amountMinor: positiveInt(`BILLING_${name}_AMOUNT_MINOR`), - providerProductId: need(`DODO_${name}_PRODUCT_ID`), - providerTrialDays: trial ? Number(trial) : 0, - }; - }), + offers, }; } @@ -93,28 +112,27 @@ export function composeBilling( const mode = options.mode ?? "cloud"; const fake = new FakeBillingProvider({ clock }); const env: NodeJS.ProcessEnv = process.env; - const dodoConfig = mode === "cloud" ? readDodoConfig(env) : null; + const lsConfig = mode === "cloud" ? readLemonSqueezyConfig(env) : null; // The fake provider takes no money, so a production deployment must never fall back to it. - if (mode === "cloud" && !dodoConfig && env.NODE_ENV === "production") { + if (mode === "cloud" && !lsConfig && env.NODE_ENV === "production") { throw new Error("billing_provider_required_in_production"); } - if (mode === "cloud" && !dodoConfig) fake.seedDefaultCatalog(); + if (mode === "cloud" && !lsConfig) fake.seedDefaultCatalog(); // Revision numbers are unique across providers, so switching providers needs a new one. - const revision = dodoConfig?.revision ?? 1; - const dodoOffers = dodoConfig + const revision = lsConfig?.revision ?? 1; + const lsOffers = lsConfig ? REFERENCE_OFFERS.map((offer) => ({ ...offer, - provider: "dodo", + provider: "lemonsqueezy", revision, - ...dodoConfig.offers.find((entry) => entry.key === offer.key)!, + ...lsConfig.offers.find((entry) => entry.key === offer.key)!, })) : null; - const dodo = dodoConfig - ? createDodoBillingProvider({ - apiKey: dodoConfig.apiKey, - environment: dodoConfig.environment, - webhookSecrets: [{ version: "current", secret: dodoConfig.webhookSecret }], - ...(dodoConfig.brandId ? { brandId: dodoConfig.brandId } : {}), + const lemonsqueezy = lsConfig + ? createLemonSqueezyBillingProvider({ + apiKey: lsConfig.apiKey, + storeId: lsConfig.storeId, + webhookSecrets: lsConfig.webhookSecrets, clock, }) : null; @@ -142,8 +160,8 @@ export function composeBilling( const billing = createBilling({ database: store, // Without this the engine stores no checkout URL and cannot resume an open checkout. - ...(dodoConfig ? { sensitiveValues: aesGcmSensitiveValuesFromEnv(process.env) } : {}), - providers: mode !== "cloud" ? [] : dodo ? [dodo] : [fake], + ...(lsConfig ? { sensitiveValues: aesGcmSensitiveValuesFromEnv(process.env) } : {}), + providers: mode !== "cloud" ? [] : lemonsqueezy ? [lemonsqueezy] : [fake], clock, authorization: grants.authorization, hooks: { @@ -161,10 +179,10 @@ export function composeBilling( }, planFeatures: { business: [SSO_FEATURE] }, mode, - checkoutProvider: mode !== "cloud" ? "" : dodo ? "dodo" : "fake", - requestedRevision: mode === "cloud" ? (dodo ? revision : 1) : null, + checkoutProvider: mode !== "cloud" ? "" : lemonsqueezy ? "lemonsqueezy" : "fake", + requestedRevision: mode === "cloud" ? (lemonsqueezy ? revision : 1) : null, requiredOfferKeys: mode === "cloud" ? [...COURSELIT_OFFER_KEYS] : [], - offers: mode !== "cloud" ? [] : (dodoOffers ?? REFERENCE_OFFERS), + offers: mode !== "cloud" ? [] : (lsOffers ?? REFERENCE_OFFERS), returnUrlValidator: createReturnUrlValidator(origins), }); return { billing, fake, grants }; diff --git a/apps/api/src/express-app.ts b/apps/api/src/express-app.ts index 13524636d..8eae51dbe 100644 --- a/apps/api/src/express-app.ts +++ b/apps/api/src/express-app.ts @@ -202,14 +202,14 @@ export function createExpressApp(deps: DispatchDeps): Express { express.raw({ type: "application/json", limit: "256kb" }), async (req, res, next) => { try { - if (deps.billingMode !== "cloud" || req.params.provider !== "dodo") { + if (deps.billingMode !== "cloud" || req.params.provider !== "lemonsqueezy") { res.status(404).json({ error: "not_found" }); return; } let failure: unknown; const response = await handleBillingWebhook({ billing: deps.billing.billing, - provider: "dodo", + provider: "lemonsqueezy", rawBody: Buffer.isBuffer(req.body) && req.body.length > 0 ? req.body : undefined, headers: req.headers, workerId: `courselit-billing-webhook-${process.pid}`, diff --git a/bun.lock b/bun.lock index e7513802e..7206f868e 100644 --- a/bun.lock +++ b/bun.lock @@ -76,7 +76,6 @@ "@xmldom/xmldom": "0.8.15", "better-auth": "1.7.22", "better-call": "1.4.5", - "dodopayments": "^2.61.1", "drizzle-orm": "0.45.2", "express": "4.22.3", "express-rate-limit": "^7.5.0", @@ -723,8 +722,6 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], - "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -1097,8 +1094,6 @@ "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], - "dodopayments": ["dodopayments@2.61.1", "", { "dependencies": { "standardwebhooks": "^1.0.0" }, "bin": { "dodopayments": "bin/cli" } }, "sha512-MgfCcXfx+v8iAOcKTwMJEcMQ19EFDHjewv8wdqRFYZjPa5EnrvWUOCDh4+rtGEBcGRB61UtBs8X0ezdHcpBdvg=="], - "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -1179,8 +1174,6 @@ "fast-redact": ["fast-redact@3.5.0", "", {}, "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A=="], - "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], - "fast-uri": ["fast-uri@3.1.9", "", {}, "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ=="], "fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="], @@ -1801,8 +1794,6 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], - "standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="], - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], From 7e7fd71d273a2cd43c9d179cc098702a74add34c Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 14 Nov 2026 21:40:45 +0530 Subject: [PATCH 03/27] feat(api): school states, schoolPlan and plan limits for courselit plans --- apps/api/drizzle/0000_baseline.sql | 391 +++++++++++---------- apps/api/drizzle/meta/0000_snapshot.json | 238 ++++++++++++- apps/api/drizzle/meta/_journal.json | 2 +- apps/api/openapi.json | 9 + apps/api/package.json | 4 +- apps/api/src/billing-plan.test.ts | 8 +- apps/api/src/billing.ts | 6 +- apps/api/src/db/schema/index.ts | 1 + apps/api/src/db/schema/integrations.ts | 5 + apps/api/src/db/schema/plans.ts | 56 +++ apps/api/src/db/schema/schools.ts | 37 +- apps/api/src/dispatch.ts | 45 +++ apps/api/src/plan-limit.test.ts | 145 ++++++++ apps/api/src/plan-limit.ts | 83 +++++ apps/api/src/school-context.ts | 5 + apps/api/src/school-plan.test.ts | 164 +++++++++ apps/api/src/school-plan.ts | 167 +++++++++ apps/api/src/school-state-dispatch.test.ts | 141 ++++++++ apps/api/src/school-state.test.ts | 139 ++++++++ apps/api/src/school-state.ts | 108 ++++++ apps/api/src/schools.ts | 7 +- bun.lock | 8 +- packages/api-contract/src/index.ts | 11 +- 23 files changed, 1581 insertions(+), 199 deletions(-) create mode 100644 apps/api/src/db/schema/plans.ts create mode 100644 apps/api/src/plan-limit.test.ts create mode 100644 apps/api/src/plan-limit.ts create mode 100644 apps/api/src/school-plan.test.ts create mode 100644 apps/api/src/school-plan.ts create mode 100644 apps/api/src/school-state-dispatch.test.ts create mode 100644 apps/api/src/school-state.test.ts create mode 100644 apps/api/src/school-state.ts diff --git a/apps/api/drizzle/0000_baseline.sql b/apps/api/drizzle/0000_baseline.sql index d880fe28f..81d8c931f 100644 --- a/apps/api/drizzle/0000_baseline.sql +++ b/apps/api/drizzle/0000_baseline.sql @@ -161,14 +161,6 @@ CREATE TABLE "oauth_resource" ( CONSTRAINT "oauth_resource_identifier_unique" UNIQUE("identifier") ); --> statement-breakpoint -CREATE TABLE "payment_notifications" ( - "id" uuid PRIMARY KEY NOT NULL, - "school_id" uuid NOT NULL, - "payment_public_id" text NOT NULL, - "created_at" timestamp with time zone NOT NULL, - CONSTRAINT "payment_notifications_payment_public_id_unique" UNIQUE("payment_public_id") -); ---> statement-breakpoint CREATE TABLE "session" ( "id" text PRIMARY KEY NOT NULL, "expires_at" timestamp with time zone NOT NULL, @@ -362,6 +354,27 @@ CREATE TABLE "scorm_runtime_states" ( "updated_at" timestamp with time zone NOT NULL ); --> statement-breakpoint +CREATE TABLE "section_drip_email_deliveries" ( + "id" uuid PRIMARY KEY NOT NULL, + "school_id" uuid NOT NULL, + "section_id" uuid NOT NULL, + "membership_id" uuid NOT NULL, + "unlocked_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "section_drip_emails" ( + "section_id" uuid PRIMARY KEY NOT NULL, + "school_id" uuid NOT NULL, + "enabled" boolean DEFAULT false NOT NULL, + "subject" text DEFAULT '' NOT NULL, + "content" jsonb, + "html" text, + "enabled_at" timestamp with time zone, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint CREATE TABLE "communities" ( "id" uuid PRIMARY KEY NOT NULL, "public_id" text NOT NULL, @@ -547,6 +560,7 @@ CREATE TABLE "school_integrations" ( "last_attempt_at" timestamp with time zone, "last_successful_sync_at" timestamp with time zone, "last_error" text, + "marketing_paused_until" timestamp with time zone, "created_at" timestamp with time zone NOT NULL, "updated_at" timestamp with time zone NOT NULL ); @@ -616,6 +630,28 @@ CREATE TABLE "migration_runs" ( "completed_at" timestamp with time zone ); --> statement-breakpoint +CREATE TABLE "media_upload_reservations" ( + "id" uuid PRIMARY KEY NOT NULL, + "school_id" uuid NOT NULL, + "school_account_id" uuid, + "bytes" bigint NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone NOT NULL, + CONSTRAINT "media_upload_reservations_bytes_check" CHECK ("media_upload_reservations"."bytes" > 0) +); +--> statement-breakpoint +CREATE TABLE "school_trials" ( + "id" uuid PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "normalized_email" text NOT NULL, + "school_id" uuid, + "started_at" timestamp with time zone NOT NULL, + "ends_at" timestamp with time zone NOT NULL, + "ended_at" timestamp with time zone, + "end_reason" text, + CONSTRAINT "school_trials_end_reason_check" CHECK ("school_trials"."end_reason" IS NULL OR "school_trials"."end_reason" IN ('purchased', 'expired')) +); +--> statement-breakpoint CREATE TABLE "learner_notification_preferences" ( "id" uuid PRIMARY KEY NOT NULL, "school_id" uuid NOT NULL, @@ -698,6 +734,102 @@ CREATE TABLE "rate_limit_events" ( "created_at" timestamp with time zone NOT NULL ); --> statement-breakpoint +CREATE TABLE "school_sso_assertion_ids" ( + "connection_id" uuid NOT NULL, + "assertion_id" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + CONSTRAINT "school_sso_assertion_ids_connection_id_assertion_id_pk" PRIMARY KEY("connection_id","assertion_id") +); +--> statement-breakpoint +CREATE TABLE "school_sso_connections" ( + "id" uuid PRIMARY KEY NOT NULL, + "public_id" text NOT NULL, + "school_id" uuid NOT NULL, + "protocol" text NOT NULL, + "status" text DEFAULT 'draft' NOT NULL, + "display_name" text NOT NULL, + "enforcement" text DEFAULT 'optional' NOT NULL, + "max_session_minutes" integer, + "idp_entity_id" text, + "idp_sso_url" text, + "idp_certificates" text[] DEFAULT '{}'::text[] NOT NULL, + "subject_attribute" text, + "oidc_issuer" text, + "oidc_client_id" text, + "oidc_client_secret" text, + "tested_at" timestamp with time zone, + "tested_config_digest" text, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "school_sso_connections_public_id_unique" UNIQUE("public_id"), + CONSTRAINT "school_sso_connections_protocol_check" CHECK ("school_sso_connections"."protocol" IN ('saml', 'oidc')), + CONSTRAINT "school_sso_connections_status_check" CHECK ("school_sso_connections"."status" IN ('draft', 'active', 'suspended', 'disabled')), + CONSTRAINT "school_sso_connections_enforcement_check" CHECK ("school_sso_connections"."enforcement" IN ('optional', 'required_for_domains')) +); +--> statement-breakpoint +CREATE TABLE "school_sso_domains" ( + "id" uuid PRIMARY KEY NOT NULL, + "connection_id" uuid NOT NULL, + "school_id" uuid NOT NULL, + "domain" text NOT NULL, + "verification_token" text NOT NULL, + "verified_at" timestamp with time zone, + "consecutive_check_failures" integer DEFAULT 0 NOT NULL, + "last_checked_at" timestamp with time zone, + "created_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "school_sso_events" ( + "id" uuid PRIMARY KEY NOT NULL, + "school_id" uuid NOT NULL, + "connection_id" uuid, + "actor_school_account_id" uuid, + "action" text NOT NULL, + "reason" text, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "school_sso_identities" ( + "id" uuid PRIMARY KEY NOT NULL, + "school_id" uuid NOT NULL, + "connection_id" uuid NOT NULL, + "issuer" text NOT NULL, + "subject" text NOT NULL, + "school_account_id" uuid NOT NULL, + "last_email" text NOT NULL, + "last_signed_in_at" timestamp with time zone, + "created_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "school_sso_requests" ( + "id" uuid PRIMARY KEY NOT NULL, + "request_digest" text NOT NULL, + "school_id" uuid NOT NULL, + "connection_id" uuid NOT NULL, + "nonce_digest" text NOT NULL, + "return_origin" text NOT NULL, + "next_path" text NOT NULL, + "protocol_request_id" text, + "oidc_nonce" text, + "pkce_verifier" text, + "purpose" text DEFAULT 'sign_in' NOT NULL, + "started_by_school_account_id" uuid, + "test_result" jsonb, + "test_result_expires_at" timestamp with time zone, + "expires_at" timestamp with time zone NOT NULL, + "consumed_at" timestamp with time zone, + "created_at" timestamp with time zone NOT NULL, + CONSTRAINT "school_sso_requests_request_digest_unique" UNIQUE("request_digest"), + CONSTRAINT "school_sso_requests_purpose_check" CHECK ("school_sso_requests"."purpose" IN ('sign_in', 'test')) +); +--> statement-breakpoint +CREATE TABLE "sso_shadow_users" ( + "user_id" text PRIMARY KEY NOT NULL, + "school_id" uuid NOT NULL, + "created_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint CREATE TABLE "api_keys" ( "id" uuid PRIMARY KEY NOT NULL, "public_id" text NOT NULL, @@ -789,6 +921,22 @@ CREATE TABLE "school_auth_tickets" ( CONSTRAINT "school_auth_tickets_ticket_digest_unique" UNIQUE("ticket_digest") ); --> statement-breakpoint +CREATE TABLE "school_hosts" ( + "id" uuid PRIMARY KEY NOT NULL, + "school_id" uuid NOT NULL, + "hostname" text NOT NULL, + "kind" text NOT NULL, + "verification_status" text DEFAULT 'unverified' NOT NULL, + "verification_token" text, + "txt_satisfied" boolean, + "routing_satisfied" boolean, + "verified_at" timestamp with time zone, + "is_primary" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "school_hosts_hostname_unique" UNIQUE("hostname") +); +--> statement-breakpoint CREATE TABLE "school_login_codes" ( "id" uuid PRIMARY KEY NOT NULL, "school_id" uuid NOT NULL, @@ -813,22 +961,6 @@ CREATE TABLE "school_login_requests" ( CONSTRAINT "school_login_requests_request_digest_unique" UNIQUE("request_digest") ); --> statement-breakpoint -CREATE TABLE "school_hosts" ( - "id" uuid PRIMARY KEY NOT NULL, - "school_id" uuid NOT NULL, - "hostname" text NOT NULL, - "kind" text NOT NULL, - "verification_status" text DEFAULT 'unverified' NOT NULL, - "verification_token" text, - "txt_satisfied" boolean, - "routing_satisfied" boolean, - "verified_at" timestamp with time zone, - "is_primary" boolean DEFAULT false NOT NULL, - "created_at" timestamp with time zone NOT NULL, - "updated_at" timestamp with time zone NOT NULL, - CONSTRAINT "school_hosts_hostname_unique" UNIQUE("hostname") -); ---> statement-breakpoint CREATE TABLE "school_onboarding" ( "school_id" uuid PRIMARY KEY NOT NULL, "status" text DEFAULT 'in_progress' NOT NULL, @@ -861,6 +993,10 @@ CREATE TABLE "schools" ( "name" text NOT NULL, "subdomain" text NOT NULL, "status" text DEFAULT 'active' NOT NULL, + "grace_ends_at" timestamp with time zone, + "suspended_reason" text, + "last_plan" jsonb, + "last_plan_at" timestamp with time zone, "locale" text DEFAULT 'en' NOT NULL, "login_methods" text[] DEFAULT '{email}'::text[] NOT NULL, "currency" text DEFAULT 'USD' NOT NULL, @@ -871,7 +1007,8 @@ CREATE TABLE "schools" ( "updated_at" timestamp with time zone NOT NULL, CONSTRAINT "schools_public_id_unique" UNIQUE("public_id"), CONSTRAINT "schools_subdomain_unique" UNIQUE("subdomain"), - CONSTRAINT "schools_login_methods_check" CHECK ("schools"."login_methods" <@ ARRAY['email', 'google']::text[]) + CONSTRAINT "schools_login_methods_check" CHECK ("schools"."login_methods" <@ ARRAY['email', 'google']::text[]), + CONSTRAINT "schools_status_check" CHECK ("schools"."status" IN ('active', 'pending_checkout', 'imported', 'read_only', 'suspended', 'maintenance', 'migrating', 'deleted')) ); --> statement-breakpoint CREATE TABLE "selected_schools" ( @@ -921,6 +1058,14 @@ CREATE TABLE "spaces" ( CONSTRAINT "spaces_public_id_unique" UNIQUE("public_id") ); --> statement-breakpoint +CREATE TABLE "payment_notifications" ( + "id" uuid PRIMARY KEY NOT NULL, + "school_id" uuid NOT NULL, + "payment_public_id" text NOT NULL, + "created_at" timestamp with time zone NOT NULL, + CONSTRAINT "payment_notifications_payment_public_id_unique" UNIQUE("payment_public_id") +); +--> statement-breakpoint CREATE TABLE "storefront_checkout_attempts" ( "id" uuid PRIMARY KEY NOT NULL, "public_id" text NOT NULL, @@ -1357,7 +1502,6 @@ ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_user_id_user_id_fk" FO ALTER TABLE "oauth_refresh_token" ADD CONSTRAINT "oauth_refresh_token_client_id_oauth_client_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_client"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "oauth_refresh_token" ADD CONSTRAINT "oauth_refresh_token_session_id_session_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."session"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "oauth_refresh_token" ADD CONSTRAINT "oauth_refresh_token_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "payment_notifications" ADD CONSTRAINT "payment_notifications_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "certificate_templates" ADD CONSTRAINT "certificate_templates_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "certificate_templates" ADD CONSTRAINT "certificate_templates_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint @@ -1390,6 +1534,11 @@ ALTER TABLE "product_sections" ADD CONSTRAINT "product_sections_product_id_produ ALTER TABLE "product_sections" ADD CONSTRAINT "product_sections_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint ALTER TABLE "scorm_runtime_states" ADD CONSTRAINT "scorm_runtime_states_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "scorm_runtime_states" ADD CONSTRAINT "scorm_runtime_states_lesson_id_lessons_id_fk" FOREIGN KEY ("lesson_id") REFERENCES "public"."lessons"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "section_drip_email_deliveries" ADD CONSTRAINT "section_drip_email_deliveries_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "section_drip_email_deliveries" ADD CONSTRAINT "section_drip_email_deliveries_section_id_product_sections_id_fk" FOREIGN KEY ("section_id") REFERENCES "public"."product_sections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "section_drip_email_deliveries" ADD CONSTRAINT "section_drip_email_deliveries_membership_id_learner_memberships_id_fk" FOREIGN KEY ("membership_id") REFERENCES "public"."learner_memberships"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "section_drip_emails" ADD CONSTRAINT "section_drip_emails_section_id_product_sections_id_fk" FOREIGN KEY ("section_id") REFERENCES "public"."product_sections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "section_drip_emails" ADD CONSTRAINT "section_drip_emails_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "communities" ADD CONSTRAINT "communities_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "communities" ADD CONSTRAINT "communities_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint ALTER TABLE "community_comments" ADD CONSTRAINT "community_comments_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint @@ -1424,6 +1573,7 @@ ALTER TABLE "learner_memberships" ADD CONSTRAINT "learner_memberships_school_acc ALTER TABLE "migration_mappings" ADD CONSTRAINT "migration_mappings_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "migration_mappings" ADD CONSTRAINT "migration_mappings_run_id_migration_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."migration_runs"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint ALTER TABLE "migration_rejections" ADD CONSTRAINT "migration_rejections_run_id_migration_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."migration_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "media_upload_reservations" ADD CONSTRAINT "media_upload_reservations_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "learner_notification_preferences" ADD CONSTRAINT "learner_notification_preferences_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "learner_notification_preferences" ADD CONSTRAINT "learner_notification_preferences_school_account_id_school_accounts_id_fk" FOREIGN KEY ("school_account_id") REFERENCES "public"."school_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "notifications" ADD CONSTRAINT "notifications_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint @@ -1434,6 +1584,21 @@ ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_school_id_schools_id_fk" ALTER TABLE "products" ADD CONSTRAINT "products_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "products" ADD CONSTRAINT "products_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint ALTER TABLE "rate_limit_events" ADD CONSTRAINT "rate_limit_events_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_sso_assertion_ids" ADD CONSTRAINT "school_sso_assertion_ids_connection_id_school_sso_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."school_sso_connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_sso_connections" ADD CONSTRAINT "school_sso_connections_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_sso_domains" ADD CONSTRAINT "school_sso_domains_connection_id_school_sso_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."school_sso_connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_sso_domains" ADD CONSTRAINT "school_sso_domains_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_sso_events" ADD CONSTRAINT "school_sso_events_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_sso_events" ADD CONSTRAINT "school_sso_events_connection_id_school_sso_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."school_sso_connections"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_sso_events" ADD CONSTRAINT "school_sso_events_actor_school_account_id_school_accounts_id_fk" FOREIGN KEY ("actor_school_account_id") REFERENCES "public"."school_accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_sso_identities" ADD CONSTRAINT "school_sso_identities_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_sso_identities" ADD CONSTRAINT "school_sso_identities_connection_id_school_sso_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."school_sso_connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_sso_identities" ADD CONSTRAINT "school_sso_identities_school_account_id_school_accounts_id_fk" FOREIGN KEY ("school_account_id") REFERENCES "public"."school_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_sso_requests" ADD CONSTRAINT "school_sso_requests_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_sso_requests" ADD CONSTRAINT "school_sso_requests_connection_id_school_sso_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."school_sso_connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_sso_requests" ADD CONSTRAINT "school_sso_requests_started_by_school_account_id_school_accounts_id_fk" FOREIGN KEY ("started_by_school_account_id") REFERENCES "public"."school_accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sso_shadow_users" ADD CONSTRAINT "sso_shadow_users_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sso_shadow_users" ADD CONSTRAINT "sso_shadow_users_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_membership_id_memberships_id_fk" FOREIGN KEY ("membership_id") REFERENCES "public"."memberships"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_created_by_school_account_id_school_accounts_id_fk" FOREIGN KEY ("created_by_school_account_id") REFERENCES "public"."school_accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint @@ -1449,13 +1614,15 @@ ALTER TABLE "school_accounts" ADD CONSTRAINT "school_accounts_user_id_user_id_fk ALTER TABLE "school_auth_tickets" ADD CONSTRAINT "school_auth_tickets_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "school_auth_tickets" ADD CONSTRAINT "school_auth_tickets_school_account_id_school_accounts_id_fk" FOREIGN KEY ("school_account_id") REFERENCES "public"."school_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "school_auth_tickets" ADD CONSTRAINT "school_auth_tickets_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_auth_tickets" ADD CONSTRAINT "school_auth_tickets_sso_connection_id_school_sso_connections_id_fk" FOREIGN KEY ("sso_connection_id") REFERENCES "public"."school_sso_connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_hosts" ADD CONSTRAINT "school_hosts_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "school_login_codes" ADD CONSTRAINT "school_login_codes_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "school_login_requests" ADD CONSTRAINT "school_login_requests_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_hosts" ADD CONSTRAINT "school_hosts_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "school_onboarding" ADD CONSTRAINT "school_onboarding_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "school_sessions" ADD CONSTRAINT "school_sessions_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "school_sessions" ADD CONSTRAINT "school_sessions_school_account_id_school_accounts_id_fk" FOREIGN KEY ("school_account_id") REFERENCES "public"."school_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "school_sessions" ADD CONSTRAINT "school_sessions_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "school_sessions" ADD CONSTRAINT "school_sessions_sso_connection_id_school_sso_connections_id_fk" FOREIGN KEY ("sso_connection_id") REFERENCES "public"."school_sso_connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "selected_schools" ADD CONSTRAINT "selected_schools_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "selected_schools" ADD CONSTRAINT "selected_schools_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "space_followers" ADD CONSTRAINT "space_followers_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint @@ -1467,6 +1634,7 @@ ALTER TABLE "space_unlock_plans" ADD CONSTRAINT "space_unlock_plans_payment_plan ALTER TABLE "space_unlocks" ADD CONSTRAINT "space_unlocks_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "space_unlocks" ADD CONSTRAINT "space_unlocks_space_id_spaces_id_fk" FOREIGN KEY ("space_id") REFERENCES "public"."spaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "spaces" ADD CONSTRAINT "spaces_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payment_notifications" ADD CONSTRAINT "payment_notifications_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "storefront_checkout_attempts" ADD CONSTRAINT "storefront_checkout_attempts_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "storefront_checkout_attempts" ADD CONSTRAINT "storefront_checkout_attempts_school_account_id_school_accounts_id_fk" FOREIGN KEY ("school_account_id") REFERENCES "public"."school_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "storefront_checkout_attempts" ADD CONSTRAINT "storefront_checkout_attempts_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint @@ -1528,6 +1696,7 @@ CREATE UNIQUE INDEX "media_school_media_lit_uidx" ON "media" USING btree ("schoo CREATE UNIQUE INDEX "media_references_resource_media_uidx" ON "media_references" USING btree ("school_id","resource_type","resource_internal_id","media_id");--> statement-breakpoint CREATE UNIQUE INDEX "product_sections_product_position_uidx" ON "product_sections" USING btree ("product_id","position");--> statement-breakpoint CREATE UNIQUE INDEX "scorm_runtime_states_membership_lesson_uidx" ON "scorm_runtime_states" USING btree ("membership_id","lesson_id");--> statement-breakpoint +CREATE UNIQUE INDEX "section_drip_email_deliveries_uidx" ON "section_drip_email_deliveries" USING btree ("section_id","membership_id");--> statement-breakpoint CREATE UNIQUE INDEX "communities_school_slug_uidx" ON "communities" USING btree ("school_id","slug");--> statement-breakpoint CREATE UNIQUE INDEX "communities_school_uidx" ON "communities" USING btree ("school_id") WHERE "communities"."deleted_at" is null;--> statement-breakpoint CREATE INDEX "community_comments_post_idx" ON "community_comments" USING btree ("post_id","created_at","id");--> statement-breakpoint @@ -1552,12 +1721,26 @@ CREATE UNIQUE INDEX "learner_memberships_included_entity_uidx" ON "learner_membe CREATE INDEX "learner_memberships_school_account_lookup_idx" ON "learner_memberships" USING btree ("school_id","school_account_id","status");--> statement-breakpoint CREATE INDEX "learner_memberships_parent_lookup_idx" ON "learner_memberships" USING btree ("parent_membership_id","status");--> statement-breakpoint CREATE UNIQUE INDEX "migration_mappings_source_target_uidx" ON "migration_mappings" USING btree ("source_system","source_collection","source_id","target_table");--> statement-breakpoint +CREATE INDEX "media_upload_reservations_school_idx" ON "media_upload_reservations" USING btree ("school_id","expires_at");--> statement-breakpoint +CREATE UNIQUE INDEX "school_trials_user_uidx" ON "school_trials" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "school_trials_email_uidx" ON "school_trials" USING btree ("normalized_email");--> statement-breakpoint +CREATE INDEX "school_trials_school_idx" ON "school_trials" USING btree ("school_id");--> statement-breakpoint CREATE UNIQUE INDEX "learner_notification_preferences_type_uidx" ON "learner_notification_preferences" USING btree ("school_id","school_account_id","type");--> statement-breakpoint CREATE INDEX "notifications_feed_idx" ON "notifications" USING btree ("school_id","school_account_id","created_at","id");--> statement-breakpoint CREATE UNIQUE INDEX "staff_notification_preferences_type_uidx" ON "staff_notification_preferences" USING btree ("school_id","membership_id","type");--> statement-breakpoint CREATE UNIQUE INDEX "products_school_slug_uidx" ON "products" USING btree ("school_id","slug");--> statement-breakpoint CREATE INDEX "rate_limit_events_lookup_idx" ON "rate_limit_events" USING btree ("school_id","user_id","scope","action","subject_id","created_at");--> statement-breakpoint CREATE INDEX "rate_limit_events_fingerprint_idx" ON "rate_limit_events" USING btree ("school_id","user_id","scope","subject_id","fingerprint","created_at");--> statement-breakpoint +CREATE INDEX "school_sso_assertion_ids_expires_idx" ON "school_sso_assertion_ids" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "school_sso_connections_school_idx" ON "school_sso_connections" USING btree ("school_id");--> statement-breakpoint +CREATE UNIQUE INDEX "school_sso_connections_one_active_uidx" ON "school_sso_connections" USING btree ("school_id") WHERE "school_sso_connections"."status" = 'active';--> statement-breakpoint +CREATE UNIQUE INDEX "school_sso_domains_school_domain_uidx" ON "school_sso_domains" USING btree ("school_id","domain");--> statement-breakpoint +CREATE INDEX "school_sso_domains_connection_idx" ON "school_sso_domains" USING btree ("connection_id");--> statement-breakpoint +CREATE INDEX "school_sso_events_school_created_idx" ON "school_sso_events" USING btree ("school_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "school_sso_identities_connection_subject_uidx" ON "school_sso_identities" USING btree ("connection_id","subject");--> statement-breakpoint +CREATE UNIQUE INDEX "school_sso_identities_connection_account_uidx" ON "school_sso_identities" USING btree ("connection_id","school_account_id");--> statement-breakpoint +CREATE INDEX "school_sso_identities_account_idx" ON "school_sso_identities" USING btree ("school_account_id");--> statement-breakpoint +CREATE INDEX "school_sso_requests_connection_idx" ON "school_sso_requests" USING btree ("connection_id");--> statement-breakpoint CREATE INDEX "invitations_school_email_idx" ON "invitations" USING btree ("school_id","email");--> statement-breakpoint CREATE UNIQUE INDEX "invitations_school_pending_email_uidx" ON "invitations" USING btree ("school_id","normalized_email") WHERE "invitations"."status" = 'pending';--> statement-breakpoint CREATE UNIQUE INDEX "memberships_school_account_uidx" ON "memberships" USING btree ("school_id","school_account_id");--> statement-breakpoint @@ -1567,6 +1750,7 @@ CREATE UNIQUE INDEX "school_accounts_school_user_uidx" ON "school_accounts" USIN CREATE UNIQUE INDEX "school_accounts_school_email_uidx" ON "school_accounts" USING btree ("school_id","email");--> statement-breakpoint CREATE UNIQUE INDEX "school_accounts_school_sendlit_contact_uidx" ON "school_accounts" USING btree ("school_id","sendlit_contact_id") WHERE "school_accounts"."sendlit_contact_id" IS NOT NULL;--> statement-breakpoint CREATE UNIQUE INDEX "school_hosts_school_hostname_uidx" ON "school_hosts" USING btree ("school_id","hostname");--> statement-breakpoint +CREATE INDEX "school_login_codes_school_email_idx" ON "school_login_codes" USING btree ("school_id","email","created_at");--> statement-breakpoint CREATE INDEX "school_sessions_school_account_idx" ON "school_sessions" USING btree ("school_id","school_account_id");--> statement-breakpoint CREATE UNIQUE INDEX "space_followers_space_account_uidx" ON "space_followers" USING btree ("space_id","school_account_id");--> statement-breakpoint CREATE INDEX "space_followers_account_idx" ON "space_followers" USING btree ("school_id","school_account_id","space_id");--> statement-breakpoint @@ -1613,155 +1797,4 @@ CREATE UNIQUE INDEX "billing_reconciliation_jobs_live_customer_uidx" ON "billing CREATE UNIQUE INDEX "billing_subscriptions_provider_subscription_uidx" ON "billing_subscriptions" USING btree ("provider","provider_subscription_id");--> statement-breakpoint CREATE UNIQUE INDEX "billing_subscriptions_entity_source_uidx" ON "billing_subscriptions" USING btree ("billable_entity_id") WHERE "billing_subscriptions"."is_entitlement_source" = true;--> statement-breakpoint CREATE UNIQUE INDEX "billing_webhook_events_provider_event_uidx" ON "billing_webhook_events" USING btree ("provider","provider_event_id");--> statement-breakpoint -CREATE INDEX "billing_webhook_events_queue_idx" ON "billing_webhook_events" USING btree ("status","available_at");--> statement-breakpoint -CREATE TABLE "section_drip_email_deliveries" ( - "id" uuid PRIMARY KEY NOT NULL, - "school_id" uuid NOT NULL, - "section_id" uuid NOT NULL, - "membership_id" uuid NOT NULL, - "unlocked_at" timestamp with time zone NOT NULL, - "created_at" timestamp with time zone NOT NULL -); ---> statement-breakpoint -CREATE TABLE "section_drip_emails" ( - "section_id" uuid PRIMARY KEY NOT NULL, - "school_id" uuid NOT NULL, - "enabled" boolean DEFAULT false NOT NULL, - "subject" text DEFAULT '' NOT NULL, - "content" jsonb, - "html" text, - "enabled_at" timestamp with time zone, - "created_at" timestamp with time zone NOT NULL, - "updated_at" timestamp with time zone NOT NULL -); ---> statement-breakpoint -CREATE TABLE "school_sso_assertion_ids" ( - "connection_id" uuid NOT NULL, - "assertion_id" text NOT NULL, - "expires_at" timestamp with time zone NOT NULL, - CONSTRAINT "school_sso_assertion_ids_connection_id_assertion_id_pk" PRIMARY KEY("connection_id","assertion_id") -); ---> statement-breakpoint -CREATE TABLE "school_sso_connections" ( - "id" uuid PRIMARY KEY NOT NULL, - "public_id" text NOT NULL, - "school_id" uuid NOT NULL, - "protocol" text NOT NULL, - "status" text DEFAULT 'draft' NOT NULL, - "display_name" text NOT NULL, - "enforcement" text DEFAULT 'optional' NOT NULL, - "max_session_minutes" integer, - "idp_entity_id" text, - "idp_sso_url" text, - "idp_certificates" text[] DEFAULT '{}'::text[] NOT NULL, - "subject_attribute" text, - "oidc_issuer" text, - "oidc_client_id" text, - "oidc_client_secret" text, - "tested_at" timestamp with time zone, - "tested_config_digest" text, - "created_at" timestamp with time zone NOT NULL, - "updated_at" timestamp with time zone NOT NULL, - CONSTRAINT "school_sso_connections_public_id_unique" UNIQUE("public_id"), - CONSTRAINT "school_sso_connections_protocol_check" CHECK ("school_sso_connections"."protocol" IN ('saml', 'oidc')), - CONSTRAINT "school_sso_connections_status_check" CHECK ("school_sso_connections"."status" IN ('draft', 'active', 'suspended', 'disabled')), - CONSTRAINT "school_sso_connections_enforcement_check" CHECK ("school_sso_connections"."enforcement" IN ('optional', 'required_for_domains')) -); ---> statement-breakpoint -CREATE TABLE "school_sso_domains" ( - "id" uuid PRIMARY KEY NOT NULL, - "connection_id" uuid NOT NULL, - "school_id" uuid NOT NULL, - "domain" text NOT NULL, - "verification_token" text NOT NULL, - "verified_at" timestamp with time zone, - "consecutive_check_failures" integer DEFAULT 0 NOT NULL, - "last_checked_at" timestamp with time zone, - "created_at" timestamp with time zone NOT NULL -); ---> statement-breakpoint -CREATE TABLE "school_sso_events" ( - "id" uuid PRIMARY KEY NOT NULL, - "school_id" uuid NOT NULL, - "connection_id" uuid, - "actor_school_account_id" uuid, - "action" text NOT NULL, - "reason" text, - "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, - "created_at" timestamp with time zone NOT NULL -); ---> statement-breakpoint -CREATE TABLE "school_sso_identities" ( - "id" uuid PRIMARY KEY NOT NULL, - "school_id" uuid NOT NULL, - "connection_id" uuid NOT NULL, - "issuer" text NOT NULL, - "subject" text NOT NULL, - "school_account_id" uuid NOT NULL, - "last_email" text NOT NULL, - "last_signed_in_at" timestamp with time zone, - "created_at" timestamp with time zone NOT NULL -); ---> statement-breakpoint -CREATE TABLE "school_sso_requests" ( - "id" uuid PRIMARY KEY NOT NULL, - "request_digest" text NOT NULL, - "school_id" uuid NOT NULL, - "connection_id" uuid NOT NULL, - "nonce_digest" text NOT NULL, - "return_origin" text NOT NULL, - "next_path" text NOT NULL, - "protocol_request_id" text, - "oidc_nonce" text, - "pkce_verifier" text, - "purpose" text DEFAULT 'sign_in' NOT NULL, - "started_by_school_account_id" uuid, - "test_result" jsonb, - "test_result_expires_at" timestamp with time zone, - "expires_at" timestamp with time zone NOT NULL, - "consumed_at" timestamp with time zone, - "created_at" timestamp with time zone NOT NULL, - CONSTRAINT "school_sso_requests_request_digest_unique" UNIQUE("request_digest"), - CONSTRAINT "school_sso_requests_purpose_check" CHECK ("school_sso_requests"."purpose" IN ('sign_in', 'test')) -); ---> statement-breakpoint -CREATE TABLE "sso_shadow_users" ( - "user_id" text PRIMARY KEY NOT NULL, - "school_id" uuid NOT NULL, - "created_at" timestamp with time zone NOT NULL -); ---> statement-breakpoint -ALTER TABLE "section_drip_email_deliveries" ADD CONSTRAINT "section_drip_email_deliveries_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "section_drip_email_deliveries" ADD CONSTRAINT "section_drip_email_deliveries_section_id_product_sections_id_fk" FOREIGN KEY ("section_id") REFERENCES "public"."product_sections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "section_drip_email_deliveries" ADD CONSTRAINT "section_drip_email_deliveries_membership_id_learner_memberships_id_fk" FOREIGN KEY ("membership_id") REFERENCES "public"."learner_memberships"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "section_drip_emails" ADD CONSTRAINT "section_drip_emails_section_id_product_sections_id_fk" FOREIGN KEY ("section_id") REFERENCES "public"."product_sections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "section_drip_emails" ADD CONSTRAINT "section_drip_emails_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_sso_assertion_ids" ADD CONSTRAINT "school_sso_assertion_ids_connection_id_school_sso_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."school_sso_connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_sso_connections" ADD CONSTRAINT "school_sso_connections_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_sso_domains" ADD CONSTRAINT "school_sso_domains_connection_id_school_sso_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."school_sso_connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_sso_domains" ADD CONSTRAINT "school_sso_domains_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_sso_events" ADD CONSTRAINT "school_sso_events_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_sso_events" ADD CONSTRAINT "school_sso_events_connection_id_school_sso_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."school_sso_connections"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_sso_events" ADD CONSTRAINT "school_sso_events_actor_school_account_id_school_accounts_id_fk" FOREIGN KEY ("actor_school_account_id") REFERENCES "public"."school_accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_sso_identities" ADD CONSTRAINT "school_sso_identities_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_sso_identities" ADD CONSTRAINT "school_sso_identities_connection_id_school_sso_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."school_sso_connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_sso_identities" ADD CONSTRAINT "school_sso_identities_school_account_id_school_accounts_id_fk" FOREIGN KEY ("school_account_id") REFERENCES "public"."school_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_sso_requests" ADD CONSTRAINT "school_sso_requests_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_sso_requests" ADD CONSTRAINT "school_sso_requests_connection_id_school_sso_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."school_sso_connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_sso_requests" ADD CONSTRAINT "school_sso_requests_started_by_school_account_id_school_accounts_id_fk" FOREIGN KEY ("started_by_school_account_id") REFERENCES "public"."school_accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "sso_shadow_users" ADD CONSTRAINT "sso_shadow_users_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "sso_shadow_users" ADD CONSTRAINT "sso_shadow_users_school_id_schools_id_fk" FOREIGN KEY ("school_id") REFERENCES "public"."schools"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_auth_tickets" ADD CONSTRAINT "school_auth_tickets_sso_connection_id_school_sso_connections_id_fk" FOREIGN KEY ("sso_connection_id") REFERENCES "public"."school_sso_connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "school_sessions" ADD CONSTRAINT "school_sessions_sso_connection_id_school_sso_connections_id_fk" FOREIGN KEY ("sso_connection_id") REFERENCES "public"."school_sso_connections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE UNIQUE INDEX "section_drip_email_deliveries_uidx" ON "section_drip_email_deliveries" USING btree ("section_id","membership_id");--> statement-breakpoint -CREATE INDEX "school_login_codes_school_email_idx" ON "school_login_codes" USING btree ("school_id","email","created_at");--> statement-breakpoint -CREATE INDEX "school_sso_assertion_ids_expires_idx" ON "school_sso_assertion_ids" USING btree ("expires_at");--> statement-breakpoint -CREATE INDEX "school_sso_connections_school_idx" ON "school_sso_connections" USING btree ("school_id");--> statement-breakpoint -CREATE UNIQUE INDEX "school_sso_connections_one_active_uidx" ON "school_sso_connections" USING btree ("school_id") WHERE "school_sso_connections"."status" = 'active';--> statement-breakpoint -CREATE UNIQUE INDEX "school_sso_domains_school_domain_uidx" ON "school_sso_domains" USING btree ("school_id","domain");--> statement-breakpoint -CREATE INDEX "school_sso_domains_connection_idx" ON "school_sso_domains" USING btree ("connection_id");--> statement-breakpoint -CREATE INDEX "school_sso_events_school_created_idx" ON "school_sso_events" USING btree ("school_id","created_at");--> statement-breakpoint -CREATE UNIQUE INDEX "school_sso_identities_connection_subject_uidx" ON "school_sso_identities" USING btree ("connection_id","subject");--> statement-breakpoint -CREATE UNIQUE INDEX "school_sso_identities_connection_account_uidx" ON "school_sso_identities" USING btree ("connection_id","school_account_id");--> statement-breakpoint -CREATE INDEX "school_sso_identities_account_idx" ON "school_sso_identities" USING btree ("school_account_id");--> statement-breakpoint -CREATE INDEX "school_sso_requests_connection_idx" ON "school_sso_requests" USING btree ("connection_id"); +CREATE INDEX "billing_webhook_events_queue_idx" ON "billing_webhook_events" USING btree ("status","available_at"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0000_snapshot.json b/apps/api/drizzle/meta/0000_snapshot.json index 82a9afd85..1357d7b59 100644 --- a/apps/api/drizzle/meta/0000_snapshot.json +++ b/apps/api/drizzle/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "8f68a5f8-20e9-4e22-bf51-f83cb1ec81dd", + "id": "1dc6ab97-4fd0-436e-930b-e14df0f39f2b", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -5136,6 +5136,12 @@ "primaryKey": false, "notNull": false }, + "marketing_paused_until": { + "name": "marketing_paused_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -5784,6 +5790,208 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.media_upload_reservations": { + "name": "media_upload_reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "school_id": { + "name": "school_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "school_account_id": { + "name": "school_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "bytes": { + "name": "bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "media_upload_reservations_school_idx": { + "name": "media_upload_reservations_school_idx", + "columns": [ + { + "expression": "school_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_upload_reservations_school_id_schools_id_fk": { + "name": "media_upload_reservations_school_id_schools_id_fk", + "tableFrom": "media_upload_reservations", + "tableTo": "schools", + "columnsFrom": [ + "school_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "media_upload_reservations_bytes_check": { + "name": "media_upload_reservations_bytes_check", + "value": "\"media_upload_reservations\".\"bytes\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.school_trials": { + "name": "school_trials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "school_id": { + "name": "school_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "school_trials_user_uidx": { + "name": "school_trials_user_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "school_trials_email_uidx": { + "name": "school_trials_email_uidx", + "columns": [ + { + "expression": "normalized_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "school_trials_school_idx": { + "name": "school_trials_school_idx", + "columns": [ + { + "expression": "school_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "school_trials_end_reason_check": { + "name": "school_trials_end_reason_check", + "value": "\"school_trials\".\"end_reason\" IS NULL OR \"school_trials\".\"end_reason\" IN ('purchased', 'expired')" + } + }, + "isRLSEnabled": false + }, "public.learner_notification_preferences": { "name": "learner_notification_preferences", "schema": "", @@ -9046,6 +9254,30 @@ "notNull": true, "default": "'active'" }, + "grace_ends_at": { + "name": "grace_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_reason": { + "name": "suspended_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_plan": { + "name": "last_plan", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_plan_at": { + "name": "last_plan_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, "locale": { "name": "locale", "type": "text", @@ -9124,6 +9356,10 @@ "schools_login_methods_check": { "name": "schools_login_methods_check", "value": "\"schools\".\"login_methods\" <@ ARRAY['email', 'google']::text[]" + }, + "schools_status_check": { + "name": "schools_status_check", + "value": "\"schools\".\"status\" IN ('active', 'pending_checkout', 'imported', 'read_only', 'suspended', 'maintenance', 'migrating', 'deleted')" } }, "isRLSEnabled": false diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 374bff4e6..82b502c8f 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -5,7 +5,7 @@ { "idx": 0, "version": "7", - "when": 1791954130021, + "when": 1794668680517, "tag": "0000_baseline", "breakpoints": true } diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 7fff747f3..249fb3a1f 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -63734,7 +63734,10 @@ "type": "string", "enum": [ "active", + "pending_checkout", + "imported", "read_only", + "suspended", "maintenance", "migrating", "deleted" @@ -64032,7 +64035,10 @@ "type": "string", "enum": [ "active", + "pending_checkout", + "imported", "read_only", + "suspended", "maintenance", "migrating", "deleted" @@ -76610,7 +76616,10 @@ "type": "string", "enum": [ "active", + "pending_checkout", + "imported", "read_only", + "suspended", "maintenance", "migrating", "deleted" diff --git a/apps/api/package.json b/apps/api/package.json index 660cc3833..351a44404 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -37,11 +37,11 @@ "@better-auth/oauth-provider": "1.7.22", "@better-auth/utils": "0.5.1", "@better-fetch/fetch": "1.3.3", - "@codelitdev/billing": "0.11.0", + "@codelitdev/billing": "0.12.0", "@codelitdev/mcp-server-kit": "0.5.0", "@codelitdev/oauth-server-kit": "0.10.0", "@codelitdev/observability": "0.5.0", - "@codelitdev/platform": "0.11.0", + "@codelitdev/platform": "0.12.0", "@codelitdev/platform-conformance": "0.9.0", "@courselit/api-contract": "workspace:*", "@frontlit/text-editor": "0.1.0-alpha.3", diff --git a/apps/api/src/billing-plan.test.ts b/apps/api/src/billing-plan.test.ts index 9d876f727..809a742c4 100644 --- a/apps/api/src/billing-plan.test.ts +++ b/apps/api/src/billing-plan.test.ts @@ -33,9 +33,9 @@ type Plan = { describe("billing composition", () => { it("refuses to run production cloud billing on the fake provider", async () => { const { runtime, clock } = await setup(); - const previous = { nodeEnv: process.env.NODE_ENV, provider: process.env.BILLING_CHECKOUT_PROVIDER }; + const previous = { nodeEnv: process.env.NODE_ENV, provider: process.env.BILLING_PROVIDER }; process.env.NODE_ENV = "production"; - delete process.env.BILLING_CHECKOUT_PROVIDER; + delete process.env.BILLING_PROVIDER; try { expect(() => composeBilling(runtime.db, clock, { mode: "cloud" })).toThrow( "billing_provider_required_in_production", @@ -44,8 +44,8 @@ describe("billing composition", () => { expect(() => composeBilling(runtime.db, clock, { mode: "oss" })).not.toThrow(); } finally { process.env.NODE_ENV = previous.nodeEnv; - if (previous.provider === undefined) delete process.env.BILLING_CHECKOUT_PROVIDER; - else process.env.BILLING_CHECKOUT_PROVIDER = previous.provider; + if (previous.provider === undefined) delete process.env.BILLING_PROVIDER; + else process.env.BILLING_PROVIDER = previous.provider; } await runtime.close(); }); diff --git a/apps/api/src/billing.ts b/apps/api/src/billing.ts index 89f9a9626..5ed5eafb4 100644 --- a/apps/api/src/billing.ts +++ b/apps/api/src/billing.ts @@ -17,7 +17,7 @@ import { createBillingAuditHook } from "./billing-audit.js"; import { verification } from "./db/schema/auth.generated.js"; import * as billingSchema from "./db/schema/billing.generated.js"; import { enqueuePlanFeatureSync } from "./plan-features.js"; -import { SSO_FEATURE } from "./sso-config.js"; +import { PLAN_FEATURES, PLAN_LIMITS } from "./school-plan.js"; import type { AppDb } from "./types.js"; export type BillingBundle = { @@ -177,7 +177,9 @@ export function composeBilling( }, }, }, - planFeatures: { business: [SSO_FEATURE] }, + planFeatures: PLAN_FEATURES, + // In oss mode the package answers unlimited and needs no catalog plans. + ...(mode === "cloud" ? { planLimits: PLAN_LIMITS } : {}), mode, checkoutProvider: mode !== "cloud" ? "" : lemonsqueezy ? "lemonsqueezy" : "fake", requestedRevision: mode === "cloud" ? (lemonsqueezy ? revision : 1) : null, diff --git a/apps/api/src/db/schema/index.ts b/apps/api/src/db/schema/index.ts index 02b2a347b..1251d64d2 100644 --- a/apps/api/src/db/schema/index.ts +++ b/apps/api/src/db/schema/index.ts @@ -7,6 +7,7 @@ export * from "./community-subscriptions.js"; export * from "./integrations.js"; export * from "./learner-memberships.js"; export * from "./migration.js"; +export * from "./plans.js"; export * from "./notifications.js"; export * from "./products.js"; export * from "./rate-limit.js"; diff --git a/apps/api/src/db/schema/integrations.ts b/apps/api/src/db/schema/integrations.ts index 7b372b6aa..49c38a9de 100644 --- a/apps/api/src/db/schema/integrations.ts +++ b/apps/api/src/db/schema/integrations.ts @@ -78,6 +78,11 @@ export const schoolIntegrations = pgTable( withTimezone: true, }), lastError: text("last_error"), + /** + * Marketing sends (drip) wait until this time after SendLit refused one for + * quota; set from SendLit's `retryAfter`. Only used on the `sendlit` row. + */ + marketingPausedUntil: timestamp("marketing_paused_until", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), }, diff --git a/apps/api/src/db/schema/plans.ts b/apps/api/src/db/schema/plans.ts new file mode 100644 index 000000000..f096395a8 --- /dev/null +++ b/apps/api/src/db/schema/plans.ts @@ -0,0 +1,56 @@ +import { sql } from "drizzle-orm"; +import { bigint, check, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { schools } from "./schools.js"; + +/** + * One row per CourseLit trial ever started. An account gets one trial + * (docs/wip/plan-limits.md, section 3). `school_id` has no foreign key, so the + * row outlives the school and deleting a school can't buy a second trial. + */ +export const schoolTrials = pgTable( + "school_trials", + { + id: uuid("id").primaryKey(), + userId: text("user_id").notNull(), + /** The owner's verified email, lowercased, `+tags` stripped, Gmail dots ignored. */ + normalizedEmail: text("normalized_email").notNull(), + schoolId: uuid("school_id"), + startedAt: timestamp("started_at", { withTimezone: true }).notNull(), + endsAt: timestamp("ends_at", { withTimezone: true }).notNull(), + endedAt: timestamp("ended_at", { withTimezone: true }), + endReason: text("end_reason").$type<"purchased" | "expired">(), + }, + (table) => [ + uniqueIndex("school_trials_user_uidx").on(table.userId), + uniqueIndex("school_trials_email_uidx").on(table.normalizedEmail), + index("school_trials_school_idx").on(table.schoolId), + check( + "school_trials_end_reason_check", + sql`${table.endReason} IS NULL OR ${table.endReason} IN ('purchased', 'expired')`, + ), + ], +); + +/** + * Bytes claimed by uploads that were authorized but not finalized yet, so + * parallel uploads can't together pass a storage limit (S1). A row goes away + * when its upload is finalized or `expires_at` passes. + */ +export const mediaUploadReservations = pgTable( + "media_upload_reservations", + { + id: uuid("id").primaryKey(), + schoolId: uuid("school_id") + .notNull() + .references(() => schools.id, { onDelete: "cascade" }), + /** Set for a learner's upload, which also counts toward their own budget. */ + schoolAccountId: uuid("school_account_id"), + bytes: bigint("bytes", { mode: "number" }).notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + }, + (table) => [ + index("media_upload_reservations_school_idx").on(table.schoolId, table.expiresAt), + check("media_upload_reservations_bytes_check", sql`${table.bytes} > 0`), + ], +); diff --git a/apps/api/src/db/schema/schools.ts b/apps/api/src/db/schema/schools.ts index 3eb1e91f6..db3977a2e 100644 --- a/apps/api/src/db/schema/schools.ts +++ b/apps/api/src/db/schema/schools.ts @@ -16,15 +16,40 @@ import { import { user } from "./auth.generated.js"; import { schoolSsoConnections } from "./school-sso.js"; +/** + * Where a school is in its life (docs/wip/plan-limits.md, section 3). + * `pending_checkout`, `imported` and `suspended` are offline; `read_only` is the + * grace period after a trial or subscription ends. Only the lifecycle job and + * the staff CLI change it. + */ +export const SCHOOL_STATUSES = [ + "active", + "pending_checkout", + "imported", + "read_only", + "suspended", + "maintenance", + "migrating", + "deleted", +] as const; +export type SchoolStatus = (typeof SCHOOL_STATUSES)[number]; + export const schools = pgTable("schools", { id: uuid("id").primaryKey(), publicId: text("public_id").notNull().unique(), name: text("name").notNull(), subdomain: text("subdomain").notNull().unique(), - status: text("status") - .$type<"active" | "read_only" | "maintenance" | "migrating" | "deleted">() - .notNull() - .default("active"), + status: text("status").$type().notNull().default("active"), + /** End of the read-only grace period after a lapse; null otherwise. */ + graceEndsAt: timestamp("grace_ends_at", { withTimezone: true }), + /** Why staff suspended the school (staff CLI); null unless `suspended`. */ + suspendedReason: text("suspended_reason"), + /** + * The last plan `schoolPlan` read from billing, used while billing can't be + * read (S23). Never written from a request body. + */ + lastPlan: jsonb("last_plan"), + lastPlanAt: timestamp("last_plan_at", { withTimezone: true }), locale: text("locale").notNull().default("en"), /** * Sign-in methods the school has turned on. SSO isn't listed: it is on @@ -47,6 +72,10 @@ export const schools = pgTable("schools", { "schools_login_methods_check", sql`${table.loginMethods} <@ ARRAY['email', 'google']::text[]`, ), + check( + "schools_status_check", + sql`${table.status} IN ('active', 'pending_checkout', 'imported', 'read_only', 'suspended', 'maintenance', 'migrating', 'deleted')`, + ), ]); /** Setup wizard progress and onboarding checklist state, one row per school. */ diff --git a/apps/api/src/dispatch.ts b/apps/api/src/dispatch.ts index 008156347..7daf6d47f 100644 --- a/apps/api/src/dispatch.ts +++ b/apps/api/src/dispatch.ts @@ -349,6 +349,7 @@ import { startSchoolCheckout, } from "./school-billing.js"; import { headerSchoolId, resolveSchoolContext } from "./school-context.js"; +import { schoolStateAllows, schoolStateError } from "./school-state.js"; import { checkSchoolSubdomain, disconnectAiConnection, @@ -689,6 +690,37 @@ export async function dispatch( deps.clock, secureLearnerCookies, ); + // Learner and public routes of a read-only or offline school are gated on + // the school's state before any handler runs (school-state.ts). The school + // is the one in the path, else the one the request names, else the + // learner's own. + { + const publicSchoolId = /^\/v1\/public\/([^/]+)\//.exec(path)?.[1]; + const isLearnerRoute = + path.startsWith("/v1/learner/") || path === "/v1/auth/tickets/consume"; + if (publicSchoolId || isLearnerRoute) { + const schoolPublicId = publicSchoolId + ? decodeURIComponent(publicSchoolId) + : (headerSchoolId(request.headers) ?? + (learnerAuth.kind === "authenticated" + ? learnerAuth.value.school.publicId + : null)); + const stateSchool = schoolPublicId + ? await loadSchoolByPublicId(deps.db, schoolPublicId) + : null; + if ( + stateSchool && + !schoolStateAllows({ + status: stateSchool.status, + actor: isLearnerRoute ? "learner" : "public", + method: request.method, + operationId: resolveOperationId(request.method, path), + }) + ) { + return errorResponse(schoolStateError(stateSchool.status)); + } + } + } if (request.method === "POST" && path === "/v1/auth/tickets/consume") { const parsed = consumeSchoolAuthTicketBodySchema.safeParse(request.body ?? {}); if (!parsed.success) { @@ -2337,6 +2369,19 @@ export async function dispatch( ) { return errorResponse(createPlatformError("forbidden")); } + // A read-only or offline school lets staff do only what its state allows + // (school-state.ts). Checked after permissions, so a refusal never tells + // someone without access what state the school is in. + if ( + !schoolStateAllows({ + status: school.value.status, + actor: school.value.isOwner ? "owner" : "team", + method: request.method, + operationId: policyOperationId, + }) + ) { + return errorResponse(schoolStateError(school.value.status)); + } if (request.method === "GET" && path === "/v1/notification-preferences") { const result = await listStaffNotificationPreferences(deps.db, { diff --git a/apps/api/src/plan-limit.test.ts b/apps/api/src/plan-limit.test.ts new file mode 100644 index 000000000..724c17f83 --- /dev/null +++ b/apps/api/src/plan-limit.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "bun:test"; +import { uuidv7 } from "@codelitdev/platform"; +import * as schema from "./db/schema/index.js"; +import { checkLimit, type PlanLimitKey, schoolLimit } from "./plan-limit.js"; +import { createPgliteRuntime, freezeRuntimeClock } from "./runtime.js"; +import { PLAN_LIMITS } from "./school-plan.js"; +import { seedWorld } from "./seed.js"; + +const NOW = new Date("2026-03-01T00:00:00.000Z"); +const MB = 1024 * 1024; + +async function setup(billingMode: "cloud" | "oss" = "cloud") { + const clock = freezeRuntimeClock(NOW); + const runtime = await createPgliteRuntime({ clock, billingMode }); + const world = await seedWorld(runtime, clock); + const deps = { db: runtime.db, billing: runtime.billing.billing, mode: billingMode, clock }; + return { runtime, world, deps }; +} + +const reason = (error: { safeDetails?: Record }) => error.safeDetails?.reason; + +describe("schoolLimit", () => { + it("reads a paid plan's allowance from the billing package", async () => { + const { runtime, world, deps } = await setup(); + expect(await schoolLimit(deps, world.schoolA.id, "storage_bytes")).toBe( + PLAN_LIMITS.pro.storage_bytes, + ); + expect(await schoolLimit(deps, world.schoolA.id, "marketing_emails_month")).toBe(10_000); + await runtime.close(); + }); + + it("uses the trial's own limits, not Business's", async () => { + const { runtime, world, deps } = await setup(); + await runtime.db.insert(schema.schoolTrials).values({ + id: uuidv7(runtime.clock), + userId: "trial_owner", + normalizedEmail: "trial@example.com", + schoolId: world.schoolB.id, + startedAt: NOW, + endsAt: new Date(NOW.getTime() + 14 * 86_400_000), + }); + expect(await schoolLimit(deps, world.schoolB.id, "storage_bytes")).toBe(500 * MB); + expect(await schoolLimit(deps, world.schoolB.id, "marketing_emails_month")).toBe(0); + await runtime.close(); + }); + + it("is 0 with no plan and unlimited in oss mode", async () => { + const cloud = await setup(); + expect(await schoolLimit(cloud.deps, cloud.world.schoolB.id, "storage_bytes")).toBe(0); + await cloud.runtime.close(); + const oss = await setup("oss"); + expect(await schoolLimit(oss.deps, oss.world.schoolB.id, "storage_bytes")).toBeNull(); + await oss.runtime.close(); + }); + + it("throws on an undeclared key instead of reading it as unlimited (S7)", async () => { + const { runtime, world, deps } = await setup(); + await expect( + schoolLimit(deps, world.schoolA.id, "storage_byte" as PlanLimitKey), + ).rejects.toThrow(/limit_key_unknown/); + await runtime.close(); + }); +}); + +describe("checkLimit", () => { + it("allows usage that fits and refuses usage that doesn't", async () => { + const { runtime, world, deps } = await setup(); + const allowed = PLAN_LIMITS.pro.storage_bytes!; + const fits = await checkLimit(deps, { + schoolId: world.schoolA.id, + key: "storage_bytes", + used: allowed - 10, + increment: 10, + showNumbers: true, + }); + expect(fits.ok).toBe(true); + const over = await checkLimit(deps, { + schoolId: world.schoolA.id, + key: "storage_bytes", + used: allowed - 10, + increment: 11, + showNumbers: true, + }); + expect(over.ok).toBe(false); + if (over.ok) throw new Error("expected a refusal"); + expect(over.error.code).toBe("forbidden"); + expect(over.error.safeDetails).toEqual({ + reason: "plan_limit_reached", + limit: "storage_bytes", + used: allowed - 10, + allowed, + }); + await runtime.close(); + }); + + it("leaves the numbers out for someone who can't see billing (S9)", async () => { + const { runtime, world, deps } = await setup(); + const over = await checkLimit(deps, { + schoolId: world.schoolB.id, + key: "storage_bytes", + used: 0, + increment: 1, + showNumbers: false, + }); + if (over.ok) throw new Error("expected a refusal"); + expect(over.error.safeDetails).toEqual({ reason: "plan_limit_reached", limit: "storage_bytes" }); + await runtime.close(); + }); + + it("refuses new usage when billing can't be read (S23)", async () => { + const { runtime, world, deps } = await setup(); + const failing = { + ...deps, + billing: { + ...deps.billing, + commercialState: async () => { + throw new Error("billing down"); + }, + }, + }; + const result = await checkLimit(failing, { + schoolId: world.schoolA.id, + key: "storage_bytes", + used: 0, + increment: 1, + showNumbers: true, + }); + if (result.ok) throw new Error("expected a refusal"); + expect(reason(result.error)).toBe("billing_unavailable"); + await runtime.close(); + }); + + it("never refuses in oss mode", async () => { + const { runtime, world, deps } = await setup("oss"); + const result = await checkLimit(deps, { + schoolId: world.schoolA.id, + key: "storage_bytes", + used: Number.MAX_SAFE_INTEGER - 1, + increment: 1, + showNumbers: true, + }); + expect(result.ok).toBe(true); + await runtime.close(); + }); +}); diff --git a/apps/api/src/plan-limit.ts b/apps/api/src/plan-limit.ts new file mode 100644 index 000000000..3ae6723da --- /dev/null +++ b/apps/api/src/plan-limit.ts @@ -0,0 +1,83 @@ +import type { BillingEngine } from "@codelitdev/billing/workflows"; +import { type Clock, createPlatformError, type PlatformError } from "@codelitdev/platform"; +import { schoolPlan, TRIAL } from "./school-plan.js"; +import type { AppDb } from "./types.js"; + +/** The limits CourseLit declares in `planLimits` (school-plan.ts, PLAN_LIMITS). */ +export type PlanLimitKey = keyof typeof TRIAL.limits; + +export type PlanLimitDeps = { + db: AppDb; + billing: Pick; + mode: "cloud" | "oss"; + clock: Clock; +}; + +/** + * The school's allowance for `key`: a number, `null` for unlimited, or + * `undefined` when billing can't be read. Paid plans read the billing + * package's `getLimit`, which throws on an undeclared key, so a typo never + * reads as unlimited (S7). + */ +export async function schoolLimit( + deps: PlanLimitDeps, + schoolId: string, + key: PlanLimitKey, +): Promise { + const plan = await schoolPlan(deps, schoolId); + switch (plan.kind) { + case "oss": + return null; + case "paid": + try { + return await deps.billing.getLimit(schoolId, key); + } catch (error) { + if (error instanceof Error && /limit_key_unknown/.test(error.message)) throw error; + return undefined; + } + case "trial": + return TRIAL.limits[key]; + case "none": + return 0; + case "unknown": + return undefined; + } +} + +/** + * Whether `increment` more of `key` fits on top of `used`. Callers count their + * own usage; the package never does. Refuses new usage when billing can't be + * read and never takes away what exists. + * + * The used and allowed numbers go into the error only when `showNumbers` is + * true: the caller may see the school's billing (S9). + */ +export async function checkLimit( + deps: PlanLimitDeps, + input: { + schoolId: string; + key: PlanLimitKey; + used: number; + increment: number; + showNumbers: boolean; + }, +): Promise<{ ok: true } | { ok: false; error: PlatformError }> { + const allowed = await schoolLimit(deps, input.schoolId, input.key); + if (allowed === undefined) { + return { + ok: false, + error: createPlatformError("internal_error", { + safeDetails: { reason: "billing_unavailable" }, + }), + }; + } + if (allowed === null || input.used + input.increment <= allowed) return { ok: true }; + return { + ok: false, + error: createPlatformError("forbidden", { + safeDetails: input.showNumbers + ? { reason: "plan_limit_reached", limit: input.key, used: input.used, allowed } + : { reason: "plan_limit_reached", limit: input.key }, + }), + }; +} diff --git a/apps/api/src/school-context.ts b/apps/api/src/school-context.ts index ea23c8c9f..c36b70be3 100644 --- a/apps/api/src/school-context.ts +++ b/apps/api/src/school-context.ts @@ -14,11 +14,14 @@ import { } from "./permissions.js"; import { isApiKeyExpired, loadActiveApiKey } from "./auth/api-keys.js"; import { loadSchoolByPublicId } from "./schools.js"; +import type { SchoolStatus } from "./db/schema/schools.js"; import type { AppDb } from "./types.js"; export type ResolvedSchool = { schoolId: string; publicId: string; + /** The school's lifecycle state; requests are gated on it (school-state.ts). */ + status: SchoolStatus; schoolAccountId: string; schoolAccountPublicId: string; staffMembershipId: string; @@ -75,6 +78,7 @@ async function resolveSchool( value: { schoolId: row.school.id, publicId: row.school.publicId, + status: row.school.status, schoolAccountId: row.ownerAccount.id, schoolAccountPublicId: row.ownerAccount.publicId, staffMembershipId: row.ownerMembership.id, @@ -139,6 +143,7 @@ async function loadMembership( value: { schoolId: row.school.id, publicId: row.school.publicId, + status: row.school.status, schoolAccountId: row.schoolAccount.id, schoolAccountPublicId: row.schoolAccount.publicId, staffMembershipId: row.membership.id, diff --git a/apps/api/src/school-plan.test.ts b/apps/api/src/school-plan.test.ts new file mode 100644 index 000000000..f04177934 --- /dev/null +++ b/apps/api/src/school-plan.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "bun:test"; +import { uuidv7 } from "@codelitdev/platform"; +import { eq } from "drizzle-orm"; +import * as schema from "./db/schema/index.js"; +import { createPgliteRuntime, freezeRuntimeClock } from "./runtime.js"; +import { + lastKnownPlan, + planIncludesFeature, + type SchoolPlan, + schoolPlan, + TRIAL, +} from "./school-plan.js"; +import { seedWorld } from "./seed.js"; + +const NOW = new Date("2026-03-01T00:00:00.000Z"); +const DAY = 86_400_000; + +async function setup(billingMode: "cloud" | "oss" = "cloud") { + const clock = freezeRuntimeClock(NOW); + const runtime = await createPgliteRuntime({ clock, billingMode }); + const world = await seedWorld(runtime, clock); + const deps = { + db: runtime.db, + billing: runtime.billing.billing, + mode: billingMode, + clock, + }; + return { runtime, world, clock, deps }; +} + +async function addTrial( + runtime: Awaited>["runtime"], + schoolId: string, + endsAt: Date, + endedAt: Date | null = null, +) { + await runtime.db.insert(schema.schoolTrials).values({ + id: uuidv7(runtime.clock), + userId: `user_${schoolId}`, + normalizedEmail: `${schoolId}@example.com`, + schoolId, + startedAt: new Date(endsAt.getTime() - 14 * DAY), + endsAt, + endedAt, + endReason: endedAt ? "purchased" : null, + }); +} + +describe("schoolPlan", () => { + it("is oss in oss mode", async () => { + const { runtime, world, deps } = await setup("oss"); + expect(await schoolPlan(deps, world.schoolA.id)).toEqual({ kind: "oss" }); + await runtime.close(); + }); + + it("reads the entitled paid plan", async () => { + const { runtime, world, deps } = await setup(); + expect(await schoolPlan(deps, world.schoolA.id)).toMatchObject({ + kind: "paid", + plan: "pro", + }); + await runtime.close(); + }); + + it("puts a paid plan ahead of a trial that is still running (S18)", async () => { + const { runtime, world, deps } = await setup(); + await addTrial(runtime, world.schoolA.id, new Date(NOW.getTime() + 10 * DAY)); + expect((await schoolPlan(deps, world.schoolA.id)).kind).toBe("paid"); + await runtime.close(); + }); + + it("is a trial while one is running and nothing is paid", async () => { + const { runtime, world, deps } = await setup(); + const endsAt = new Date(NOW.getTime() + 10 * DAY); + await addTrial(runtime, world.schoolB.id, endsAt); + expect(await schoolPlan(deps, world.schoolB.id)).toEqual({ kind: "trial", endsAt }); + await runtime.close(); + }); + + it("is expired once the trial has ended or was closed", async () => { + const { runtime, world, deps } = await setup(); + await addTrial(runtime, world.schoolB.id, new Date(NOW.getTime() - DAY)); + expect(await schoolPlan(deps, world.schoolB.id)).toEqual({ + kind: "none", + reason: "expired", + }); + await runtime.close(); + }); + + it("is never a trial for a school with no trial record (S15)", async () => { + const { runtime, world, deps } = await setup(); + expect(await schoolPlan(deps, world.schoolB.id)).toEqual({ + kind: "none", + reason: "expired", + }); + await runtime.close(); + }); + + it.each(["pending_checkout", "imported"] as const)( + "gives the %s reason for a school waiting on its first checkout", + async (status) => { + const { runtime, world, deps } = await setup(); + await runtime.db + .update(schema.schools) + .set({ status }) + .where(eq(schema.schools.id, world.schoolB.id)); + expect(await schoolPlan(deps, world.schoolB.id)).toEqual({ + kind: "none", + reason: status, + }); + await runtime.close(); + }, + ); + + it("is unknown when billing can't be read, and keeps the last plan billing confirmed (S23)", async () => { + const { runtime, world, deps } = await setup(); + const confirmed = await schoolPlan(deps, world.schoolA.id); + const [cached] = await runtime.db + .select({ lastPlan: schema.schools.lastPlan, lastPlanAt: schema.schools.lastPlanAt }) + .from(schema.schools) + .where(eq(schema.schools.id, world.schoolA.id)); + expect(cached!.lastPlan).toEqual(confirmed); + expect(cached!.lastPlanAt).toEqual(NOW); + + const failing = { + ...deps, + billing: { + commercialState: async () => { + throw new Error("billing down"); + }, + }, + }; + const live = await schoolPlan(failing, world.schoolA.id); + expect(live).toEqual({ kind: "unknown" }); + expect(lastKnownPlan(live, cached!.lastPlan)).toEqual(confirmed); + await runtime.close(); + }); +}); + +describe("planIncludesFeature", () => { + const paid = (plan: "pro" | "business"): SchoolPlan => ({ kind: "paid", plan, status: "active" }); + const trial: SchoolPlan = { kind: "trial", endsAt: NOW }; + + it("follows the plan matrix", () => { + expect(planIncludesFeature(paid("pro"), "remove_branding")).toBe(true); + expect(planIncludesFeature(paid("pro"), "learner_sso")).toBe(false); + expect(planIncludesFeature(paid("business"), "learner_sso")).toBe(true); + expect(planIncludesFeature(paid("business"), "audit_log")).toBe(true); + expect(planIncludesFeature({ kind: "oss" }, "anything")).toBe(true); + }); + + it("gives a trial Business features except removing branding and public code injection", () => { + expect(planIncludesFeature(trial, "learner_sso")).toBe(true); + expect(planIncludesFeature(trial, "scorm")).toBe(true); + expect(planIncludesFeature(trial, "remove_branding")).toBe(false); + expect(planIncludesFeature(trial, "code_injection")).toBe(false); + expect(TRIAL.limits).toEqual({ marketing_emails_month: 0, storage_bytes: 500 * 1024 * 1024 }); + }); + + it("gives nothing to no plan or an unknown one", () => { + expect(planIncludesFeature({ kind: "none", reason: "expired" }, "remove_branding")).toBe(false); + expect(planIncludesFeature({ kind: "unknown" }, "learner_sso")).toBe(false); + }); +}); diff --git a/apps/api/src/school-plan.ts b/apps/api/src/school-plan.ts new file mode 100644 index 000000000..728d87b77 --- /dev/null +++ b/apps/api/src/school-plan.ts @@ -0,0 +1,167 @@ +import type { BillingEngine } from "@codelitdev/billing/workflows"; +import type { Clock } from "@codelitdev/platform"; +import { and, eq, gt, isNull } from "drizzle-orm"; +import * as schema from "./db/schema/index.js"; +import { SSO_FEATURE } from "./sso-config.js"; +import type { AppDb } from "./types.js"; + +/** Features on Pro (docs/wip/plan-limits.md, section 4). */ +export const PRO_FEATURES = ["remove_branding", "code_injection"] as const; + +/** Business has everything Pro has, plus these. */ +export const BUSINESS_FEATURES = [ + ...PRO_FEATURES, + SSO_FEATURE, + "scorm", + "audit_log", +] as const; + +/** What each paid plan unlocks, for `planFeatures` at `createBilling`. */ +export const PLAN_FEATURES: Record<"pro" | "business", readonly string[]> = { + pro: PRO_FEATURES, + business: BUSINESS_FEATURES, +}; + +const MB = 1024 * 1024; +const GB = 1024 * MB; + +/** + * What each paid plan allows (docs/wip/plan-limits.md, section 5). `null` is + * unlimited. These are also the `planLimits` given to the billing package. + */ +export const PLAN_LIMITS: Record< + "pro" | "business", + { marketing_emails_month: number | null; storage_bytes: number | null } +> = { + pro: { marketing_emails_month: 10_000, storage_bytes: 20 * GB }, + business: { marketing_emails_month: 50_000, storage_bytes: 200 * GB }, +}; + +/** + * The trial's own features and limits. It is Business minus removing + * branding and public code injection, with no marketing email and 500 MB. + * Never derived from Business's lists, so the trial can't pick up a Business + * allowance by accident. + */ +export const TRIAL = { + features: BUSINESS_FEATURES.filter( + (feature) => feature !== "remove_branding" && feature !== "code_injection", + ) as readonly string[], + limits: { marketing_emails_month: 0, storage_bytes: 500 * MB }, +} as const; + +/** + * A school's plan, as every feature check, limit check, sync and the Plan + * settings screen must read it. Nothing else calls billing directly. + */ +export type SchoolPlan = + | { kind: "oss" } + | { kind: "paid"; plan: "pro" | "business"; status: string } + | { kind: "trial"; endsAt: Date } + | { kind: "none"; reason: "expired" | "pending_checkout" | "imported" } + | { kind: "unknown" }; + +type Billing = Pick; + +/** + * Reads the school's plan. The order matters: + * 1. `oss` mode has every feature. + * 2. Billing first, so a school that has paid is never treated as a trial + * (S18). A billing read error gives `unknown` (S23). + * 3. A running trial. + * 4. Otherwise none, with the reason taken from the school's state. + * + * Every successful billing read is cached on the school (`last_plan`), for + * callers to fall back on while billing is `unknown`. + */ +export async function schoolPlan( + deps: { db: AppDb; billing: Billing; mode: "cloud" | "oss"; clock: Clock }, + schoolId: string, +): Promise { + if (deps.mode === "oss") return { kind: "oss" }; + const [school] = await deps.db + .select({ + status: schema.schools.status, + lastPlan: schema.schools.lastPlan, + }) + .from(schema.schools) + .where(eq(schema.schools.id, schoolId)) + .limit(1); + if (!school) return { kind: "none", reason: "expired" }; + + let activePaidPlan: string | null; + let subscriptionStatus: string | null; + try { + ({ activePaidPlan, subscriptionStatus } = await deps.billing.commercialState(schoolId)); + } catch { + return { kind: "unknown" }; + } + + let plan: SchoolPlan; + if (activePaidPlan === "pro" || activePaidPlan === "business") { + plan = { kind: "paid", plan: activePaidPlan, status: subscriptionStatus ?? "active" }; + } else { + const now = deps.clock.now(); + const [trial] = await deps.db + .select({ endsAt: schema.schoolTrials.endsAt }) + .from(schema.schoolTrials) + .where( + and( + eq(schema.schoolTrials.schoolId, schoolId), + isNull(schema.schoolTrials.endedAt), + gt(schema.schoolTrials.endsAt, now), + ), + ) + .limit(1); + plan = trial + ? { kind: "trial", endsAt: trial.endsAt } + : { + kind: "none", + reason: + school.status === "pending_checkout" || school.status === "imported" + ? school.status + : "expired", + }; + } + + const stored = JSON.stringify(plan); + if (JSON.stringify(school.lastPlan) !== stored) { + await deps.db + .update(schema.schools) + .set({ lastPlan: JSON.parse(stored), lastPlanAt: deps.clock.now() }) + .where(eq(schema.schools.id, schoolId)); + } + return plan; +} + +/** + * The plan to act on: the live one, or the last one billing confirmed when + * billing can't be read. Feature checks use it; limit checks must not (they + * refuse new usage on `unknown`). + */ +export function lastKnownPlan(plan: SchoolPlan, lastPlan: unknown): SchoolPlan { + if (plan.kind !== "unknown") return plan; + if (lastPlan && typeof lastPlan === "object" && "kind" in lastPlan) { + const cached = lastPlan as SchoolPlan; + if (cached.kind === "trial") { + return { kind: "trial", endsAt: new Date(cached.endsAt) }; + } + return cached; + } + return plan; +} + +/** Whether a plan includes a feature. `unknown` and `none` include nothing. */ +export function planIncludesFeature(plan: SchoolPlan, feature: string): boolean { + switch (plan.kind) { + case "oss": + return true; + case "paid": + return PLAN_FEATURES[plan.plan].includes(feature); + case "trial": + return TRIAL.features.includes(feature); + case "none": + case "unknown": + return false; + } +} diff --git a/apps/api/src/school-state-dispatch.test.ts b/apps/api/src/school-state-dispatch.test.ts new file mode 100644 index 000000000..25adb0c1a --- /dev/null +++ b/apps/api/src/school-state-dispatch.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "bun:test"; +import { eq } from "drizzle-orm"; +import * as schema from "./db/schema/index.js"; +import type { SchoolStatus } from "./db/schema/schools.js"; +import { dispatch } from "./dispatch.js"; +import { createPgliteRuntime, freezeRuntimeClock } from "./runtime.js"; +import { type SeededWorld, seedWorld } from "./seed.js"; + +async function setup(status: SchoolStatus) { + const clock = freezeRuntimeClock(new Date("2026-03-01T00:00:00.000Z")); + const runtime = await createPgliteRuntime({ clock }); + const world = await seedWorld(runtime, clock); + await runtime.db + .update(schema.schools) + .set({ status }) + .where(eq(schema.schools.id, world.schoolA.id)); + return { runtime, world }; +} + +const owner = (world: SeededWorld) => ({ + cookie: world.owner.sessionCookie, + "x-school-id": world.schoolA.publicId, +}); + +const reason = (body: unknown) => + (body as { details?: { reason?: string } }).details?.reason; + +describe("school state at the dispatch edge", () => { + it("leaves an active school alone", async () => { + const { runtime, world } = await setup("active"); + const settings = await dispatch(runtime, { + method: "GET", + path: `/v1/public/${world.schoolA.publicId}/site/settings`, + headers: {}, + }); + expect(settings.status).toBe(200); + await runtime.close(); + }); + + it("read_only: staff can read and the owner can reach billing, but nothing changes", async () => { + const { runtime, world } = await setup("read_only"); + const plan = await dispatch(runtime, { + method: "GET", + path: "/v1/school/billing", + headers: owner(world), + }); + expect(plan.status).toBe(200); + const create = await dispatch(runtime, { + method: "POST", + path: "/v1/products", + headers: owner(world), + body: { title: "New course", type: "course" }, + }); + expect(create.status).toBe(403); + expect(reason(create.body)).toBe("school_read_only"); + await runtime.close(); + }); + + it("read_only: the public site stays up, but nobody can buy or sign up", async () => { + const { runtime, world } = await setup("read_only"); + const settings = await dispatch(runtime, { + method: "GET", + path: `/v1/public/${world.schoolA.publicId}/site/settings`, + headers: {}, + }); + expect(settings.status).toBe(200); + for (const path of ["checkout-sessions", "lead-magnets"]) { + const refused = await dispatch(runtime, { + method: "POST", + path: `/v1/public/${world.schoolA.publicId}/${path}`, + headers: {}, + body: {}, + }); + expect(refused.status).toBe(403); + expect(reason(refused.body)).toBe("school_read_only"); + } + await runtime.close(); + }); + + it("read_only: learners can still ask for a sign-in code", async () => { + const { runtime, world } = await setup("read_only"); + const code = await dispatch(runtime, { + method: "POST", + path: "/v1/learner/auth/email-code", + headers: { "x-school-id": world.schoolA.publicId }, + body: { email: "learner@example.com" }, + }); + expect(code.status).not.toBe(403); + await runtime.close(); + }); + + it.each(["pending_checkout", "imported", "suspended"] as const)( + "%s: the site is offline and learners can't sign in", + async (status) => { + const { runtime, world } = await setup(status); + const settings = await dispatch(runtime, { + method: "GET", + path: `/v1/public/${world.schoolA.publicId}/site/settings`, + headers: {}, + }); + expect(settings.status).toBe(403); + const code = await dispatch(runtime, { + method: "POST", + path: "/v1/learner/auth/email-code", + headers: { "x-school-id": world.schoolA.publicId }, + body: { email: "learner@example.com" }, + }); + expect(code.status).toBe(403); + // The owner can still see the plan. + const plan = await dispatch(runtime, { + method: "GET", + path: "/v1/school/billing", + headers: owner(world), + }); + expect(plan.status).toBe(200); + await runtime.close(); + }, + ); + + it("an API key is staff, not the owner, so it can't use billing on an offline school", async () => { + const { runtime, world } = await setup("pending_checkout"); + const products = await dispatch(runtime, { + method: "GET", + path: "/v1/products", + headers: { "x-api-key": world.apiKeyA.raw }, + }); + expect(products.status).toBe(403); + await runtime.close(); + }); + + it("leaves another school's routes alone", async () => { + const { runtime, world } = await setup("suspended"); + const settings = await dispatch(runtime, { + method: "GET", + path: `/v1/public/${world.schoolB.publicId}/site/settings`, + headers: {}, + }); + expect(settings.status).toBe(200); + await runtime.close(); + }); +}); diff --git a/apps/api/src/school-state.test.ts b/apps/api/src/school-state.test.ts new file mode 100644 index 000000000..bf70d0cce --- /dev/null +++ b/apps/api/src/school-state.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "bun:test"; +import { schoolSchema } from "@courselit/api-contract"; +import { OPERATION_POLICIES } from "./auth/operation-policy.js"; +import { SCHOOL_STATUSES, type SchoolStatus } from "./db/schema/schools.js"; +import { type SchoolStateActor, schoolStateAllows } from "./school-state.js"; + +const allows = ( + status: SchoolStatus, + actor: SchoolStateActor, + method: string, + operationId: string | null, +) => schoolStateAllows({ status, actor, method, operationId }); + +const ACTORS: SchoolStateActor[] = ["owner", "team", "learner", "public"]; + +describe("schoolStateAllows", () => { + it("names only operations that exist in the contract", async () => { + // A renamed operation must not silently fall out of an allowlist. + const source = await Bun.file(new URL("./school-state.ts", import.meta.url)).text(); + const named = [...source.matchAll(/^\s+"([a-zA-Z]+)",$/gm)].map((match) => match[1]!); + expect(named.length).toBeGreaterThan(10); + for (const operationId of named) { + expect(operationId in OPERATION_POLICIES).toBe(true); + } + }); + + it("lets everyone do everything while a school is active, in maintenance or migrating", () => { + for (const status of ["active", "maintenance", "migrating"] as const) { + for (const actor of ACTORS) { + expect(allows(status, actor, "POST", "createCheckoutSession")).toBe(true); + expect(allows(status, actor, "DELETE", null)).toBe(true); + } + } + }); + + it("refuses everything for a deleted school", () => { + for (const actor of ACTORS) { + expect(allows("deleted", actor, "GET", "getPublicSiteSettings")).toBe(false); + } + }); + + describe("read_only (the grace period)", () => { + it("allows every read", () => { + for (const actor of ACTORS) { + expect(allows("read_only", actor, "GET", "getPublicSitePage")).toBe(true); + } + }); + + it("refuses new purchases, sign-ups, joins, posts and uploads", () => { + for (const operationId of [ + "createCheckoutSession", + "learnerCheckout", + "startLearnerCheckoutSession", + "startLearnerCommunityCheckout", + "createLearnerMembership", + "signUpForLeadMagnet", + "subscribeNewsletter", + "createLearnerCommunityPost", + "createLearnerCommunityComment", + "authorizeLearnerCommunityMediaUpload", + "authorizeLearnerAvatarMediaUpload", + ]) { + expect(allows("read_only", "learner", "POST", operationId)).toBe(false); + expect(allows("read_only", "public", "POST", operationId)).toBe(false); + } + }); + + it("lets learners sign in, use what they have, and cancel their subscriptions", () => { + for (const operationId of [ + "learnerRequestEmailCode", + "learnerVerifyEmailCode", + "consumeSchoolAuthTicket", + "learnerSignOut", + "learnerCompleteLesson", + "updateLearnerProfile", + "cancelLearnerProductSubscription", + ]) { + expect(allows("read_only", "learner", "POST", operationId)).toBe(true); + } + }); + + it("lets the owner pick a plan but change nothing else", () => { + expect(allows("read_only", "owner", "POST", "startSchoolBillingCheckout")).toBe(true); + expect(allows("read_only", "owner", "POST", "resumeSchoolBillingPlan")).toBe(true); + expect(allows("read_only", "owner", "PATCH", "updateProduct")).toBe(false); + expect(allows("read_only", "owner", "POST", null)).toBe(false); + }); + + it("gives the rest of the team reads only, and never billing", () => { + expect(allows("read_only", "team", "GET", "listProducts")).toBe(true); + expect(allows("read_only", "team", "POST", "startSchoolBillingCheckout")).toBe(false); + expect(allows("read_only", "team", "PATCH", "updateProduct")).toBe(false); + }); + }); + + describe("offline: pending_checkout and imported", () => { + it("is closed to learners and the public, even for reads", () => { + for (const status of ["pending_checkout", "imported"] as const) { + for (const actor of ["learner", "public", "team"] as const) { + expect(allows(status, actor, "GET", "getPublicSiteSettings")).toBe(false); + expect(allows(status, actor, "POST", "learnerRequestEmailCode")).toBe(false); + } + } + }); + + it("lets the owner read and reach checkout", () => { + for (const status of ["pending_checkout", "imported"] as const) { + expect(allows(status, "owner", "GET", "getSchoolBillingPlan")).toBe(true); + expect(allows(status, "owner", "POST", "startSchoolBillingCheckout")).toBe(true); + expect(allows(status, "owner", "PATCH", "updateProduct")).toBe(false); + } + }); + }); + + describe("suspended", () => { + it("is closed to everyone but the owner; learners can't even sign in", () => { + for (const actor of ["learner", "public", "team"] as const) { + expect(allows("suspended", actor, "GET", "getPublicSiteSettings")).toBe(false); + expect(allows("suspended", actor, "POST", "learnerRequestEmailCode")).toBe(false); + } + }); + + it("lets the owner read and cancel or resume the plan, but not start a new one", () => { + expect(allows("suspended", "owner", "GET", "getSchoolBillingPlan")).toBe(true); + expect(allows("suspended", "owner", "POST", "cancelSchoolBillingPlan")).toBe(true); + expect(allows("suspended", "owner", "POST", "startSchoolBillingCheckout")).toBe(false); + }); + }); + + it("uses the same statuses as the API contract", () => { + expect([...schoolSchema.shape.status.options].sort()).toEqual([...SCHOOL_STATUSES].sort()); + }); + + it("covers every school status", () => { + for (const status of SCHOOL_STATUSES) { + expect(typeof allows(status, "owner", "GET", null)).toBe("boolean"); + } + }); +}); diff --git a/apps/api/src/school-state.ts b/apps/api/src/school-state.ts new file mode 100644 index 000000000..e2ef17960 --- /dev/null +++ b/apps/api/src/school-state.ts @@ -0,0 +1,108 @@ +import { createPlatformError, type PlatformError } from "@codelitdev/platform"; +import type { SchoolStatus } from "./db/schema/schools.js"; + +/** + * Who is calling, as far as a school's state cares: + * - `owner` and `team`: staff of the school (API keys count as `team`); + * - `learner`: a signed-in learner of the school; + * - `public`: anyone on the school's public routes. + */ +export type SchoolStateActor = "owner" | "team" | "learner" | "public"; + +/** Owner operations that stay open while a school is read-only or offline. */ +const OWNER_BILLING_OPERATIONS: ReadonlySet = new Set([ + "getSchoolBillingPlan", + "startSchoolBillingCheckout", + "cancelSchoolBillingPlan", + "resumeSchoolBillingPlan", +]); + +/** What a suspended school's owner can still do with the CourseLit plan. */ +const SUSPENDED_OWNER_OPERATIONS: ReadonlySet = new Set([ + "getSchoolBillingPlan", + "cancelSchoolBillingPlan", + "resumeSchoolBillingPlan", +]); + +/** + * Learner and public writes that stay open during the read-only grace period: + * signing in and out, and using content the learner already has. Everything + * else that writes (checkout, joining, posting, uploading, signing up) is + * refused, so nobody pays or joins a school that is about to be deleted. + */ +const READ_ONLY_LEARNER_WRITES: ReadonlySet = new Set([ + "learnerRequestEmailCode", + "learnerVerifyEmailCode", + "learnerStartGoogleSignIn", + "learnerStartSsoSignIn", + "consumeSchoolAuthTicket", + "learnerSignOut", + "updateLearnerProfile", + "cancelLearnerProductSubscription", + "learnerCompleteLesson", + "evaluateLearnerQuiz", + "updateLearnerScormRuntime", + "learnerCreateDownloadLink", + "markLearnerNotificationRead", + "markAllLearnerNotificationsRead", + "updateLearnerNotificationPreference", +]); + +const isRead = (method: string) => method === "GET" || method === "HEAD"; + +/** + * Whether a school in `status` lets `actor` run `operationId`. It is an + * allowlist that fails closed: anything not named here is refused while the + * school is read-only or offline (docs/wip/plan-limits.md, section 3 and S26). + * `operationId` is null for routes outside the contract, which only reads may + * pass. + */ +export function schoolStateAllows(input: { + status: SchoolStatus; + actor: SchoolStateActor; + method: string; + operationId: string | null; +}): boolean { + const { status, actor, method, operationId } = input; + const read = isRead(method); + switch (status) { + case "active": + case "maintenance": + case "migrating": + return true; + case "read_only": + if (read) return true; + if (operationId === null) return false; + if (actor === "owner") return OWNER_BILLING_OPERATIONS.has(operationId); + if (actor === "team") return false; + return READ_ONLY_LEARNER_WRITES.has(operationId); + case "pending_checkout": + case "imported": + // Offline until the owner picks a plan. + return ( + actor === "owner" && + (read || (operationId !== null && OWNER_BILLING_OPERATIONS.has(operationId))) + ); + case "suspended": + return ( + actor === "owner" && + (read || (operationId !== null && SUSPENDED_OWNER_OPERATIONS.has(operationId))) + ); + case "deleted": + return false; + } +} + +/** The error a refused request gets. It names the state, not the plan or usage. */ +export function schoolStateError(status: SchoolStatus): PlatformError { + return createPlatformError("forbidden", { + safeDetails: { + reason: + status === "read_only" + ? "school_read_only" + : status === "suspended" + ? "school_suspended" + : "school_unavailable", + }, + }); +} diff --git a/apps/api/src/schools.ts b/apps/api/src/schools.ts index 0b2a8ceb5..21c9c5a5e 100644 --- a/apps/api/src/schools.ts +++ b/apps/api/src/schools.ts @@ -7,6 +7,7 @@ import { } from "@codelitdev/platform"; import type { MediaRef } from "@courselit/api-contract"; import { and, eq, inArray, ne } from "drizzle-orm"; +import type { SchoolStatus } from "./db/schema/schools.js"; import * as schema from "./db/schema/index.js"; import { normalizeEmail } from "./invitations.js"; import { schoolHasPaidPayments } from "./revenue.js"; @@ -43,7 +44,7 @@ export type SchoolDto = { storefrontHost: string | null; /** Where the storefront is reached, e.g. `https://school.example.com`. */ storefrontOrigin: string | null; - status: "active" | "read_only" | "maintenance" | "migrating" | "deleted"; + status: SchoolStatus; locale: string; currency: string; permissions?: CourseLitPermission[]; @@ -138,6 +139,10 @@ export async function createSchool( paymentSettingsEncrypted: null, codeInjectionHead: "", codeInjectionBody: "", + graceEndsAt: null, + suspendedReason: null, + lastPlan: null, + lastPlanAt: null, createdAt: now, updatedAt: now, }; diff --git a/bun.lock b/bun.lock index 7206f868e..767daf23f 100644 --- a/bun.lock +++ b/bun.lock @@ -60,11 +60,11 @@ "@better-auth/oauth-provider": "1.7.22", "@better-auth/utils": "0.5.1", "@better-fetch/fetch": "1.3.3", - "@codelitdev/billing": "0.11.0", + "@codelitdev/billing": "0.12.0", "@codelitdev/mcp-server-kit": "0.5.0", "@codelitdev/oauth-server-kit": "0.10.0", "@codelitdev/observability": "0.5.0", - "@codelitdev/platform": "0.11.0", + "@codelitdev/platform": "0.12.0", "@codelitdev/platform-conformance": "0.9.0", "@courselit/api-contract": "workspace:*", "@frontlit/text-editor": "0.1.0-alpha.3", @@ -288,7 +288,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-B1R/l+CwEpKFSuqiwePzPNRk1EiJN8kc0UhdafNz6MZN9v5OFP9HYP1irptvWzHrwVI4blVNGMbxc5zt70m3IA=="], - "@codelitdev/billing": ["@codelitdev/billing@0.11.0", "", { "dependencies": { "drizzle-orm": "^0.45.2", "nanoid": "^5.1.6", "zod": "^3.25.76" }, "peerDependencies": { "dodopayments": "^2.48.0" }, "optionalPeers": ["dodopayments"], "bin": { "codelit-billing": "bin/codelit-billing.js" } }, "sha512-eqX7WPmqlVKSDK10x/IkasQws+EKomnqPR68VrBto4hLzH2pMkF6NWAAg4vgyyOXqMykG85flth1GlDU72noFA=="], + "@codelitdev/billing": ["@codelitdev/billing@0.12.0", "", { "dependencies": { "drizzle-orm": "^0.45.2", "nanoid": "^5.1.6", "zod": "^3.25.76" }, "peerDependencies": { "dodopayments": "^2.48.0" }, "optionalPeers": ["dodopayments"], "bin": { "codelit-billing": "bin/codelit-billing.js" } }, "sha512-Q7jif1sKkxbf1vatezIP4K4RWZvYiP5idijdvNqS+ZD+7vsybgfod5d2kbwJX4D3XqQ6Kr717i+crWKyKJj1bg=="], "@codelitdev/design-system": ["@codelitdev/design-system@0.5.0", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-jcfS2X6HKc4wbDM3mIetOWyockNq0pEphlj5HXCXUrNnl0UPru1X9g4hPrX7KI2gsIyUsQwCm4rFVv8tLpY4Iw=="], @@ -298,7 +298,7 @@ "@codelitdev/observability": ["@codelitdev/observability@0.5.0", "", { "dependencies": { "pino": "^9.6.0" }, "peerDependencies": { "express": "^4.21.2", "posthog-js": "^1.200.0", "posthog-node": "^4.0.0" }, "optionalPeers": ["express", "posthog-js", "posthog-node"] }, "sha512-rQcB9fkaustBImNc3u7MSE4eCufD+DyLA02u2jLqoWcrq2cjrmTjbWrJ4AV4gdhtMGuiLjM0R6vdF9PWsTB7aQ=="], - "@codelitdev/platform": ["@codelitdev/platform@0.11.0", "", { "dependencies": { "tldts": "7.4.19" }, "peerDependencies": { "@codelitdev/billing": ">=0.11.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["@codelitdev/billing", "drizzle-orm"] }, "sha512-4Q8HbLNyZodiWVEICEltrWTK8yjqAFCXb38yonZdSXr9eALxrCtfChf5xb9844N0hPVVmYSH+jcZpDtUtqhixg=="], + "@codelitdev/platform": ["@codelitdev/platform@0.12.0", "", { "dependencies": { "tldts": "7.4.19" }, "peerDependencies": { "@codelitdev/billing": ">=0.12.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["@codelitdev/billing", "drizzle-orm"] }, "sha512-TMCfsRGxTbI+fR+RbGwjtYcZHOe0eXaf6Bo9+GP0UvMCQ9XkOS6Pg7bZmsNf9WNxMy4LC5zqKAdM/AjEYYR5nQ=="], "@codelitdev/platform-conformance": ["@codelitdev/platform-conformance@0.9.0", "", { "peerDependencies": { "@codelitdev/platform": ">=0.9.0" } }, "sha512-zP4u1MomIfiE3MVCYvUfO0M0zgqmyvUc6cDxbWw+x+rwErw3NJIoDKVmOl+h2gECWngA6IONIf4k5dmdqAKxfg=="], diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index 60ddc4a48..ebe9bc0eb 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -536,7 +536,16 @@ export const schoolSchema = z.object({ subdomain: z.string(), storefrontHost: z.string().nullable(), storefrontOrigin: z.string().nullable(), - status: z.enum(["active", "read_only", "maintenance", "migrating", "deleted"]), + status: z.enum([ + "active", + "pending_checkout", + "imported", + "read_only", + "suspended", + "maintenance", + "migrating", + "deleted", + ]), locale: z.string(), currency: z.string(), permissions: z.array(z.string()).optional(), From 50c5d1fabd00ecafc7a8bdd66f8da0bb701cf653 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 14 Nov 2026 21:40:46 +0530 Subject: [PATCH 04/27] feat(api): set sendlit marketing quotas from the school's plan --- apps/api/src/drip-emails.ts | 46 ++++- apps/api/src/integration-provisioning.test.ts | 25 ++- apps/api/src/integration-provisioning.ts | 32 +++- apps/api/src/mail-automation.test.ts | 82 +++++++- apps/api/src/marketing-quota.test.ts | 178 ++++++++++++++++++ apps/api/src/marketing-quota.ts | 100 ++++++++++ apps/api/src/sendlit-client.ts | 51 ++++- apps/api/src/sso-jobs.ts | 40 +++- apps/api/src/worker.ts | 1 + 9 files changed, 541 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/marketing-quota.test.ts create mode 100644 apps/api/src/marketing-quota.ts diff --git a/apps/api/src/drip-emails.ts b/apps/api/src/drip-emails.ts index 96baefe5c..1651f850d 100644 --- a/apps/api/src/drip-emails.ts +++ b/apps/api/src/drip-emails.ts @@ -13,10 +13,32 @@ import { sectionUnlockAt } from "./catalog.js"; import * as schema from "./db/schema/index.js"; import type { CourseLitPermission } from "./permissions.js"; import { + isSendLitQuotaExhausted, type SendLitConfig, sendLitConfig, sendSendLitTransactionalEmail, } from "./sendlit-client.js"; + +/** + * How long drip waits after SendLit refuses a send for quota. SendLit gives no + * retry time, so drip checks back hourly; the quota sync clears the pause as + * soon as it raises the quota (an upgrade, or a new month's allowance). + */ +export const DRIP_QUOTA_PAUSE_MS = 60 * 60 * 1000; + +async function marketingPausedUntil(db: AppDb, schoolId: string): Promise { + const [row] = await db + .select({ until: schema.schoolIntegrations.marketingPausedUntil }) + .from(schema.schoolIntegrations) + .where( + and( + eq(schema.schoolIntegrations.schoolId, schoolId), + eq(schema.schoolIntegrations.provider, "sendlit"), + ), + ) + .limit(1); + return row?.until ?? null; +} import { getSendLitAuthForSchool } from "./sendlit-service.js"; import type { AppDb } from "./types.js"; @@ -155,7 +177,7 @@ export async function processDripEmails( db: AppDb, clock: Clock, options: { - logger?: Pick; + logger?: Pick; config?: SendLitConfig; limit?: number; sendEmail?: typeof sendSendLitTransactionalEmail; @@ -250,6 +272,10 @@ export async function processDripEmails( }); if (due.length === 0) continue; + // Out of marketing quota: wait quietly until the pause ends. + const pausedUntil = await marketingPausedUntil(db, section.schoolId); + if (pausedUntil && pausedUntil > now) continue; + const auth = await getSendLitAuthForSchool(db, section.schoolId, clock, config); if (!auth.ok) { options.logger?.warn( @@ -297,6 +323,24 @@ export async function processDripEmails( await db .delete(schema.sectionDripEmailDeliveries) .where(eq(schema.sectionDripEmailDeliveries.id, claimed[0]!.id)); + if (isSendLitQuotaExhausted(error)) { + // The school is out of marketing quota (a trial's is 0). Pause it + // instead of failing every learner on every run. + await db + .update(schema.schoolIntegrations) + .set({ marketingPausedUntil: new Date(now.getTime() + DRIP_QUOTA_PAUSE_MS) }) + .where( + and( + eq(schema.schoolIntegrations.schoolId, section.schoolId), + eq(schema.schoolIntegrations.provider, "sendlit"), + ), + ); + options.logger?.info( + { schoolId: section.schoolId, sectionId: section.publicId }, + "drip emails paused: marketing quota used up", + ); + break; + } options.logger?.warn( { schoolId: section.schoolId, diff --git a/apps/api/src/integration-provisioning.test.ts b/apps/api/src/integration-provisioning.test.ts index ba02eb8e5..f5b85749c 100644 --- a/apps/api/src/integration-provisioning.test.ts +++ b/apps/api/src/integration-provisioning.test.ts @@ -122,7 +122,12 @@ describe("CourseLit SendLit provisioning", () => { expect(created.ok).toBe(true); if (!created.ok) throw new Error("school_creation_failed"); - const provisioned: Array<{ name: string; sender?: { fromName?: string } }> = []; + const provisioned: Array<{ + name: string; + sender?: { fromName?: string }; + quota?: { monthlyLimit?: number | null }; + }> = []; + const quotas: Array<{ teamId: string; monthlyLimit: number | null }> = []; const sendLitOperations: SendLitOperations = { provisionTeam: async (input) => { provisioned.push(input); @@ -135,6 +140,9 @@ describe("CourseLit SendLit provisioning", () => { }; }, rotateKey: async () => ({ keyId: "key_1", key: "sl_live_rotated_key" }), + updateTeamQuota: async (teamId, quota) => { + quotas.push({ teamId, monthlyLimit: quota.monthlyLimit }); + }, }; const processed = await processNextIntegrationJob( @@ -147,6 +155,10 @@ describe("CourseLit SendLit provisioning", () => { expect(processed).toBe(true); // Its email goes out under the school's name, not the shared account's. expect(provisioned[0]?.sender?.fromName).toBe("SendLit School"); + // A new team never starts unlimited (S20): provisioned with the smallest + // quota SendLit accepts, then set to 0 before it is marked ready. + expect(provisioned[0]?.quota).toEqual({ monthlyLimit: 1 }); + expect(quotas).toEqual([{ teamId: "sl_team_1", monthlyLimit: 0 }]); const [ready] = await runtime.db .select() @@ -159,6 +171,17 @@ describe("CourseLit SendLit provisioning", () => { ); expect(ready).toMatchObject({ status: "ready", remoteTeamId: "sl_team_1" }); expect(ready!.encryptedTeamKey).not.toContain("sl_live_key_999"); + // The plan sync that sets the real allowance is queued with the ready mark. + const syncJobs = await runtime.db + .select() + .from(schema.integrationOutboxJobs) + .where( + and( + eq(schema.integrationOutboxJobs.schoolId, ready!.schoolId), + eq(schema.integrationOutboxJobs.type, "sync_plan_features"), + ), + ); + expect(syncJobs).toHaveLength(1); await runtime.close(); }, 20000); diff --git a/apps/api/src/integration-provisioning.ts b/apps/api/src/integration-provisioning.ts index 9556697f7..c19f927e0 100644 --- a/apps/api/src/integration-provisioning.ts +++ b/apps/api/src/integration-provisioning.ts @@ -21,6 +21,7 @@ import { listSendLitContacts, provisionSendLitTeam, rotateSendLitTeamKey, + updateSendLitTeamQuota, SendLitApiError, type SendLitConfig, type SendLitContact, @@ -33,6 +34,7 @@ import type { TrackSendLitEventPayload } from "./membership-events.js"; import type { SendSchoolEmailPayload } from "./school-emails.js"; import { sendSystemMail } from "./system-mail.js"; import { deleteShadowUserIfOrphaned } from "./shadow-users.js"; +import { enqueuePlanFeatureSync } from "./plan-features.js"; import type { AppDb } from "./types.js"; import { decryptIntegrationSecret, @@ -57,6 +59,7 @@ export function effectiveTeamPermissions( export type SendLitOperations = { provisionTeam: typeof provisionSendLitTeam; rotateKey: typeof rotateSendLitTeamKey; + updateTeamQuota?: typeof updateSendLitTeamQuota; createContact?: typeof createSendLitContact; getContact?: typeof getSendLitContact; updateContact?: typeof updateSendLitContact; @@ -70,6 +73,7 @@ export type SendLitOperations = { const defaultSendLitOperations: SendLitOperations = { provisionTeam: provisionSendLitTeam, rotateKey: rotateSendLitTeamKey, + updateTeamQuota: updateSendLitTeamQuota, createContact: createSendLitContact, getContact: getSendLitContact, updateContact: updateSendLitContact, @@ -183,6 +187,9 @@ async function provisionOneSendLitSchool( // Learners see the school, not the shared sending account, as the sender. sender: { fromName: payload.name }, mailingAddress: payload.mailingAddress, + // SendLit only accepts a positive quota here; it is set to 0 below + // before the team is used, so a new team never starts unlimited (S20). + quota: { monthlyLimit: 1 }, }, { config }, ); @@ -204,13 +211,24 @@ async function provisionOneSendLitSchool( ); } - await updateIntegration(db, job.schoolId, "sendlit", { - remoteTeamId: teamId, - encryptedTeamKey: encryptIntegrationSecret(teamApiKey), - status: "ready", - lastSuccessfulSyncAt: clock.now(), - lastError: null, - updatedAt: clock.now(), + // Fail closed: no marketing sends until the plan sync sets the school's + // real allowance. The sync is queued with the ready mark, below. Until + // this PATCH succeeds the integration is not ready, and the job retries. + if (!operations.updateTeamQuota) { + throw new Error("SendLit updateTeamQuota operation is required to provision"); + } + await operations.updateTeamQuota(teamId, { monthlyLimit: 0 }, { config }); + + await db.transaction(async (tx) => { + await updateIntegration(tx as unknown as AppDb, job.schoolId, "sendlit", { + remoteTeamId: teamId, + encryptedTeamKey: encryptIntegrationSecret(teamApiKey!), + status: "ready", + lastSuccessfulSyncAt: clock.now(), + lastError: null, + updatedAt: clock.now(), + }); + await enqueuePlanFeatureSync(tx as unknown as AppDb, job.schoolId, clock); }); } else { await updateIntegration(db, job.schoolId, "sendlit", { diff --git a/apps/api/src/mail-automation.test.ts b/apps/api/src/mail-automation.test.ts index bfe1c9804..a296dadc6 100644 --- a/apps/api/src/mail-automation.test.ts +++ b/apps/api/src/mail-automation.test.ts @@ -3,7 +3,8 @@ import { uuidv7 } from "@codelitdev/platform"; import { and, eq, inArray } from "drizzle-orm"; import * as schema from "./db/schema/index.js"; import { dispatch } from "./dispatch.js"; -import { processDripEmails } from "./drip-emails.js"; +import { DRIP_QUOTA_PAUSE_MS, processDripEmails } from "./drip-emails.js"; +import { SendLitApiError } from "./sendlit-client.js"; import { processNextIntegrationJob, type SendLitOperations, @@ -379,4 +380,83 @@ describe("section drip emails", () => { ).toBe(0); await runtime.close(); }); + + it("pauses a school out of marketing quota instead of failing every learner", async () => { + const { runtime, adminHeaders, productId } = await setUp(); + const createdSection = await dispatch(runtime, { + method: "POST", + path: `/v1/products/${productId}/sections`, + headers: adminHeaders, + body: { title: "First section" }, + }); + const sectionId = (createdSection.body as { id: string }).id; + await dispatch(runtime, { + method: "PATCH", + path: `/v1/sections/${sectionId}`, + headers: adminHeaders, + body: { dripEnabled: true, dripType: "relative-date", dripDelaySeconds: 86_400 }, + }); + await dispatch(runtime, { + method: "PUT", + path: `/v1/sections/${sectionId}/drip-email`, + headers: adminHeaders, + body: { + enabled: true, + subject: "Week 2 is open", + content: { content: [] }, + html: "

Week 2 is open

", + }, + }); + await grantMembership(runtime, adminHeaders, productId); + + let attempts = 0; + let quotaLeft = false; + const sent: string[] = []; + const sendEmail = async (_key: string, input: { to: string }) => { + attempts += 1; + if (!quotaLeft) { + throw new SendLitApiError( + "SendLit request failed with HTTP 429: organization_team_quota_exhausted", + 429, + true, + ); + } + sent.push(input.to); + return { txeId: "txe_1" }; + }; + const warnings: unknown[] = []; + const logger = { warn: (...args: unknown[]) => warnings.push(args), info: () => {} }; + + const unlocked = freezeRuntimeClock(new Date("2026-03-02T01:00:00.000Z")); + expect( + await processDripEmails(runtime.db, unlocked, { config: sendLitConfig, sendEmail, logger }), + ).toBe(0); + expect(attempts).toBe(1); + expect(warnings).toEqual([]); + const [integration] = await runtime.db + .select({ until: schema.schoolIntegrations.marketingPausedUntil }) + .from(schema.schoolIntegrations) + .where(eq(schema.schoolIntegrations.provider, "sendlit")); + expect(integration!.until).toEqual( + new Date(unlocked.now().getTime() + DRIP_QUOTA_PAUSE_MS), + ); + + // Inside the pause, SendLit isn't asked again. + const later = freezeRuntimeClock(new Date("2026-03-02T01:30:00.000Z")); + await processDripEmails(runtime.db, later, { config: sendLitConfig, sendEmail, logger }); + expect(attempts).toBe(1); + + // The quota sync clears the pause when it raises the quota; the learner + // whose claim was released gets the email. + quotaLeft = true; + await runtime.db + .update(schema.schoolIntegrations) + .set({ marketingPausedUntil: null }) + .where(eq(schema.schoolIntegrations.provider, "sendlit")); + expect( + await processDripEmails(runtime.db, later, { config: sendLitConfig, sendEmail, logger }), + ).toBe(1); + expect(sent).toEqual(["buyer@example.com"]); + await runtime.close(); + }); }); diff --git a/apps/api/src/marketing-quota.test.ts b/apps/api/src/marketing-quota.test.ts new file mode 100644 index 000000000..3129b4e86 --- /dev/null +++ b/apps/api/src/marketing-quota.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "bun:test"; +import { uuidv7 } from "@codelitdev/platform"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import * as schema from "./db/schema/index.js"; +import { marketingQuotaFor, syncMarketingQuota } from "./marketing-quota.js"; +import { createPgliteRuntime, freezeRuntimeClock } from "./runtime.js"; +import { seedWorld } from "./seed.js"; +import { provisionSendLitTeam, updateSendLitTeamQuota } from "./sendlit-client.js"; + +const config = { server: "http://sendlit.test", provisioningApiKey: "sl_org_key" }; + +describe("marketingQuotaFor", () => { + it("follows the plan limits", () => { + expect(marketingQuotaFor({ kind: "paid", plan: "pro", status: "active" })).toBe(10_000); + expect(marketingQuotaFor({ kind: "paid", plan: "business", status: "active" })).toBe(50_000); + expect(marketingQuotaFor({ kind: "trial", endsAt: new Date() })).toBe(0); + expect(marketingQuotaFor({ kind: "none", reason: "expired" })).toBe(0); + expect(marketingQuotaFor({ kind: "oss" })).toBeNull(); + }); + + it("gives nothing to act on when billing can't be read", () => { + expect(marketingQuotaFor({ kind: "unknown" })).toBeUndefined(); + }); +}); + +async function setup(billingMode: "cloud" | "oss" = "cloud") { + const clock = freezeRuntimeClock(new Date("2026-03-01T00:00:00.000Z")); + const runtime = await createPgliteRuntime({ clock, billingMode }); + const world = await seedWorld(runtime, clock); + const now = clock.now(); + for (const [schoolId, teamId] of [ + [world.schoolA.id, "team_a"], + [world.schoolB.id, "team_b"], + ] as const) { + await runtime.db + .delete(schema.schoolIntegrations) + .where( + // Replace whatever the seed made with a ready team. + eq(schema.schoolIntegrations.schoolId, schoolId), + ); + await runtime.db.insert(schema.schoolIntegrations).values({ + id: uuidv7(clock), + schoolId, + provider: "sendlit", + server: config.server, + externalId: `ext_${teamId}`, + remoteTeamId: teamId, + status: "ready", + createdAt: now, + updatedAt: now, + }); + } + const calls: Array<{ teamId: string; monthlyLimit: number | null }> = []; + const deps = { + db: runtime.db, + billing: runtime.billing.billing, + mode: billingMode, + clock, + config, + updateTeamQuota: (async (teamId: string, quota: { monthlyLimit: number | null }) => { + calls.push({ teamId, monthlyLimit: quota.monthlyLimit }); + }) as typeof updateSendLitTeamQuota, + }; + return { runtime, world, deps, calls }; +} + +describe("syncMarketingQuota", () => { + it("sets a paid school's team to its plan's allowance and a school with no plan to 0", async () => { + const { runtime, world, deps, calls } = await setup(); + expect(await syncMarketingQuota(deps, world.schoolA.id)).toBe("updated"); + expect(await syncMarketingQuota(deps, world.schoolB.id)).toBe("updated"); + expect(calls).toEqual([ + { teamId: "team_a", monthlyLimit: 10_000 }, + { teamId: "team_b", monthlyLimit: 0 }, + ]); + await runtime.close(); + }); + + it("sets unlimited in oss mode", async () => { + const { runtime, world, deps, calls } = await setup("oss"); + await syncMarketingQuota(deps, world.schoolA.id); + expect(calls).toEqual([{ teamId: "team_a", monthlyLimit: null }]); + await runtime.close(); + }); + + it("changes nothing and throws, so the job retries, when billing can't be read", async () => { + const { runtime, world, deps, calls } = await setup(); + const failing = { + ...deps, + billing: { + commercialState: async () => { + throw new Error("billing down"); + }, + }, + }; + await expect(syncMarketingQuota(failing, world.schoolA.id)).rejects.toThrow( + "billing_unavailable", + ); + expect(calls).toEqual([]); + await runtime.close(); + }); + + it("skips a school whose SendLit team isn't ready, and skips when SendLit isn't configured", async () => { + const { runtime, world, deps, calls } = await setup(); + await runtime.db + .update(schema.schoolIntegrations) + .set({ status: "provisioning" }) + .where(eq(schema.schoolIntegrations.schoolId, world.schoolA.id)); + expect(await syncMarketingQuota(deps, world.schoolA.id)).toBe("skipped"); + expect( + await syncMarketingQuota({ ...deps, config: { server: "", provisioningApiKey: "" } }, world.schoolB.id), + ).toBe("skipped"); + expect(calls).toEqual([]); + await runtime.close(); + }); +}); + +/** + * A pinned copy of SendLit's quota schemas (sendlit + * `packages/api-contract/src/schemas/teams.ts`), which is a private package. + * SendLit's zod objects drop unknown keys, so a wrong field name is silently + * ignored and the team ends up unlimited (S20). Update both sides together. + */ +const provisionQuota = z + .object({ + dailyLimit: z.number().int().positive().nullable().optional(), + monthlyLimit: z.number().int().positive().nullable().optional(), + }) + .strict(); +const updateQuota = z + .object({ + dailyLimit: z.number().int().nonnegative().nullable().optional(), + monthlyLimit: z.number().int().nonnegative().nullable().optional(), + }) + .strict(); + +function capture() { + const bodies: Array<{ method: string; path: string; body: Record }> = []; + const fetcher = (async (url: string | URL, init?: RequestInit) => { + bodies.push({ + method: init?.method ?? "GET", + path: new URL(String(url)).pathname, + body: init?.body ? JSON.parse(String(init.body)) : {}, + }); + return new Response( + JSON.stringify({ teamId: "t1", externalId: "e1", name: "n", created: true, apiKey: "k" }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; + return { bodies, fetcher }; +} + +describe("SendLit quota contract", () => { + it("provisions with a quota SendLit accepts and understands", async () => { + const { bodies, fetcher } = capture(); + await provisionSendLitTeam( + { externalId: "e1", name: "School", quota: { monthlyLimit: 1 } }, + { config, fetcher }, + ); + expect(bodies[0]!.method).toBe("POST"); + expect(provisionQuota.parse(bodies[0]!.body.quota)).toEqual({ monthlyLimit: 1 }); + // 0 is refused at provisioning; it must be set with PATCH. + expect(provisionQuota.safeParse({ monthlyLimit: 0 }).success).toBe(false); + }); + + it("sets 0 and unlimited with PATCH, using SendLit's field names", async () => { + const { bodies, fetcher } = capture(); + await updateSendLitTeamQuota("t1", { monthlyLimit: 0 }, { config, fetcher }); + await updateSendLitTeamQuota("t1", { monthlyLimit: null }, { config, fetcher }); + expect(bodies.map((call) => `${call.method} ${call.path}`)).toEqual([ + "PATCH /provisioning/teams/t1", + "PATCH /provisioning/teams/t1", + ]); + expect(updateQuota.parse(bodies[0]!.body.quota)).toEqual({ monthlyLimit: 0 }); + expect(updateQuota.parse(bodies[1]!.body.quota)).toEqual({ monthlyLimit: null }); + }); +}); diff --git a/apps/api/src/marketing-quota.ts b/apps/api/src/marketing-quota.ts new file mode 100644 index 000000000..8acfefbc4 --- /dev/null +++ b/apps/api/src/marketing-quota.ts @@ -0,0 +1,100 @@ +import type { BillingEngine } from "@codelitdev/billing/workflows"; +import type { Clock } from "@codelitdev/platform"; +import { and, eq, isNotNull } from "drizzle-orm"; +import * as schema from "./db/schema/index.js"; +import { PLAN_LIMITS, type SchoolPlan, schoolPlan, TRIAL } from "./school-plan.js"; +import { + type SendLitConfig, + sendLitConfig, + updateSendLitTeamQuota, +} from "./sendlit-client.js"; +import type { AppDb } from "./types.js"; + +/** + * The monthly marketing email quota for a plan: a number, `null` for + * unlimited, or `undefined` when the plan is unknown (billing couldn't be + * read), in which case nothing may be changed. + */ +export function marketingQuotaFor(plan: SchoolPlan): number | null | undefined { + switch (plan.kind) { + case "oss": + return null; + case "paid": + return PLAN_LIMITS[plan.plan].marketing_emails_month; + case "trial": + return TRIAL.limits.marketing_emails_month; + case "none": + return 0; + case "unknown": + return undefined; + } +} + +export type MarketingQuotaDeps = { + db: AppDb; + billing: Pick; + mode: "cloud" | "oss"; + clock: Clock; + updateTeamQuota?: typeof updateSendLitTeamQuota; + config?: SendLitConfig; +}; + +/** + * Sets the school's SendLit team quota from its plan (S3, S20). Safe to run + * any number of times. Skips a school whose SendLit team isn't ready yet: + * provisioning sets it to 0 and queues this sync. Throws when billing can't + * be read, so the job retries instead of guessing a number. + */ +export async function syncMarketingQuota( + deps: MarketingQuotaDeps, + schoolId: string, +): Promise<"updated" | "skipped"> { + const config = deps.config ?? sendLitConfig(); + if (!config.server || !config.provisioningApiKey) return "skipped"; + const [integration] = await deps.db + .select({ remoteTeamId: schema.schoolIntegrations.remoteTeamId }) + .from(schema.schoolIntegrations) + .where( + and( + eq(schema.schoolIntegrations.schoolId, schoolId), + eq(schema.schoolIntegrations.provider, "sendlit"), + eq(schema.schoolIntegrations.status, "ready"), + isNotNull(schema.schoolIntegrations.remoteTeamId), + ), + ) + .limit(1); + if (!integration?.remoteTeamId) return "skipped"; + const quota = marketingQuotaFor(await schoolPlan(deps, schoolId)); + if (quota === undefined) throw new Error("billing_unavailable"); + await (deps.updateTeamQuota ?? updateSendLitTeamQuota)( + integration.remoteTeamId, + { monthlyLimit: quota }, + { config }, + ); + // The quota may have gone up (an upgrade), so drip can try again now. + await deps.db + .update(schema.schoolIntegrations) + .set({ marketingPausedUntil: null }) + .where( + and( + eq(schema.schoolIntegrations.schoolId, schoolId), + eq(schema.schoolIntegrations.provider, "sendlit"), + ), + ); + return "updated"; +} + +/** Schools whose SendLit team is ready, for the daily reconciliation (S3). */ +export async function schoolsWithSendLitTeams(db: AppDb): Promise { + const rows = await db + .select({ schoolId: schema.schoolIntegrations.schoolId }) + .from(schema.schoolIntegrations) + .where( + and( + eq(schema.schoolIntegrations.provider, "sendlit"), + eq(schema.schoolIntegrations.status, "ready"), + isNotNull(schema.schoolIntegrations.remoteTeamId), + ), + ); + return rows.map((row) => row.schoolId); +} diff --git a/apps/api/src/sendlit-client.ts b/apps/api/src/sendlit-client.ts index 8aaf8fbdb..09c70cc89 100644 --- a/apps/api/src/sendlit-client.ts +++ b/apps/api/src/sendlit-client.ts @@ -22,6 +22,19 @@ export class SendLitApiError extends Error { } } +/** + * Whether SendLit refused a send because the team's (or the organization's) + * sending quota is used up. SendLit answers 429 with + * `organization_team_quota_exhausted` or `organization_quota_exhausted`. + */ +export function isSendLitQuotaExhausted(error: unknown): boolean { + return ( + error instanceof SendLitApiError && + error.status === 429 && + /quota_exhausted/.test(`${error.message} ${error.detail ?? ""}`) + ); +} + export function sendLitConfig( env: Record = process.env, ): SendLitConfig { @@ -48,9 +61,14 @@ export type SendLitProvisionTeamInput = { source: "organization" | "custom"; espId?: string; }; + /** + * SendLit's field names (`schemas/teams.ts` in SendLit's API contract). On + * provisioning SendLit only accepts positive numbers; use + * `updateSendLitTeamQuota` to set 0. `null` means unlimited. + */ quota?: { - monthly?: number; - daily?: number; + monthlyLimit?: number | null; + dailyLimit?: number | null; }; }; @@ -384,6 +402,35 @@ export async function provisionSendLitTeam( ); } +/** + * Sets a provisioned team's sending quota (`PATCH /provisioning/teams/:id`). + * Unlike provisioning, this accepts 0, which stops all sending from the team. + */ +export async function updateSendLitTeamQuota( + teamId: string, + quota: { monthlyLimit: number | null; dailyLimit?: number | null }, + options: { config?: SendLitConfig; fetcher?: FetchLike } = {}, +): Promise { + const config = options.config ?? sendLitConfig(); + if (!config.provisioningApiKey) { + throw new SendLitApiError( + "SENDLIT_ORGANIZATION_API_KEY is not configured", + undefined, + false, + ); + } + await requestJson( + config, + `/provisioning/teams/${encodeURIComponent(teamId)}`, + { + method: "PATCH", + provisioningApiKey: config.provisioningApiKey, + body: { quota }, + }, + options.fetcher, + ); +} + export async function rotateSendLitTeamKey( teamId: string, input: { name: string }, diff --git a/apps/api/src/sso-jobs.ts b/apps/api/src/sso-jobs.ts index cb3508f82..bae828bc4 100644 --- a/apps/api/src/sso-jobs.ts +++ b/apps/api/src/sso-jobs.ts @@ -5,6 +5,7 @@ import type { Logger } from "pino"; import * as schema from "./db/schema/index.js"; import { processNextPlatformJob } from "./integration-provisioning.js"; import { cleanupSsoRequests } from "./learner-sso-sign-in.js"; +import { schoolsWithSendLitTeams, syncMarketingQuota } from "./marketing-quota.js"; import { schoolsWithSsoConnections, syncPlanFeatures } from "./plan-features.js"; import { SSO_DOMAIN_LABEL, ssoDnsResolver } from "./sso-admin.js"; import { notifySsoOwner, recordSsoEvent } from "./sso-events.js"; @@ -17,12 +18,16 @@ type Billing = Pick; export const DOMAIN_FAILURES_BEFORE_UNVERIFIED = 3; const DOMAIN_RECHECK_INTERVAL_MS = 6.5 * 24 * 60 * 60 * 1000; -/** Drain the platform jobs the billing hook queued (`sync_plan_features`). */ +/** + * Drain the platform jobs the billing hook and SendLit provisioning queued + * (`sync_plan_features`): SSO connections, then the SendLit marketing quota. + */ export async function processPlatformJobs( db: AppDb, billing: Billing, clock: Clock, logger: Logger, + mode: "cloud" | "oss" = "cloud", ): Promise { let processed = 0; while ( @@ -31,6 +36,7 @@ export async function processPlatformJobs( throw new Error(`Unknown platform job type: ${job.type}`); } await syncPlanFeatures(db, billing, job.schoolId, clock); + await syncMarketingQuota({ db, billing, mode, clock }, job.schoolId); }) ) { processed += 1; @@ -38,6 +44,33 @@ export async function processPlatformJobs( return processed; } +/** + * The daily backstop for SendLit quotas (S3): sets every ready team's quota + * from its school's plan, in case a sync job was lost or failed. + */ +export async function syncAllMarketingQuotas( + db: AppDb, + billing: Billing, + clock: Clock, + mode: "cloud" | "oss", + logger?: Logger, +): Promise { + let synced = 0; + for (const schoolId of await schoolsWithSendLitTeams(db)) { + try { + if ((await syncMarketingQuota({ db, billing, mode, clock }, schoolId)) === "updated") { + synced += 1; + } + } catch (error) { + logger?.warn( + { schoolId, error: error instanceof Error ? error.message : String(error) }, + "marketing quota sync failed", + ); + } + } + return synced; +} + /** The daily backstop: re-sync every school that has an active or suspended connection. */ export async function syncAllSsoPlanFeatures( db: AppDb, @@ -155,9 +188,11 @@ export function startSsoJobs(options: { billing: Billing; clock: Clock; logger: Logger; + billingMode?: "cloud" | "oss"; onError?: (task: string, error: unknown) => void; }): () => void { const { db, billing, clock, logger } = options; + const mode = options.billingMode ?? "cloud"; const fail = (task: string, error: unknown) => { logger.error( { sso_task: task, error: error instanceof Error ? error.message : String(error) }, @@ -173,7 +208,7 @@ export function startSsoJobs(options: { if (jobsRunning || stopped) return; jobsRunning = true; try { - await processPlatformJobs(db, billing, clock, logger); + await processPlatformJobs(db, billing, clock, logger, mode); } catch (error) { fail("platform_jobs", error); } finally { @@ -196,6 +231,7 @@ export function startSsoJobs(options: { try { if (clock.now().getTime() - lastPlanSync >= 24 * 60 * 60 * 1000) { await syncAllSsoPlanFeatures(db, billing, clock, logger); + await syncAllMarketingQuotas(db, billing, clock, mode, logger); lastPlanSync = clock.now().getTime(); } } catch (error) { diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 1f385597b..aa77fcd2d 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -84,6 +84,7 @@ try { const stopSsoJobs = startSsoJobs({ db: runtime.db, billing: runtime.billing.billing, + billingMode: runtime.billingMode, clock: runtime.clock, logger: observability.logger, onError: (task, error) => From 00f0545ceb6bd837700dbf67e1cf2075a9c6cbb0 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 14 Nov 2026 22:52:33 +0530 Subject: [PATCH 05/27] feat(api): send learner mail through platform streams with caps and abuse limits --- .../components/onboarding/wizard/flow.test.ts | 8 + .../components/onboarding/wizard/flow.ts | 12 + apps/api/src/commerce-receipts.test.ts | 24 +- apps/api/src/dispatch.ts | 21 +- apps/api/src/integration-provisioning.ts | 8 + apps/api/src/learner-sign-in.ts | 11 +- apps/api/src/mail-abuse-limits.ts | 94 +++++++ apps/api/src/mail-routing.test.ts | 251 ++++++++++++++++++ apps/api/src/membership-events.ts | 19 +- apps/api/src/school-emails.test.ts | 57 ++-- apps/api/src/school-emails.ts | 117 +++++++- apps/api/src/sso-events.ts | 1 + apps/api/src/system-mail.ts | 35 ++- packages/api-contract/package.json | 4 + packages/api-contract/src/index.ts | 4 +- packages/api-contract/src/school-name.test.ts | 23 ++ packages/api-contract/src/school-name.ts | 18 ++ 17 files changed, 637 insertions(+), 70 deletions(-) create mode 100644 apps/api/src/mail-abuse-limits.ts create mode 100644 apps/api/src/mail-routing.test.ts create mode 100644 packages/api-contract/src/school-name.test.ts create mode 100644 packages/api-contract/src/school-name.ts diff --git a/apps/admin/components/onboarding/wizard/flow.test.ts b/apps/admin/components/onboarding/wizard/flow.test.ts index 3036951a3..99a51c0cf 100644 --- a/apps/admin/components/onboarding/wizard/flow.test.ts +++ b/apps/admin/components/onboarding/wizard/flow.test.ts @@ -8,6 +8,7 @@ import { deriveSiteDomain, detectLocaleDefaults, emptyWizardData, + ERROR_COPY, formatMailingAddress, hasNewConnection, hasUploadsInFlight, @@ -163,6 +164,13 @@ describe("validation", () => { validateSchool({ school: "A", subdomain: "abc" }, "taken").subdomain, ).toBeDefined(); expect(validateSchool({ school: "A", subdomain: "abc" }, "ok")).toEqual({}); + expect( + validateSchool({ school: "paypal.com support", subdomain: "abc" }, "ok").school, + ).toBe(ERROR_COPY.schoolLink); + expect(validateSchool({ school: "x".repeat(81), subdomain: "abc" }, "ok").school).toBe( + ERROR_COPY.schoolLong, + ); + expect(validateSchool({ school: "St. Mary's Guitar School", subdomain: "abc" }, "ok")).toEqual({}); }); it("validates each offer form", () => { diff --git a/apps/admin/components/onboarding/wizard/flow.ts b/apps/admin/components/onboarding/wizard/flow.ts index 0795c12fd..0392819b6 100644 --- a/apps/admin/components/onboarding/wizard/flow.ts +++ b/apps/admin/components/onboarding/wizard/flow.ts @@ -4,6 +4,7 @@ * unit tested in isolation. */ import type { MediaRef } from "@courselit/api-contract"; +import { schoolNameSchema } from "@courselit/api-contract/school-name"; export type WizardStep = | "about" @@ -412,6 +413,9 @@ export type FieldErrors = Partial< export const ERROR_COPY = { name: "Add your name so learners know who's teaching.", school: "Give your school a name.", + schoolLink: + "School names can't include a web or email address. It appears in every email your learners get.", + schoolLong: "Keep your school's name to 80 characters or fewer.", courseTitle: "Give your course a title.", communityName: "Give your community a name.", question: "Add the question applicants answer.", @@ -435,6 +439,14 @@ export function validateSchool( ): FieldErrors { const errors: FieldErrors = {}; if (blank(data.school)) errors.school = ERROR_COPY.school; + else { + // The same rule the API applies (S14): the name goes into email subjects. + const checked = schoolNameSchema.safeParse(data.school); + if (!checked.success) { + errors.school = + data.school.trim().length > 80 ? ERROR_COPY.schoolLong : ERROR_COPY.schoolLink; + } + } if (!isValidSubdomain(data.subdomain)) errors.subdomain = ERROR_COPY.subdomain; else if (availability === "taken") errors.subdomain = ERROR_COPY.subdomainTaken; return errors; diff --git a/apps/api/src/commerce-receipts.test.ts b/apps/api/src/commerce-receipts.test.ts index a1b818137..75de7f4c9 100644 --- a/apps/api/src/commerce-receipts.test.ts +++ b/apps/api/src/commerce-receipts.test.ts @@ -323,7 +323,7 @@ describe.serial("commerce: receipts", () => { expect(receipts[0]).toMatchObject({ provider: "system", type: "send_system_email", - payload: { subject: "Your receipt for Paid course" }, + payload: { subject: "Your receipt for Paid course | School A", stream: "transactional" }, }); const { html, text } = receipts[0]!.payload; expect(text).toContain("$25.00"); @@ -347,7 +347,7 @@ describe.serial("commerce: receipts", () => { ); } expect(sent.filter((message) => message.to === "buyer@example.com")).toEqual([ - expect.objectContaining({ subject: "Your receipt for Paid course" }), + expect.objectContaining({ subject: "Your receipt for Paid course | School A" }), ]); const pending = await setup.runtime.db .select() @@ -362,12 +362,18 @@ describe.serial("commerce: receipts", () => { await setup.runtime.close(); }); - it("sends receipts through the school's mailing team when it has one", async () => { + it("sends receipts through the platform even when the school has a mailing team", async () => { + // The school's team carries marketing only, so no school quota can ever + // block a receipt (docs/wip/plan-limits.md, section 6). const setup = await setUp({ mailingTeam: true }); const learner = await signUp(setup.runtime, setup.world, "team-buyer@example.com"); await buy(setup, learner, "pay_team"); expect(await receiptsTo(setup.runtime, "team-buyer@example.com")).toEqual([ - expect.objectContaining({ provider: "sendlit", type: "send_school_email" }), + expect.objectContaining({ + provider: "system", + type: "send_system_email", + payload: expect.objectContaining({ stream: "transactional" }), + }), ]); await setup.runtime.close(); }); @@ -380,7 +386,7 @@ describe.serial("commerce: receipts", () => { expect(started.body).toMatchObject({ status: "paid" }); const receipts = await receiptsTo(setup.runtime, "free@example.com"); expect(receipts).toHaveLength(1); - expect(receipts[0]!.payload.subject).toBe("You're in: Paid course"); + expect(receipts[0]!.payload.subject).toBe("You're in: Paid course | School A"); expect(receipts[0]!.payload.text).toContain("Amount: Free"); expect( await saleNotifications(setup.runtime, setup.world.owner.email), @@ -400,8 +406,8 @@ describe.serial("commerce: receipts", () => { expect(second.status).toBe(200); const receipts = await receiptsTo(setup.runtime, "renewal@example.com"); expect(receipts.map((receipt) => receipt.payload.subject)).toEqual([ - "Your receipt for Paid course", - "Payment received for Paid course", + "Your receipt for Paid course | School A", + "Payment received for Paid course | School A", ]); expect( await saleNotifications(setup.runtime, setup.world.owner.email), @@ -563,7 +569,7 @@ describe.serial("commerce: sale alerts", () => { const ownerAlertEmails = (await emailJobs(setup.runtime)).filter( (job) => job.payload.to === setup.world.owner.email && - job.payload.subject === "New sale: Paid course", + job.payload.subject === "New sale: Paid course | School A", ); expect(ownerAlertEmails).toHaveLength(1); expect(ownerAlertEmails[0]!.provider).toBe("system"); @@ -585,7 +591,7 @@ describe.serial("commerce: sale alerts", () => { (await emailJobs(setup.runtime)).filter( (job) => job.payload.to === setup.world.owner.email && - job.payload.subject === "New sale: Paid course", + job.payload.subject === "New sale: Paid course | School A", ), ).toHaveLength(1); // The buyer still gets their receipt. diff --git a/apps/api/src/dispatch.ts b/apps/api/src/dispatch.ts index 7daf6d47f..150baad86 100644 --- a/apps/api/src/dispatch.ts +++ b/apps/api/src/dispatch.ts @@ -350,6 +350,7 @@ import { } from "./school-billing.js"; import { headerSchoolId, resolveSchoolContext } from "./school-context.js"; import { schoolStateAllows, schoolStateError } from "./school-state.js"; +import { assertTriggeredMailAllowed } from "./mail-abuse-limits.js"; import { checkSchoolSubdomain, disconnectAiConnection, @@ -963,6 +964,19 @@ export async function dispatch( } const human = await verifyCaptcha(request.headers); if (!human.ok) return errorResponse(human.error); + // Anyone can type anyone's address here (S14, S28). + const leadIp = request.headers[CLIENT_IP_HEADER]; + const allowed = await assertTriggeredMailAllowed( + deps.db, + { + schoolId: school.id, + kind: "lead_magnet", + email: parsed.data.email, + clientIp: (Array.isArray(leadIp) ? leadIp[0] : leadIp) ?? null, + }, + deps.clock, + ); + if (!allowed.ok) return errorResponse(allowed.error); const result = await signUpForLeadMagnet( deps.db, { @@ -1033,9 +1047,14 @@ export async function dispatch( if (!schoolPublicId) return errorResponse(createPlatformError("tenant_required")); const human = await verifyCaptcha(request.headers); if (!human.ok) return errorResponse(human.error); + const clientIp = request.headers[CLIENT_IP_HEADER]; const result = await requestLearnerEmailCode( deps.db, - { schoolPublicId, email: parsed.data.email }, + { + schoolPublicId, + email: parsed.data.email, + clientIp: (Array.isArray(clientIp) ? clientIp[0] : clientIp) ?? null, + }, deps.clock, ); return result.ok ? { status: 204, body: undefined } : errorResponse(result.error); diff --git a/apps/api/src/integration-provisioning.ts b/apps/api/src/integration-provisioning.ts index c19f927e0..e24b56230 100644 --- a/apps/api/src/integration-provisioning.ts +++ b/apps/api/src/integration-provisioning.ts @@ -890,8 +890,16 @@ export async function processNextSystemEmailJob( subject: payload.subject, text: payload.text ?? "", ...(payload.html ? { html: payload.html } : {}), + stream: payload.stream ?? "transactional", + idempotencyKey: payload.idempotencyKey ?? job.id, }); await finishJob(db, job, clock); + // Every platform send is traceable to its school, so a complaint about + // one school's mail can be found and acted on (S14). + logger.info( + { jobId: job.id, schoolId: job.schoolId, stream: payload.stream ?? "transactional" }, + "platform email sent", + ); } catch (error) { const actionRequired = error instanceof IntegrationActionRequiredError || diff --git a/apps/api/src/learner-sign-in.ts b/apps/api/src/learner-sign-in.ts index c44e7b15c..b298521b5 100644 --- a/apps/api/src/learner-sign-in.ts +++ b/apps/api/src/learner-sign-in.ts @@ -1,3 +1,4 @@ +import { assertTriggeredMailAllowed } from "./mail-abuse-limits.js"; import { randomBytes, randomInt, timingSafeEqual } from "node:crypto"; import { type Clock, @@ -94,7 +95,7 @@ export function learnerCodeEmail( /** Email a six-digit sign-in code that only works for this school. */ export async function requestLearnerEmailCode( db: AppDb, - input: { schoolPublicId: string; email: string }, + input: { schoolPublicId: string; email: string; clientIp?: string | null }, clock: Clock, deliver?: (message: { email: string; otp: string }) => Promise, ): Promise> { @@ -127,6 +128,13 @@ export async function requestLearnerEmailCode( ) { return { ok: false, error: createPlatformError("rate_limited") }; } + // Per school, per IP and per address across every school (S14, S28). + const allowed = await assertTriggeredMailAllowed( + db, + { schoolId: school.id, kind: "sign_in_code", email, clientIp: input.clientIp ?? null }, + clock, + ); + if (!allowed.ok) return allowed; const id = uuidv7(clock); const code = randomInt(0, 1_000_000).toString().padStart(6, "0"); await db.insert(schema.schoolLoginCodes).values({ @@ -153,6 +161,7 @@ export async function requestLearnerEmailCode( ...learnerCodeEmail(school.name, code), }, clock, + "sign_in", ); } return { ok: true, value: null }; diff --git a/apps/api/src/mail-abuse-limits.ts b/apps/api/src/mail-abuse-limits.ts new file mode 100644 index 000000000..8ae84565c --- /dev/null +++ b/apps/api/src/mail-abuse-limits.ts @@ -0,0 +1,94 @@ +import { type Clock, createPlatformError, type PlatformError, uuidv7 } from "@codelitdev/platform"; +import { and, count, eq, gte, type SQL } from "drizzle-orm"; +import * as schema from "./db/schema/index.js"; +import type { AppDb } from "./types.js"; + +/** + * Mail anyone can trigger at an arbitrary address, which goes out under + * CourseLit's reputation (docs/wip/plan-limits.md, S14 and S28). + */ +export type TriggeredMailKind = "sign_in_code" | "lead_magnet"; + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +type Window = { limit: number; windowMs: number }; + +/** The limits, from the plan's rate-limit table. */ +export const TRIGGERED_MAIL_LIMITS: Record< + TriggeredMailKind, + { addressPerSchool: Window; school: Window; ip: Window; addressPlatform: Window } +> = { + sign_in_code: { + addressPerSchool: { limit: 5, windowMs: HOUR }, + school: { limit: 50, windowMs: HOUR }, + ip: { limit: 20, windowMs: HOUR }, + addressPlatform: { limit: 10, windowMs: HOUR }, + }, + lead_magnet: { + addressPerSchool: { limit: 3, windowMs: DAY }, + school: { limit: 100, windowMs: DAY }, + ip: { limit: 20, windowMs: HOUR }, + addressPlatform: { limit: 10, windowMs: DAY }, + }, +}; + +const SCOPE = "triggered_mail"; + +async function recent(db: AppDb, where: SQL | undefined): Promise { + const [row] = await db.select({ count: count() }).from(schema.rateLimitEvents).where(where); + return Number(row?.count ?? 0); +} + +/** + * Checks every limit for one more email of `kind` to `email` and, when all + * pass, records it. The address and IP limits count across every school, so + * one address can't be flooded from many schools. + */ +export async function assertTriggeredMailAllowed( + db: AppDb, + input: { schoolId: string; kind: TriggeredMailKind; email: string; clientIp: string | null }, + clock: Clock, +): Promise<{ ok: true } | { ok: false; error: PlatformError }> { + const limits = TRIGGERED_MAIL_LIMITS[input.kind]; + const now = clock.now().getTime(); + const since = (window: Window) => new Date(now - window.windowMs); + const base = (window: Window) => + and( + eq(schema.rateLimitEvents.scope, SCOPE), + eq(schema.rateLimitEvents.action, input.kind), + gte(schema.rateLimitEvents.createdAt, since(window)), + ); + const email = input.email.trim().toLowerCase(); + const checks: Array<[Window, SQL | undefined]> = [ + [ + limits.addressPerSchool, + and( + base(limits.addressPerSchool), + eq(schema.rateLimitEvents.schoolId, input.schoolId), + eq(schema.rateLimitEvents.subjectId, email), + ), + ], + [limits.school, and(base(limits.school), eq(schema.rateLimitEvents.schoolId, input.schoolId))], + [limits.addressPlatform, and(base(limits.addressPlatform), eq(schema.rateLimitEvents.subjectId, email))], + ]; + if (input.clientIp) { + checks.push([limits.ip, and(base(limits.ip), eq(schema.rateLimitEvents.userId, input.clientIp))]); + } + for (const [window, where] of checks) { + if ((await recent(db, where)) >= window.limit) { + return { ok: false, error: createPlatformError("rate_limited") }; + } + } + await db.insert(schema.rateLimitEvents).values({ + id: uuidv7(clock), + schoolId: input.schoolId, + userId: input.clientIp ?? "unknown", + scope: SCOPE, + action: input.kind, + subjectId: email, + fingerprint: null, + createdAt: clock.now(), + }); + return { ok: true }; +} diff --git a/apps/api/src/mail-routing.test.ts b/apps/api/src/mail-routing.test.ts new file mode 100644 index 000000000..2dcd69e28 --- /dev/null +++ b/apps/api/src/mail-routing.test.ts @@ -0,0 +1,251 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { uuidv7 } from "@codelitdev/platform"; +import { and, eq } from "drizzle-orm"; +import * as schema from "./db/schema/index.js"; +import { assertTriggeredMailAllowed, TRIGGERED_MAIL_LIMITS } from "./mail-abuse-limits.js"; +import { schoolSiteUrl } from "./membership-events.js"; +import { createPgliteRuntime, freezeRuntimeClock } from "./runtime.js"; +import { + PLATFORM_MAIL_DAILY_CAPS, + platformMailTier, + queueSchoolEmail, +} from "./school-emails.js"; +import { createSchool } from "./schools.js"; +import { seedWorld } from "./seed.js"; +import { platformTeamApiKey } from "./system-mail.js"; + +const NOW = new Date("2026-03-01T10:00:00.000Z"); +const previousMode = process.env.BILLING_MODE; + +afterEach(() => { + if (previousMode === undefined) delete process.env.BILLING_MODE; + else process.env.BILLING_MODE = previousMode; +}); + +async function setup() { + delete process.env.BILLING_MODE; + const clock = freezeRuntimeClock(NOW); + const runtime = await createPgliteRuntime({ clock }); + const world = await seedWorld(runtime, clock); + return { runtime, world, clock }; +} + +const email = (n: number) => ({ + to: `learner${n}@example.com`, + subject: "Your receipt for Course", + html: "

Thanks

", + text: "Thanks", + idempotencyKey: `test:${n}`, +}); + +async function jobs(runtime: Awaited>["runtime"], schoolId: string) { + return runtime.db + .select() + .from(schema.integrationOutboxJobs) + .where( + and( + eq(schema.integrationOutboxJobs.schoolId, schoolId), + eq(schema.integrationOutboxJobs.type, "send_system_email"), + ), + ); +} + +describe("queueSchoolEmail", () => { + it("always queues platform mail tagged with its stream, never the school's team", async () => { + const { runtime, world, clock } = await setup(); + expect(await queueSchoolEmail(runtime.db, world.schoolA.id, email(1), clock, "sign_in")).toBe( + "queued", + ); + const [job] = await jobs(runtime, world.schoolA.id); + expect(job).toMatchObject({ provider: "system", type: "send_system_email" }); + expect(job!.payload).toMatchObject({ stream: "sign_in" }); + await runtime.close(); + }); + + it("names the school in the subject, once", async () => { + const { runtime, world, clock } = await setup(); + await queueSchoolEmail(runtime.db, world.schoolA.id, email(1), clock); + await queueSchoolEmail( + runtime.db, + world.schoolA.id, + { ...email(2), subject: "Sign in to School A" }, + clock, + ); + const subjects = (await jobs(runtime, world.schoolA.id)).map( + (job) => (job.payload as { subject: string }).subject, + ); + expect(subjects.sort()).toEqual(["Sign in to School A", "Your receipt for Course | School A"]); + await runtime.close(); + }); + + it("skips notifications over the school's daily cap; they stay in-app (S22)", async () => { + const { runtime, world, clock } = await setup(); + const cap = PLATFORM_MAIL_DAILY_CAPS.notifications.trial; + for (let n = 0; n < cap; n += 1) { + expect( + await queueSchoolEmail(runtime.db, world.schoolB.id, email(n), clock, "notifications"), + ).toBe("queued"); + } + expect( + await queueSchoolEmail(runtime.db, world.schoolB.id, email(cap), clock, "notifications"), + ).toBe("skipped"); + expect(await jobs(runtime, world.schoolB.id)).toHaveLength(cap); + // Sign-in codes are never held back by the notification budget. + expect( + await queueSchoolEmail(runtime.db, world.schoolB.id, email(cap + 1), clock, "sign_in"), + ).toBe("queued"); + await runtime.close(); + }); + + it("holds transactional mail over the cap until the next UTC day", async () => { + const { runtime, world, clock } = await setup(); + const cap = PLATFORM_MAIL_DAILY_CAPS.transactional.trial; + for (let n = 0; n < cap; n += 1) { + await queueSchoolEmail(runtime.db, world.schoolB.id, email(n), clock); + } + expect(await queueSchoolEmail(runtime.db, world.schoolB.id, email(cap), clock)).toBe( + "deferred", + ); + const deferred = (await jobs(runtime, world.schoolB.id)).find( + (job) => (job.payload as { to: string }).to === `learner${cap}@example.com`, + ); + expect(deferred!.nextAttemptAt).toEqual(new Date("2026-03-02T00:00:00.000Z")); + await runtime.close(); + }); + + it("gives a paid school the larger cap of its plan", async () => { + const { runtime, world, clock } = await setup(); + await runtime.db + .update(schema.schools) + .set({ lastPlan: { kind: "paid", plan: "business", status: "active" } }) + .where(eq(schema.schools.id, world.schoolA.id)); + for (let n = 0; n < PLATFORM_MAIL_DAILY_CAPS.notifications.trial + 5; n += 1) { + expect( + await queueSchoolEmail(runtime.db, world.schoolA.id, email(n), clock, "notifications"), + ).toBe("queued"); + } + await runtime.close(); + }); +}); + +describe("platformMailTier", () => { + it("caps anything not known to be paid at the smallest tier", () => { + expect(platformMailTier(null)).toBe("trial"); + expect(platformMailTier({ kind: "unknown" })).toBe("trial"); + expect(platformMailTier({ kind: "none", reason: "expired" })).toBe("trial"); + expect(platformMailTier({ kind: "paid", plan: "pro" })).toBe("pro"); + expect(platformMailTier({ kind: "oss" })).toBeNull(); + }); + + it("is uncapped on a self-hosted deployment", () => { + process.env.BILLING_MODE = "oss"; + expect(platformMailTier(null)).toBeNull(); + }); +}); + +describe("platformTeamApiKey", () => { + it("uses the stream's own team, else the single platform team", () => { + const env = { + SENDLIT_PLATFORM_TEAM_API_KEY: "shared", + SENDLIT_PLATFORM_SIGN_IN_TEAM_API_KEY: "sign-in", + }; + expect(platformTeamApiKey(env, "sign_in")).toBe("sign-in"); + expect(platformTeamApiKey(env, "notifications")).toBe("shared"); + expect(platformTeamApiKey({}, "staff")).toBeUndefined(); + }); +}); + +describe("schoolSiteUrl", () => { + it("links a trial's mail to its platform subdomain, and a paid school's to its custom domain (S31)", async () => { + const { runtime, world, clock } = await setup(); + await runtime.db.insert(schema.schoolHosts).values({ + id: uuidv7(clock), + schoolId: world.schoolA.id, + hostname: "learn.example.org", + kind: "custom", + verificationStatus: "verified", + isPrimary: true, + createdAt: clock.now(), + updatedAt: clock.now(), + } as typeof schema.schoolHosts.$inferInsert); + expect(await schoolSiteUrl(runtime.db, world.schoolA.id)).not.toContain("learn.example.org"); + await runtime.db + .update(schema.schools) + .set({ lastPlan: { kind: "paid", plan: "pro", status: "active" } }) + .where(eq(schema.schools.id, world.schoolA.id)); + expect(await schoolSiteUrl(runtime.db, world.schoolA.id)).toContain("learn.example.org"); + await runtime.close(); + }); +}); + +describe("assertTriggeredMailAllowed", () => { + const send = ( + runtime: Awaited>["runtime"], + clock: ReturnType, + schoolId: string, + to: string, + clientIp: string | null, + ) => + assertTriggeredMailAllowed( + runtime.db, + { schoolId, kind: "lead_magnet", email: to, clientIp }, + clock, + ); + + it("limits one address per school", async () => { + const { runtime, world, clock } = await setup(); + const { limit } = TRIGGERED_MAIL_LIMITS.lead_magnet.addressPerSchool; + for (let n = 0; n < limit; n += 1) { + expect((await send(runtime, clock, world.schoolA.id, "target@example.com", `10.0.0.${n}`)).ok).toBe(true); + } + expect((await send(runtime, clock, world.schoolA.id, "Target@Example.com", "10.0.1.1")).ok).toBe(false); + await runtime.close(); + }); + + it("limits one address across every school, so it can't be flooded from many (S28)", async () => { + const { runtime, world, clock } = await setup(); + const limits = TRIGGERED_MAIL_LIMITS.lead_magnet; + // Enough schools that no one school reaches its own per-address limit. + const schoolIds = [world.schoolA.id, world.schoolB.id]; + for (let n = 0; schoolIds.length * limits.addressPerSchool.limit <= limits.addressPlatform.limit; n += 1) { + const created = await createSchool( + runtime.db, + { + name: `Extra School ${n}`, + subdomain: `extra-school-${n}`, + locale: "en", + currency: "USD", + principalId: world.owner.id, + requestId: `extra-${n}`, + }, + clock, + ); + if (!created.ok) throw new Error("school_creation_failed"); + const [row] = await runtime.db + .select({ id: schema.schools.id }) + .from(schema.schools) + .where(eq(schema.schools.publicId, created.value.id)); + schoolIds.push(row!.id); + } + for (let n = 0; n < limits.addressPlatform.limit; n += 1) { + const result = await send(runtime, clock, schoolIds[n % schoolIds.length]!, "victim@example.com", `10.1.0.${n}`); + expect(result.ok).toBe(true); + } + const next = schoolIds[limits.addressPlatform.limit % schoolIds.length]!; + expect((await send(runtime, clock, next, "victim@example.com", "10.2.0.1")).ok).toBe(false); + // Another address from the same school is still fine. + expect((await send(runtime, clock, next, "someone-else@example.com", "10.2.0.2")).ok).toBe(true); + await runtime.close(); + }); + + it("limits one IP across addresses", async () => { + const { runtime, world, clock } = await setup(); + const { limit } = TRIGGERED_MAIL_LIMITS.lead_magnet.ip; + for (let n = 0; n < limit; n += 1) { + await send(runtime, clock, n % 2 ? world.schoolA.id : world.schoolB.id, `a${n}@example.com`, "203.0.113.9"); + } + expect((await send(runtime, clock, world.schoolA.id, "fresh@example.com", "203.0.113.9")).ok).toBe(false); + expect((await send(runtime, clock, world.schoolA.id, "fresh@example.com", "203.0.113.10")).ok).toBe(true); + await runtime.close(); + }); +}); diff --git a/apps/api/src/membership-events.ts b/apps/api/src/membership-events.ts index 9654ddaa1..b65e0443d 100644 --- a/apps/api/src/membership-events.ts +++ b/apps/api/src/membership-events.ts @@ -16,13 +16,26 @@ export type TrackSendLitEventPayload = { occurredAt: string; }; -/** The school's storefront origin: its verified primary custom host, or its - * platform subdomain. */ +/** + * The school's storefront origin, as used in email: its verified primary + * custom host, or its platform subdomain. Mail goes out under CourseLit's + * reputation, so it links to a custom domain only once the school is known to + * be on a paid plan (or self-hosted). A trial's mail links to its platform + * subdomain (docs/wip/plan-limits.md, S31). + */ export async function schoolSiteUrl( db: AppDb, schoolId: string, ): Promise { - const [customHost] = await db + const [plan] = await db + .select({ lastPlan: schema.schools.lastPlan }) + .from(schema.schools) + .where(eq(schema.schools.id, schoolId)) + .limit(1); + const kind = (plan?.lastPlan as { kind?: string } | null)?.kind; + const customDomainAllowed = + process.env.BILLING_MODE === "oss" || kind === "paid" || kind === "oss"; + const [customHost] = !customDomainAllowed ? [] : await db .select({ hostname: schema.schoolHosts.hostname }) .from(schema.schoolHosts) .where( diff --git a/apps/api/src/school-emails.test.ts b/apps/api/src/school-emails.test.ts index ed1aeffad..69a2ae277 100644 --- a/apps/api/src/school-emails.test.ts +++ b/apps/api/src/school-emails.test.ts @@ -3,10 +3,7 @@ import { uuidv7 } from "@codelitdev/platform"; import { and, eq } from "drizzle-orm"; import * as schema from "./db/schema/index.js"; import { dispatch } from "./dispatch.js"; -import { - processNextIntegrationJob, - type SendLitOperations, -} from "./integration-provisioning.js"; +import { processNextSystemEmailJob } from "./integration-provisioning.js"; import { createLearnerNotification } from "./notifications.js"; import { createLearnerSpacePost, followSpace } from "./spaces.js"; import { createPgliteRuntime, freezeRuntimeClock } from "./runtime.js"; @@ -68,7 +65,7 @@ async function queuedEmails(runtime: Awaited>["runtime" const jobs = await runtime.db .select() .from(schema.integrationOutboxJobs) - .where(eq(schema.integrationOutboxJobs.type, "send_school_email")); + .where(eq(schema.integrationOutboxJobs.type, "send_system_email")); return jobs.map((job) => job.payload as SendSchoolEmailPayload); } @@ -88,7 +85,7 @@ describe("school emails", () => { expect(emails).toHaveLength(1); expect(emails[0]).toMatchObject({ to: "buyer@example.com", - subject: "You have been invited to Starter kit", + subject: "You have been invited to Starter kit | School A", }); expect(emails[0]!.html).toContain(`/dashboard/courses/${productId}`); await runtime.close(); @@ -132,45 +129,23 @@ describe("school emails", () => { expect(emails).toEqual([ expect.objectContaining({ to: "visitor@example.com", - subject: "Thank you for signing up for Starter kit", + subject: "Thank you for signing up for Starter kit | School A", }), ]); expect(emails[0]!.html).toContain("Download now"); - // The worker sends it through the school's mailing team. - await runtime.db - .update(schema.integrationOutboxJobs) - .set({ status: "done" }) - .where(eq(schema.integrationOutboxJobs.type, "sync_sendlit_contact")); - await runtime.db - .update(schema.integrationOutboxJobs) - .set({ status: "done" }) - .where(eq(schema.integrationOutboxJobs.type, "track_sendlit_event")); - const sent: unknown[] = []; - const operations: SendLitOperations = { - provisionTeam: async () => { - throw new Error("unexpected"); - }, - rotateKey: async () => { - throw new Error("unexpected"); - }, - sendEmail: async (key, input) => { - sent.push({ key, ...input }); - return { txeId: "txe_1" }; - }, - }; - await processNextIntegrationJob( - runtime.db, - clock, - runtime.logger, - operations, - sendLitConfig, - ); + // The platform sender delivers it, never the school's mailing team, so no + // school quota can block it (docs/wip/plan-limits.md, section 6). + const sent: Array> = []; + await processNextSystemEmailJob(runtime.db, clock, runtime.logger, async (message) => { + sent.push(message as Record); + return "console"; + }); expect(sent).toEqual([ expect.objectContaining({ - key: "sl_team_key", to: "visitor@example.com", - subject: "Thank you for signing up for Starter kit", + subject: "Thank you for signing up for Starter kit | School A", + stream: "transactional", }), ]); await runtime.close(); @@ -194,7 +169,7 @@ describe("school emails", () => { await createLearnerNotification(runtime.db, target, notification, clock); let emails = await queuedEmails(runtime); expect(emails).toEqual([ - expect.objectContaining({ to: account!.email, subject: "New reply" }), + expect.objectContaining({ to: account!.email, subject: "New reply | School A" }), ]); expect(emails[0]!.html).toContain("/communities/com_1/posts/pst_1"); expect(emails[0]!.html).toContain("/dashboard/account/notifications"); @@ -319,7 +294,7 @@ describe("space post notifications", () => { ).toBe(true); await runtime.db .delete(schema.integrationOutboxJobs) - .where(eq(schema.integrationOutboxJobs.type, "send_school_email")); + .where(eq(schema.integrationOutboxJobs.type, "send_system_email")); const posted = await createLearnerSpacePost( runtime.db, @@ -333,7 +308,7 @@ describe("space post notifications", () => { const recipients = emails.map((email) => email.to).sort(); expect(recipients).toContain("follower@example.com"); expect(recipients).not.toContain("author@example.com"); - expect(emails.every((email) => email.subject === "New space post")).toBe(true); + expect(emails.every((email) => email.subject === "New space post | School A")).toBe(true); // Staff get a link to the post on the school site. const staffEmail = emails.find((email) => email.to !== "follower@example.com"); expect(staffEmail?.html).toContain(`/dashboard/s/${space!.publicId}/`); diff --git a/apps/api/src/school-emails.ts b/apps/api/src/school-emails.ts index b0da0c1bb..ec28d55b5 100644 --- a/apps/api/src/school-emails.ts +++ b/apps/api/src/school-emails.ts @@ -1,8 +1,8 @@ import { type Clock, uuidv7 } from "@codelitdev/platform"; -import { and, eq } from "drizzle-orm"; +import { and, count, eq, gte } from "drizzle-orm"; import * as schema from "./db/schema/index.js"; import { schoolSiteUrl } from "./membership-events.js"; -import { escapeHtml } from "./system-mail.js"; +import { escapeHtml, type MailStream } from "./system-mail.js"; import type { AppDb } from "./types.js"; /** An email a school sends to one person: notifications, access grants and @@ -15,8 +15,42 @@ export type SendSchoolEmailPayload = { html: string; text: string; idempotencyKey: string; + /** Which platform team sends it; set by `queueSchoolEmail`. */ + stream?: MailStream; }; +/** + * Daily caps per school on platform mail (docs/wip/plan-limits.md, S14 and + * S22). Notifications have their own budget, so a busy community can never + * use up the allowance that receipts and access emails need. + */ +export const PLATFORM_MAIL_DAILY_CAPS: Record< + "transactional" | "notifications", + Record<"trial" | "pro" | "business", number> +> = { + transactional: { trial: 200, pro: 2_000, business: 10_000 }, + notifications: { trial: 500, pro: 5_000, business: 25_000 }, +}; + +/** + * The cap tier from the school's last confirmed plan (`schools.last_plan`), so + * queueing an email never waits on billing. Anything not known to be paid + * gets the smallest cap. `null` is uncapped (self-hosted). + */ +export function platformMailTier(lastPlan: unknown): "trial" | "pro" | "business" | null { + if (process.env.BILLING_MODE === "oss") return null; + const plan = lastPlan as { kind?: string; plan?: string } | null; + if (plan?.kind === "oss") return null; + if (plan?.kind === "paid" && (plan.plan === "pro" || plan.plan === "business")) { + return plan.plan; + } + return "trial"; +} + +function startOfUtcDay(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + type EmailBody = { schoolName: string; paragraphs: string[]; @@ -97,30 +131,90 @@ export async function schoolEmailSender( }; } -/** Queues one email for the worker. Email is never dropped: without a usable - * mailing team it goes out through the platform's default sender. */ +/** + * Queues one email to a learner or the school's owner. It always goes through + * the platform's own SendLit teams, never the school's: no school quota can + * block sign-in codes or receipts, and the school's team carries marketing + * only (docs/wip/plan-limits.md, section 6). + * + * The subject names the school, because the sender is the platform until + * SendLit can set a sender name per message. Transactional mail over the + * school's daily cap waits for the next UTC day; notifications over theirs + * are skipped and stay in-app. + */ export async function queueSchoolEmail( db: AppDb, schoolId: string, payload: SendSchoolEmailPayload, clock: Clock, -): Promise { - const { sender } = await schoolEmailSender(db, schoolId); + stream: MailStream = "transactional", +): Promise<"queued" | "deferred" | "skipped"> { const now = clock.now(); + const [school] = await db + .select({ name: schema.schools.name, lastPlan: schema.schools.lastPlan }) + .from(schema.schools) + .where(eq(schema.schools.id, schoolId)) + .limit(1); + if (!school) return "skipped"; + + let nextAttemptAt = now; + if (stream === "transactional" || stream === "notifications") { + const tier = platformMailTier(school.lastPlan); + if (tier) { + const cap = PLATFORM_MAIL_DAILY_CAPS[stream][tier]; + const dayStart = startOfUtcDay(now); + const [row] = await db + .select({ count: count() }) + .from(schema.rateLimitEvents) + .where( + and( + eq(schema.rateLimitEvents.schoolId, schoolId), + eq(schema.rateLimitEvents.scope, "platform_mail"), + eq(schema.rateLimitEvents.action, stream), + gte(schema.rateLimitEvents.createdAt, dayStart), + ), + ); + const used = Number(row?.count ?? 0); + if (used >= cap) { + if (stream === "notifications") return "skipped"; + nextAttemptAt = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); + } + if (used + 1 === Math.ceil(cap * 0.8)) { + console.warn( + JSON.stringify({ msg: "platform mail cap at 80%", schoolId, stream, cap }), + ); + } + await db.insert(schema.rateLimitEvents).values({ + id: uuidv7(clock), + schoolId, + userId: "system", + scope: "platform_mail", + action: stream, + subjectId: schoolId, + fingerprint: null, + createdAt: now, + }); + } + } + + const schoolName = school.name.replace(/[\r\n]+/g, " ").trim(); + const subject = payload.subject.includes(schoolName) + ? payload.subject + : `${payload.subject} | ${schoolName}`; await db.insert(schema.integrationOutboxJobs).values({ id: uuidv7(clock), schoolId, - provider: sender === "school" ? "sendlit" : "system", - type: sender === "school" ? "send_school_email" : "send_system_email", - payload, + provider: "system", + type: "send_system_email", + payload: { ...payload, subject, stream }, status: "pending", attempts: 0, revision: 1, - nextAttemptAt: now, + nextAttemptAt, createdAt: now, updatedAt: now, }); - return sender; + return nextAttemptAt > now ? "deferred" : "queued"; } /** The admin app's origin, where staff notification links point. */ @@ -209,6 +303,7 @@ export async function queueNotificationEmail( idempotencyKey: `notification:${input.notificationId}`, }, clock, + input.audience === "admin" ? "staff" : "notifications", ); } diff --git a/apps/api/src/sso-events.ts b/apps/api/src/sso-events.ts index 194df8fd1..5178f2dec 100644 --- a/apps/api/src/sso-events.ts +++ b/apps/api/src/sso-events.ts @@ -80,5 +80,6 @@ export async function notifySsoOwner( input.schoolId, { to, subject: input.subject, ...rendered, idempotencyKey: input.idempotencyKey }, clock, + "staff", ); } diff --git a/apps/api/src/system-mail.ts b/apps/api/src/system-mail.ts index 9e0d2778a..867fb9e1d 100644 --- a/apps/api/src/system-mail.ts +++ b/apps/api/src/system-mail.ts @@ -5,12 +5,40 @@ import { type FetchLike, } from "./sendlit-client.js"; +/** + * The platform's sending streams (docs/wip/plan-limits.md, section 6). Each + * can have its own SendLit team, so sign-in codes never share a rate limit or + * a suppression list with receipts and notifications (S21). + */ +export type MailStream = "staff" | "sign_in" | "transactional" | "notifications"; + +export const MAIL_STREAM_KEY_ENV: Record = { + staff: "SENDLIT_PLATFORM_STAFF_TEAM_API_KEY", + sign_in: "SENDLIT_PLATFORM_SIGN_IN_TEAM_API_KEY", + transactional: "SENDLIT_PLATFORM_TRANSACTIONAL_TEAM_API_KEY", + notifications: "SENDLIT_PLATFORM_NOTIFICATIONS_TEAM_API_KEY", +}; + +/** The stream's own team key, else the single platform team key. */ +export function platformTeamApiKey( + env: Record, + stream: MailStream, +): string | undefined { + return ( + env[MAIL_STREAM_KEY_ENV[stream]]?.trim() || env.SENDLIT_PLATFORM_TEAM_API_KEY?.trim() || undefined + ); +} + export type SystemMailMessage = { to: string; subject: string; text: string; html?: string; replyTo?: string; + /** Which platform team sends it; staff mail when unset. */ + stream?: MailStream; + /** Lets SendLit drop a retried duplicate. */ + idempotencyKey?: string; }; export type SystemMailDelivery = "sendlit" | "smtp" | "console"; @@ -83,16 +111,17 @@ export async function sendSystemMail( } = {}, ): Promise { const env = options.env ?? process.env; - const platformTeamApiKey = env.SENDLIT_PLATFORM_TEAM_API_KEY?.trim(); + const teamApiKey = platformTeamApiKey(env, message.stream ?? "staff"); - if (platformTeamApiKey) { + if (teamApiKey) { await sendSendLitTransactionalEmail( - platformTeamApiKey, + teamApiKey, { to: message.to, subject: message.subject, html: message.html ?? textAsHtml(message.text), ...(message.replyTo ? { replyTo: message.replyTo } : {}), + ...(message.idempotencyKey ? { idempotencyKey: message.idempotencyKey } : {}), }, { config: sendLitConfig(env), fetcher: options.fetcher }, ); diff --git a/packages/api-contract/package.json b/packages/api-contract/package.json index 8f0772a44..670caa955 100644 --- a/packages/api-contract/package.json +++ b/packages/api-contract/package.json @@ -24,6 +24,10 @@ "types": "./src/safe-redirect.ts", "import": "./src/safe-redirect.ts" }, + "./school-name": { + "types": "./src/school-name.ts", + "import": "./src/school-name.ts" + }, "./money": { "types": "./src/money.ts", "import": "./src/money.ts" diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index ebe9bc0eb..04f8633a4 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -10,6 +10,8 @@ export * from "./learner-avatar.js"; export * from "./page-blocks.js"; export * from "./payment-webhooks.js"; export * from "./safe-redirect.js"; +export * from "./school-name.js"; +import { schoolNameSchema } from "./school-name.js"; const c = initContract(); @@ -2019,7 +2021,7 @@ export const learnerDownloadLinkSchema = z.object({ }); export const createSchoolBodySchema = z.object({ - name: z.string().trim().min(1).max(200), + name: schoolNameSchema, subdomain: z .string() .trim() diff --git a/packages/api-contract/src/school-name.test.ts b/packages/api-contract/src/school-name.test.ts new file mode 100644 index 000000000..5995aece6 --- /dev/null +++ b/packages/api-contract/src/school-name.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "bun:test"; +import { schoolNameSchema } from "./school-name.js"; + +describe("schoolNameSchema", () => { + it.each(["Inkwell Studio", "St. Mary's Guitar School", "Maya's Yoga — Level 2", " Trimmed "])( + "accepts %p", + (name) => { + expect(schoolNameSchema.safeParse(name).success).toBe(true); + }, + ); + + it.each([ + "Visit https://evil.example", + "paypal.com support", + "www.bank-login", + "Contact help@paypal.example", + "PayPal Security\nReset your password", + "x".repeat(81), + "", + ])("refuses %p, which would put a link or address in email subjects", (name) => { + expect(schoolNameSchema.safeParse(name).success).toBe(false); + }); +}); diff --git a/packages/api-contract/src/school-name.ts b/packages/api-contract/src/school-name.ts new file mode 100644 index 000000000..28377cd23 --- /dev/null +++ b/packages/api-contract/src/school-name.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +/** + * A school's name goes into the subject of every email the platform sends + * for it, under CourseLit's reputation (docs/wip/plan-limits.md, S14). So it + * can't carry a link or an address: no URLs, email addresses or domain-like + * words such as `paypal.com`. + */ +export const schoolNameSchema = z + .string() + .trim() + .min(1) + .max(80) + .refine((name) => !/[\r\n]/.test(name), { message: "school_name_invalid" }) + .refine( + (name) => !/(:\/\/|www\.|@|[a-z0-9-]\.[a-z]{2,})/i.test(name), + { message: "school_name_link_not_allowed" }, + ); From 3730ac0cf4aa1d2b5c72a37b82e8c8b0cc674cb3 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 15 Nov 2026 01:10:53 +0530 Subject: [PATCH 06/27] feat: trials, school lifecycle, plan features and the courselit badge Trial and lifecycle (docs/wip/plan-limits.md, step 4): - the account's first school starts a 14-day trial; later schools wait for checkout (pending_checkout) and their site is offline - evaluate_school_lifecycle moves schools between active, read_only and offline, and emails the owner once per notice - the admin explains read-only and offline schools with a banner - staff CLI (suspend, unsuspend, import-school, remove-imported) with platform_audit_events - trial-aware plan features, and SSO enforcement kept unless a lapse is confirmed Plan features (step 5): - "Powered by CourseLit" badge under every public page, in the course viewer and learner dashboard sidebars, and on learner email; hidden only with remove_branding, saved from the admin's branding checkbox - during a trial, injected code runs only for the owner, fetched through the owner's session (S25) - SCORM lessons and package changes need the scorm feature - audit log screen and listSchoolAuditEvents; billing events only for the owner in a browser session (S9, S30) - getSchoolPlanFeatures so every team member's screens can explain limits - LearnerLink now renders a real link; the page-builder Link primitive only styles text, so storefront links didn't navigate --- apps/admin/app/onboarding/setup/page.tsx | 1 + apps/admin/app/settings/page.tsx | 25 +- .../components/layout/admin-breadcrumb.tsx | 4 +- apps/admin/components/layout/admin-shell.tsx | 4 + .../layout/school-state-banner.test.ts | 40 ++ .../components/layout/school-state-banner.tsx | 52 ++ .../admin/components/onboarding/wizard/api.ts | 7 +- .../components/onboarding/wizard/flow.test.ts | 19 +- .../components/onboarding/wizard/flow.ts | 5 +- .../components/onboarding/wizard/wizard.tsx | 30 +- .../components/products/lesson-authoring.tsx | 26 +- .../components/products/product-types.ts | 1 + .../settings/audit-log-settings.test.ts | 26 + .../settings/audit-log-settings.tsx | 157 ++++ .../components/settings/branding-settings.tsx | 49 +- .../components/settings/plan-settings.tsx | 10 +- .../website/code-injection-settings.tsx | 15 +- apps/admin/lib/plan-features.test.ts | 14 + apps/admin/lib/plan-features.ts | 56 ++ apps/api/drizzle/0000_baseline.sql | 12 + apps/api/drizzle/meta/0000_snapshot.json | 86 ++- apps/api/drizzle/meta/_journal.json | 2 +- apps/api/openapi.json | 678 +++++++++++++++++- apps/api/package.json | 1 + apps/api/scripts/staff.ts | 83 +++ apps/api/src/app.test.ts | 2 + apps/api/src/audit-log.ts | 107 +++ apps/api/src/auth/operation-policy.ts | 3 + apps/api/src/db/schema/integrations.ts | 1 + apps/api/src/db/schema/plans.ts | 19 + apps/api/src/db/schema/schools.ts | 2 + apps/api/src/dispatch.ts | 172 ++++- apps/api/src/learner-auth.test.ts | 6 +- apps/api/src/learner-sso-sign-in.ts | 6 +- apps/api/src/login-methods.ts | 6 +- apps/api/src/main.ts | 15 + apps/api/src/marketing-quota.ts | 9 +- .../src/mcp-tools/operation-descriptions.ts | 4 + apps/api/src/onboarding.test.ts | 8 +- apps/api/src/onboarding.ts | 8 +- apps/api/src/operation-permissions.test.ts | 6 +- apps/api/src/plan-features.test.ts | 65 +- apps/api/src/plan-features.ts | 102 ++- apps/api/src/school-billing.ts | 31 +- apps/api/src/school-emails.ts | 56 +- apps/api/src/school-lifecycle.test.ts | 342 +++++++++ apps/api/src/school-lifecycle.ts | 239 ++++++ apps/api/src/school-plan.ts | 14 +- apps/api/src/school-state-dispatch.test.ts | 12 +- apps/api/src/school-state.test.ts | 7 +- apps/api/src/school-state.ts | 8 +- apps/api/src/school-trials.ts | 115 +++ apps/api/src/schools.ts | 95 ++- apps/api/src/scorm-plan.test.ts | 230 ++++++ apps/api/src/scorm-plan.ts | 71 ++ apps/api/src/scorm.test.ts | 5 +- apps/api/src/sign-in-policy.ts | 26 + apps/api/src/sso-admin.ts | 12 +- apps/api/src/sso-jobs.ts | 39 +- apps/api/src/staff.ts | 178 +++++ apps/api/src/storefront-plan.test.ts | 230 ++++++ apps/api/src/storefront-plan.ts | 75 ++ apps/api/src/ui-surface.test.ts | 6 +- apps/storefront/app/api/[...path]/route.ts | 6 + .../[lessonId]/content/[...path]/route.ts | 2 +- .../app/schools/[schoolId]/layout.tsx | 6 +- apps/storefront/components/code-injector.tsx | 50 +- .../storefront/components/courselit-badge.tsx | 51 ++ .../layout/course-viewer-sidebar.tsx | 2 + .../components/layout/learner-sidebar.tsx | 2 + .../components/site-page-renderer.tsx | 2 + .../components/themed-page-builder.test.ts | 15 + .../components/themed-page-builder.tsx | 55 +- apps/storefront/lib/courselit-public.ts | 4 + apps/storefront/lib/school-resolution.ts | 7 +- apps/storefront/proxy.test.ts | 18 + apps/storefront/proxy.ts | 12 + docs/wip/plan-limits.md | 6 +- packages/api-contract/src/index.ts | 85 ++- packages/api-contract/src/mcp-parity.ts | 10 + 80 files changed, 3921 insertions(+), 147 deletions(-) create mode 100644 apps/admin/components/layout/school-state-banner.test.ts create mode 100644 apps/admin/components/layout/school-state-banner.tsx create mode 100644 apps/admin/components/settings/audit-log-settings.test.ts create mode 100644 apps/admin/components/settings/audit-log-settings.tsx create mode 100644 apps/admin/lib/plan-features.test.ts create mode 100644 apps/admin/lib/plan-features.ts create mode 100644 apps/api/scripts/staff.ts create mode 100644 apps/api/src/audit-log.ts create mode 100644 apps/api/src/school-lifecycle.test.ts create mode 100644 apps/api/src/school-lifecycle.ts create mode 100644 apps/api/src/school-trials.ts create mode 100644 apps/api/src/scorm-plan.test.ts create mode 100644 apps/api/src/scorm-plan.ts create mode 100644 apps/api/src/staff.ts create mode 100644 apps/api/src/storefront-plan.test.ts create mode 100644 apps/api/src/storefront-plan.ts create mode 100644 apps/storefront/components/courselit-badge.tsx create mode 100644 apps/storefront/components/themed-page-builder.test.ts diff --git a/apps/admin/app/onboarding/setup/page.tsx b/apps/admin/app/onboarding/setup/page.tsx index 78a40a75e..24d1bf0c6 100644 --- a/apps/admin/app/onboarding/setup/page.tsx +++ b/apps/admin/app/onboarding/setup/page.tsx @@ -11,6 +11,7 @@ function SetupWizardRoute() { isNew={params.get("new") === "1"} checkoutReturn={params.get("checkout") === "return"} resume={params.get("resume") === "1"} + choosePlan={params.get("plan") === "1"} /> ); } diff --git a/apps/admin/app/settings/page.tsx b/apps/admin/app/settings/page.tsx index 9d4f0325c..447dc6b3a 100644 --- a/apps/admin/app/settings/page.tsx +++ b/apps/admin/app/settings/page.tsx @@ -15,6 +15,7 @@ import { Palette, RotateCcw, Save, + ScrollText, Sparkles, Users, } from "lucide-react"; @@ -25,6 +26,7 @@ import { useEffect, useState } from "react"; import { AuthGate } from "@/components/auth-gate"; import { AiAssistantSettings } from "@/components/settings/ai-assistant-settings"; import { ApiKeySettings } from "@/components/settings/api-key-settings"; +import { AuditLogSettings } from "@/components/settings/audit-log-settings"; import { BrandingSettings } from "@/components/settings/branding-settings"; import { EmailDeliveryNotice } from "@/components/mails/email-delivery-notice"; import { LoginProviderSettings } from "@/components/settings/login-provider-settings"; @@ -60,7 +62,7 @@ import { import { Textarea } from "@/components/ui/codelit/textarea"; import currencies from "@/data/currencies.json"; -const GENERAL_SETTINGS_TABS = ["payment", "team", "notifications", "login-providers", "api-keys", "ai-assistants"] as const; +const GENERAL_SETTINGS_TABS = ["payment", "team", "notifications", "login-providers", "api-keys", "ai-assistants", "audit-log"] as const; const MAIL_SETTINGS_TABS = ["delivery"] as const; const WEBSITE_SETTINGS_TABS = ["branding", "code-injection", "domain"] as const; type SettingsMode = "general" | "website" | "mails"; @@ -111,6 +113,7 @@ type SchoolItem = { currency: string; isOwner?: boolean; selected?: boolean; + permissions?: string[]; }; @@ -192,6 +195,7 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) { const [schoolId, setSchoolId] = useState(null); const [isOwner, setIsOwner] = useState(false); + const [canManageSchool, setCanManageSchool] = useState(false); const providerSettings = isPaymentProvider(paymentMethod) ? paymentSettings?.[paymentMethod] @@ -208,6 +212,7 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) { if (!selected) return; setSchoolId(selected.id); setIsOwner(selected.isOwner === true); + setCanManageSchool(selected.permissions?.includes("school:write") === true); setCurrency(selected.currency || "USD"); void fetch("/api/v1/school/payment-settings", { credentials: "include", @@ -444,6 +449,16 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) { ), icon: , }, + // The audit log is for people who manage the school's settings. + ...(canManageSchool + ? [ + { + value: "audit-log", + label: "Audit log", + icon: , + }, + ] + : []), ] } > @@ -765,6 +780,14 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) { ) : null} + + {mode === "general" && selectedTab === "audit-log" && canManageSchool ? ( +
+ +
+ ) : null} +
+ {mode === "general" && selectedTab === "notifications" ? (
diff --git a/apps/admin/components/layout/admin-breadcrumb.tsx b/apps/admin/components/layout/admin-breadcrumb.tsx index e9541943d..dd5d89489 100644 --- a/apps/admin/components/layout/admin-breadcrumb.tsx +++ b/apps/admin/components/layout/admin-breadcrumb.tsx @@ -192,7 +192,8 @@ export function getFallbackBreadcrumbs(pathname: string): BreadcrumbItemType[] { clean === "/settings/team" || clean === "/settings/notifications" || clean === "/settings/api-keys" || - clean === "/settings/ai-assistants" + clean === "/settings/ai-assistants" || + clean === "/settings/audit-log" ) { const tabLabels: Record = { payment: "Payments", @@ -200,6 +201,7 @@ export function getFallbackBreadcrumbs(pathname: string): BreadcrumbItemType[] { notifications: "Notifications", "api-keys": "API keys", "ai-assistants": "AI assistants", + "audit-log": "Audit log", }; const tab = clean.split("/")[2]; return [{ label: "Settings", href: "/settings" }, { label: tabLabels[tab] }]; diff --git a/apps/admin/components/layout/admin-shell.tsx b/apps/admin/components/layout/admin-shell.tsx index 3b541c5f5..c12a8fda7 100644 --- a/apps/admin/components/layout/admin-shell.tsx +++ b/apps/admin/components/layout/admin-shell.tsx @@ -9,6 +9,7 @@ import { BreadcrumbProvider } from "@/components/layout/breadcrumb-context"; import type { CurrentAccount } from "@/components/layout/nav-user"; import type { School } from "@/components/layout/team-switcher"; import { NotificationsBell } from "@/components/notifications/notifications-bell"; +import { SchoolStateBanner } from "@/components/layout/school-state-banner"; import { PermissionDeniedFeedback } from "@/components/permission-denied-feedback"; import { CourseLitLoadingIcon } from "@/components/loading"; import { Separator } from "@/components/ui/separator"; @@ -320,6 +321,9 @@ export function AdminShell({ children }: { children: React.ReactNode }) { aria-hidden={showLoadingOverlay || undefined} className={`h-full overflow-auto p-6 ${showLoadingOverlay ? "invisible" : ""}`} > + school.selected)} + /> {children}
) : null} diff --git a/apps/admin/components/layout/school-state-banner.test.ts b/apps/admin/components/layout/school-state-banner.test.ts new file mode 100644 index 000000000..0d52c7e0e --- /dev/null +++ b/apps/admin/components/layout/school-state-banner.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "bun:test"; +import { schoolStateNotice } from "./school-state-banner"; + +describe("schoolStateNotice", () => { + it("says nothing about a running school", () => { + expect( + schoolStateNotice({ name: "A", status: "active", isOwner: true }), + ).toBeNull(); + expect(schoolStateNotice({ name: "A", status: undefined })).toBeNull(); + }); + + it("sends the owner of a read-only school to pick a plan", () => { + const notice = schoolStateNotice({ name: "A", status: "read_only", isOwner: true }); + expect(notice?.text).toContain("read-only"); + expect(notice?.actionLabel).toBe("Choose a plan"); + }); + + it("tells the rest of the team to ask the owner", () => { + const notice = schoolStateNotice({ + name: "A", + status: "read_only", + isOwner: false, + }); + expect(notice?.text).toContain("its owner"); + expect(notice?.actionLabel).not.toBe("Choose a plan"); + }); + + it("opens the plan step for a school waiting on checkout", () => { + for (const status of ["pending_checkout", "imported"]) { + const notice = schoolStateNotice({ name: "A", status, isOwner: true }); + expect(notice?.actionHref).toBe("/onboarding/setup?plan=1"); + } + }); + + it("points a suspended school at support", () => { + expect( + schoolStateNotice({ name: "A", status: "suspended", isOwner: true })?.text, + ).toContain("support@courselit.app"); + }); +}); diff --git a/apps/admin/components/layout/school-state-banner.tsx b/apps/admin/components/layout/school-state-banner.tsx new file mode 100644 index 000000000..3b3db0d7e --- /dev/null +++ b/apps/admin/components/layout/school-state-banner.tsx @@ -0,0 +1,52 @@ +import type { School } from "@/components/layout/team-switcher"; +import { StatusBanner } from "@/components/status-banner"; + +const PLAN_HREF = "/account/billing"; + +/** + * What the admin says about a school that isn't fully running, and where it + * sends the reader. Only the owner can pick a plan, so the rest of the team is + * told to ask them. Null for a school that needs no notice. + */ +export function schoolStateNotice( + school: Pick, +): { text: string; actionLabel: string; actionHref: string } | null { + const owner = school.isOwner === true; + switch (school.status) { + case "read_only": + return { + text: owner + ? `${school.name} is read-only. Learners keep their access, but nothing can change until you pick a plan.` + : `${school.name} is read-only until its owner picks a plan.`, + actionLabel: owner ? "Choose a plan" : "See the plan", + actionHref: PLAN_HREF, + }; + case "pending_checkout": + case "imported": + return { + text: owner + ? `${school.name} isn't live yet. Pick a plan to open it to learners.` + : `${school.name} isn't live until its owner picks a plan.`, + actionLabel: owner ? "Choose a plan" : "See the plan", + actionHref: owner ? "/onboarding/setup?plan=1" : PLAN_HREF, + }; + case "suspended": + return { + text: `${school.name} is suspended. Write to support@courselit.app to find out why.`, + actionLabel: "See the plan", + actionHref: PLAN_HREF, + }; + default: + return null; + } +} + +export function SchoolStateBanner({ school }: { school: School | undefined }) { + const notice = school ? schoolStateNotice(school) : null; + if (!notice) return null; + return ( + + {notice.text} + + ); +} diff --git a/apps/admin/components/onboarding/wizard/api.ts b/apps/admin/components/onboarding/wizard/api.ts index 921f99620..c9ae87430 100644 --- a/apps/admin/components/onboarding/wizard/api.ts +++ b/apps/admin/components/onboarding/wizard/api.ts @@ -95,7 +95,12 @@ export type SchoolOnboarding = { checklistHidden: boolean; resumePromptHidden: boolean; checklist: Array<{ key: string; done: boolean }>; - billing: { required: boolean; entitled: boolean }; + billing: { + required: boolean; + entitled: boolean; + paid: boolean; + trialEndsAt: string | null; + }; updatedAt: string; }; diff --git a/apps/admin/components/onboarding/wizard/flow.test.ts b/apps/admin/components/onboarding/wizard/flow.test.ts index 99a51c0cf..51f7e4e39 100644 --- a/apps/admin/components/onboarding/wizard/flow.test.ts +++ b/apps/admin/components/onboarding/wizard/flow.test.ts @@ -7,8 +7,8 @@ import { canSubmitSubdomain, deriveSiteDomain, detectLocaleDefaults, - emptyWizardData, ERROR_COPY, + emptyWizardData, formatMailingAddress, hasNewConnection, hasUploadsInFlight, @@ -18,6 +18,7 @@ import { parseDraft, previousStep, resolveResumeStep, + resumeRows, sanitizeSubdomainInput, serializeDraft, slugify, @@ -356,3 +357,19 @@ describe("site domain", () => { ).toBe("courselit.app"); }); }); + +describe("resume summary", () => { + const env = { firstRun: false, hosted: true }; + const plan = (trialEndsAt: string | null) => + resumeRows("offer", env, emptyWizardData(), "Owner", trialEndsAt).find( + (row) => row.key === "plan", + )?.desc; + + it("asks for a plan when the school has none", () => { + expect(plan(null)).toBe("Choose a CourseLit plan to go live"); + }); + + it("names the end of a running trial instead", () => { + expect(plan("2026-11-28T12:00:00.000Z")).toStartWith("Free trial until"); + }); +}); diff --git a/apps/admin/components/onboarding/wizard/flow.ts b/apps/admin/components/onboarding/wizard/flow.ts index 0392819b6..4e51819cb 100644 --- a/apps/admin/components/onboarding/wizard/flow.ts +++ b/apps/admin/components/onboarding/wizard/flow.ts @@ -918,6 +918,7 @@ export function resumeRows( env: Pick, data: WizardData, accountName: string, + trialEndsAt: string | null = null, ): Array<{ key: string; name: string; desc: string; done: boolean; next: boolean }> { const groups = stepperGroups(env).filter((group) => group.key !== "live"); const current = stepperIndex(stepperGroups(env), step); @@ -928,7 +929,9 @@ export function resumeRows( offer: offerTitle(data) || "Not chosen yet", site: "Logo, colour and tagline", email: "Mailing address", - plan: "Choose a CourseLit plan to go live", + plan: trialEndsAt + ? `Free trial until ${new Date(trialEndsAt).toLocaleDateString(undefined, { month: "long", day: "numeric" })}` + : "Choose a CourseLit plan to go live", live: "", }; return groups.map((group, index) => ({ diff --git a/apps/admin/components/onboarding/wizard/wizard.tsx b/apps/admin/components/onboarding/wizard/wizard.tsx index 2df8c1c70..79c02ec28 100644 --- a/apps/admin/components/onboarding/wizard/wizard.tsx +++ b/apps/admin/components/onboarding/wizard/wizard.tsx @@ -22,13 +22,13 @@ import { adoptProduct, applyBranding, applyEmail, + assertAcceptedFile, type BillingCatalog, checkSubdomain, createSchool, fetchBillingCatalog, getOnboarding, isHostedCatalog, - assertAcceptedFile, newUploadingFile, patchOnboarding, publishOffer, @@ -101,6 +101,8 @@ export type WizardProps = { checkoutReturn: boolean; /** `?resume=1`: offer the Resume dialog. */ resume: boolean; + /** `?plan=1`: open on the plan step, e.g. to buy a plan during a trial. */ + choosePlan?: boolean; }; type SchoolInfo = { @@ -131,7 +133,12 @@ function realAccountName( return name && name.toLowerCase() !== prefix ? name : ""; } -export function SetupWizard({ isNew, checkoutReturn, resume }: WizardProps) { +export function SetupWizard({ + isNew, + checkoutReturn, + resume, + choosePlan, +}: WizardProps) { const router = useRouter(); const shell = useAdminShellContext(); const account = shell?.account ?? null; @@ -158,6 +165,7 @@ export function SetupWizard({ isNew, checkoutReturn, resume }: WizardProps) { const [bootError, setBootError] = useState(null); const [catalog, setCatalog] = useState(null); const [hosted, setHosted] = useState(true); + const [trialEndsAt, setTrialEndsAt] = useState(null); const [firstRun, setFirstRun] = useState(false); const [step, setStep] = useState("about"); const [data, setData] = useState(() => emptyWizardData(localeDefaults)); @@ -329,13 +337,16 @@ export function SetupWizard({ isNew, checkoutReturn, resume }: WizardProps) { setData(restored); dataRef.current = restored; setFirstRun(false); - const entitled = onboarding.billing.entitled || !onboarding.billing.required; + // Back from checkout: only a paid plan confirms the payment. A running + // trial is entitled too, but it says nothing about this checkout. + const paid = onboarding.billing.paid || !onboarding.billing.required; + setTrialEndsAt(onboarding.billing.paid ? null : onboarding.billing.trialEndsAt); const target = resolveResumeStep(onboarding.step, { hosted: isHosted, hasOffer: restored.offer !== null, }); if (checkoutReturn) { - if (entitled) { + if (paid) { setStep("plan"); setPaidConfirmed(true); setGoingLive(true); @@ -345,6 +356,9 @@ export function SetupWizard({ isNew, checkoutReturn, resume }: WizardProps) { } setCancelled(true); setStep("plan"); + } else if (choosePlan) { + setPlanMode("form"); + setStep("plan"); } else { setStep(target); setResumeStep(target); @@ -1509,7 +1523,13 @@ export function SetupWizard({ isNew, checkoutReturn, resume }: WizardProps) { { markResumePrompted(); setShowResume(false); diff --git a/apps/admin/components/products/lesson-authoring.tsx b/apps/admin/components/products/lesson-authoring.tsx index ec4905aaf..9559ee8e2 100644 --- a/apps/admin/components/products/lesson-authoring.tsx +++ b/apps/admin/components/products/lesson-authoring.tsx @@ -39,6 +39,7 @@ import { import { Switch } from "@/components/ui/codelit/switch"; import { Textarea } from "@/components/ui/codelit/textarea"; import { truncateText } from "@/lib/truncate"; +import { PLAN_FEATURE_COPY, usePlanFeatures } from "@/lib/plan-features"; import { MediaPicker } from "./media-picker"; import type { Lesson, @@ -81,34 +82,39 @@ function LessonTypeChips({ value: selectedValue, onChange, disabled = false, + locked = [], }: { value: LessonType; onChange: (value: LessonType) => void; disabled?: boolean; + /** Types the school's plan doesn't include. */ + locked?: readonly LessonType[]; }) { return (
{lessonTypes.map(({ value, label, icon: Icon }) => { const selected = value === selectedValue; + const isLocked = locked.includes(value) && !selected; return ( @@ -355,6 +361,7 @@ export function LessonAuthoring({ const router = useRouter(); const editing = Boolean(lessonId); const [school, setSchool] = useState(null); + const scormIncluded = usePlanFeatures(school?.id).has("scorm"); const [product, setProduct] = useState(null); const [title, setTitle] = useState(""); const [type, setType] = useState("text"); @@ -640,7 +647,13 @@ export function LessonAuthoring({ } }} disabled={editing} + locked={scormIncluded ? [] : ["scorm"]} /> + {!editing && !scormIncluded ? ( +

+ {PLAN_FEATURE_COPY.scorm} +

+ ) : null} {editing ? (

The type can't change after a lesson is created. @@ -777,7 +790,12 @@ export function LessonAuthoring({ {type === "scorm" ? (

SCORM package - {editing && school && lessonId ? ( + {editing && school && lessonId && !scormIncluded ? ( +

+ This lesson keeps playing. Replacing its package needs the + Business plan. +

+ ) : editing && school && lessonId ? ( { + it("names who did it", () => { + expect( + auditActorLabel({ kind: "user", name: "Maya", email: "m@example.com" }), + ).toBe("Maya"); + expect(auditActorLabel({ kind: "user", name: null, email: "m@example.com" })).toBe( + "m@example.com", + ); + expect(auditActorLabel({ kind: "user", name: null, email: null })).toBe( + "A former team member", + ); + expect(auditActorLabel({ kind: "system", name: null, email: null })).toBe( + "CourseLit", + ); + }); + + it("turns action keys into words", () => { + expect(auditActionLabel("school.updated")).toBe("School updated"); + expect(auditActionLabel("learner.owner_profile_provisioned")).toBe( + "Learner owner profile provisioned", + ); + }); +}); diff --git a/apps/admin/components/settings/audit-log-settings.tsx b/apps/admin/components/settings/audit-log-settings.tsx new file mode 100644 index 000000000..afa0f555b --- /dev/null +++ b/apps/admin/components/settings/audit-log-settings.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/ui/codelit/button"; +import { PLAN_FEATURE_COPY, usePlanFeatures } from "@/lib/plan-features"; + +export type AuditLogEvent = { + id: string; + action: string; + resourceType: string; + resourceId: string; + actor: { kind: "user" | "system"; name: string | null; email: string | null }; + createdAt: string; +}; + +type AuditLogPage = { items: AuditLogEvent[]; nextCursor: string | null }; + +/** Who did it, as the list shows it. */ +export function auditActorLabel(actor: AuditLogEvent["actor"]): string { + if (actor.kind === "system") return "CourseLit"; + return actor.name || actor.email || "A former team member"; +} + +/** `school.updated` reads as "School updated". */ +export function auditActionLabel(action: string): string { + const words = action.replace(/[._]+/g, " ").trim(); + return words ? words[0]!.toUpperCase() + words.slice(1) : action; +} + +/** + * The school's audit log: who changed what and when, newest first + * (docs/wip/plan-limits.md, section 4). A Business feature; on other plans + * the API refuses it and this explains why. + */ +export function AuditLogSettings({ schoolId }: { schoolId: string | null }) { + const features = usePlanFeatures(schoolId); + const included = features.has("audit_log"); + const [items, setItems] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback( + async (cursor: string | null) => { + if (!schoolId) return; + setLoading(true); + setError(null); + try { + const query = new URLSearchParams({ limit: "50" }); + if (cursor) query.set("cursor", cursor); + const response = await fetch( + `/api/v1/school/audit-events?${query.toString()}`, + { + credentials: "include", + cache: "no-store", + headers: { "x-school-id": schoolId }, + }, + ); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + details?: { reason?: string }; + } | null; + throw new Error( + body?.details?.reason === "plan_required" + ? PLAN_FEATURE_COPY.audit_log + : "Unable to load the audit log.", + ); + } + const page = (await response.json()) as AuditLogPage; + setItems((current) => (cursor ? [...current, ...page.items] : page.items)); + setNextCursor(page.nextCursor); + } catch (caught) { + setError( + caught instanceof Error ? caught.message : "Unable to load the audit log.", + ); + } finally { + setLoading(false); + } + }, + [schoolId], + ); + + useEffect(() => { + if (features.loading || !included) return; + void load(null); + }, [features.loading, included, load]); + + if (!features.loading && !included) { + return ( +
+

{PLAN_FEATURE_COPY.audit_log}

+

+ It records who changed what in your school, and when. +

+
+ ); + } + + return ( +
+

+ Who changed what in your school, newest first. +

+ {error ? ( +

+ {error} +

+ ) : null} + {items.length === 0 && !loading && !error ? ( +

Nothing has been recorded yet.

+ ) : null} + {items.length > 0 ? ( +
+ + + + + + + + + + + {items.map((event) => ( + + + + + + + ))} + +
WhenWhoWhatResource
+ {new Date(event.createdAt).toLocaleString()} + + {auditActorLabel(event.actor)} + {auditActionLabel(event.action)} + {event.resourceType}{" "} + + {event.resourceId} + +
+
+ ) : null} + {nextCursor ? ( + + ) : null} +
+ ); +} diff --git a/apps/admin/components/settings/branding-settings.tsx b/apps/admin/components/settings/branding-settings.tsx index 389cc3925..89f5b4fd1 100644 --- a/apps/admin/components/settings/branding-settings.tsx +++ b/apps/admin/components/settings/branding-settings.tsx @@ -11,6 +11,7 @@ import { Input } from "@/components/ui/codelit/input"; import { Label } from "@/components/ui/codelit/label"; import type { CourseLitMedia } from "@/lib/course-media-uploader"; import { getCourseLitMedia } from "@/lib/course-media-uploader"; +import { PLAN_FEATURE_COPY, usePlanFeatures } from "@/lib/plan-features"; type WebsiteBrandingSettings = { title?: string | null; @@ -36,10 +37,14 @@ export function BrandingSettings() { const [subtitle, setSubtitle] = useState(""); const [savedSubtitle, setSavedSubtitle] = useState(""); const [hideCourseLitBranding, setHideCourseLitBranding] = useState(false); + const [badgeSaving, setBadgeSaving] = useState(false); const [logo, setLogo] = useState(null); const [brandingSaving, setBrandingSaving] = useState(false); const [logoSaving, setLogoSaving] = useState(false); + // Removing the badge comes with any paid plan; a trial keeps it. + const brandingRemovable = usePlanFeatures(schoolId).has("remove_branding"); + const brandingChanged = title.trim() !== savedTitle || subtitle.trim() !== savedSubtitle; @@ -56,6 +61,7 @@ export function BrandingSettings() { setSchoolId(selected.id); setTitle(selected.name); setSavedTitle(selected.name.trim()); + setHideCourseLitBranding(selected.hideCourseLitBranding === true); }) .catch(() => {}); return () => { @@ -63,6 +69,38 @@ export function BrandingSettings() { }; }, []); + async function saveBadge(hide: boolean) { + if (!schoolId || badgeSaving) return; + setBadgeSaving(true); + setHideCourseLitBranding(hide); + try { + const response = await fetch(`/api/v1/schools/${encodeURIComponent(schoolId)}`, { + method: "PATCH", + credentials: "include", + headers: { "content-type": "application/json", "x-school-id": schoolId }, + body: JSON.stringify({ hideCourseLitBranding: hide }), + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + details?: { reason?: string }; + } | null; + throw new Error( + body?.details?.reason === "plan_required" + ? "Removing CourseLit branding comes with any paid plan." + : "Unable to save the setting.", + ); + } + toast.success(hide ? "CourseLit branding removed." : "CourseLit branding shown."); + } catch (caught) { + setHideCourseLitBranding(!hide); + toast.error( + caught instanceof Error ? caught.message : "Unable to save the setting.", + ); + } finally { + setBadgeSaving(false); + } + } + useEffect(() => { if (!school) return; let active = true; @@ -202,12 +240,19 @@ export function BrandingSettings() {

Remove CourseLit branding

- Hide "Powered by CourseLit" on your CourseLit courses and site. + Hide "Powered by CourseLit" on your site, course viewer, learner + dashboard and the emails your learners get.

+ {brandingRemovable ? null : ( +

+ {PLAN_FEATURE_COPY.remove_branding} +

+ )}
setHideCourseLitBranding(value === true)} + disabled={!schoolId || badgeSaving || !brandingRemovable} + onCheckedChange={(value) => void saveBadge(value === true)} aria-label="Remove CourseLit branding" />
diff --git a/apps/admin/components/settings/plan-settings.tsx b/apps/admin/components/settings/plan-settings.tsx index bd6b402dc..4ceec8273 100644 --- a/apps/admin/components/settings/plan-settings.tsx +++ b/apps/admin/components/settings/plan-settings.tsx @@ -40,6 +40,7 @@ type SchoolPlan = { pendingPlanChange: boolean; canCancel: boolean; canResume: boolean; + courseLitTrial: boolean; }; const PLAN_NAMES = { pro: "Pro", business: "Business" } as const; @@ -222,10 +223,17 @@ export function PlanSettings({ schoolId }: { schoolId: string | null }) { {statusBadge(plan)} - {price ? `${price}. ` : ""}Billed through your CourseLit account. + {plan.courseLitTrial + ? "A free trial with every Business feature. Pick Pro or Business before it ends to keep your school running." + : `${price ? `${price}. ` : ""}Billed through your CourseLit account.`}
+ {plan.courseLitTrial ? ( + + ) : null} {plan.canResume ? (