New: PHASE8_ENROLLMENT_OPERATIONS.md (phase summary), WINTER_BREAK_CAMP_OPERATIONS.md (the real operational walkthrough), REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md, OFFERING_ADMIN_GUIDE.md. Updated: ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md (BUILT NOW/PHASE 8 BUILT table), OFFERING_AND_REGISTRATION_MODEL.md (both ordering bugs, launch readiness, registration enforcement), STUDENT_GUARDIAN_DATA_MODEL.md (guardian communications now wired), WEBSITE_ENROLLMENT_INTEGRATION.md (the real, live registration contract with worked examples, replacing the old speculative future-flow section), platform/README.md (test list). Co-Authored-By: Claude Sonnet 5 <[email protected]>
137 lines
8.3 KiB
Markdown
137 lines
8.3 KiB
Markdown
# Offering & Registration Model
|
|
|
|
Phase 7 Steps 3/6/12-14/18-20 (extended in Phase 8) — the schema and lifecycle for offerings,
|
|
capacity, registrations, agreements, and curriculum packages. See `platform/api/migrations/
|
|
034_phase7_offerings_registrations_agreements.sql` and `035_phase8_enrollment_operations.sql`
|
|
for the exact DDL and `platform/api/src/offerings.js` for the implementation.
|
|
|
|
**Updated for Phase 8**: registration is now server-authoritative (draft/cancelled/completed/
|
|
full/window-closed offerings reject registration attempts with distinct error codes — see
|
|
`assertRegistrationOpen()`); offerings can require specific agreements before a registration may
|
|
convert to enrollment (`offering_agreement_requirements`); `accepted_by_type` gained a
|
|
`'registrant'` value for an adult self-accepting before their student_id exists; the website
|
|
registration integration (`POST /api/integrations/registrations`) is live — see `docs/
|
|
WEBSITE_ENROLLMENT_INTEGRATION.md`; and launch readiness is now server-computed
|
|
(`computeOfferingReadiness()`), exposed at `GET /api/admin/offerings/:id/readiness`.
|
|
|
|
## Offering lifecycle (server-defined transitions only)
|
|
|
|
```
|
|
draft ──► published ──► enrollment_open ──┬──► full ──► enrollment_open (reopen)
|
|
│ │ │ │
|
|
│ │ ├──► in_progress ──► completed
|
|
│ │ │
|
|
└──► cancelled ◄──────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
No route accepts an arbitrary client-supplied status — `offerings.transitionOffering()` checks
|
|
a fixed transition table and rejects anything not explicitly allowed with `409
|
|
INVALID_TRANSITION`. `full` is a distinct status from `enrollment_open` (not a computed flag) so
|
|
an admin can see at a glance why enrollment stopped, and can explicitly reopen it (e.g. a seat
|
|
freed up) rather than have it silently reappear.
|
|
|
|
## Capacity — race-safe by construction
|
|
|
|
`offerings.capacity` is nullable (NULL = unlimited, the normal case for `self_paced`).
|
|
`offerings.reserved_seats` is a maintained counter, incremented by exactly one SQL statement:
|
|
|
|
```sql
|
|
UPDATE offerings SET reserved_seats = reserved_seats + 1
|
|
WHERE offering_id = $1 AND reserved_seats < capacity
|
|
RETURNING *;
|
|
```
|
|
|
|
If zero rows come back, the offering is genuinely full — `409 OFFERING_FULL`. This is not a
|
|
`SELECT count(*) ... then INSERT` pattern (the exact anti-pattern the authorization named): the
|
|
row-level lock Postgres takes for the UPDATE's own duration makes two concurrent registration
|
|
attempts for the last seat serialize correctly, with exactly one succeeding, never both.
|
|
|
|
**Two real ordering bugs found and fixed across Phase 7 and Phase 8, in opposite directions —
|
|
the current, final order is: validate fields → reserve the seat → insert the row → notify.**
|
|
|
|
Phase 7's own testing found the route originally reserved the seat *before* validating the
|
|
registration's fields (e.g. a minor missing `guardianId`), letting a plain validation error get
|
|
masked behind a false `OFFERING_FULL` under a full offering. Fixed by validating first.
|
|
|
|
Phase 8 then added a `registration_received` notification fired right after the registration row
|
|
was inserted — which briefly made the ORIGINAL Phase 7 fix's remaining shape (insert, then
|
|
reserve, then roll back the row by deleting it if the seat turned out to be unavailable) newly
|
|
unsafe: the notification row, created after insert but before the seat check, held a real
|
|
foreign key to the registration — so when reservation failed and the code tried to delete the
|
|
just-inserted registration row, that DELETE itself failed on the very foreign key the
|
|
notification had just created. Found via this phase's own regression testing, fixed by moving
|
|
seat reservation to happen BEFORE the insert (rolling the seat back, not a row, if the insert
|
|
itself then somehow fails) and only ever queuing the notification once the registration is fully,
|
|
durably committed — see `offerings.js`'s `createRegistration()` and its own header comment.
|
|
|
|
## Registration lifecycle
|
|
|
|
```
|
|
started ──► submitted ──► reviewed ──┬──► agreements_pending ──┬──► payment_pending ──► confirmed ──► enrollment_activated
|
|
│ │ │ │
|
|
│ └──────────────────► confirmed ◄────────────────┘
|
|
└──► cancelled (from any non-terminal state; releases the reserved seat)
|
|
```
|
|
|
|
Not every offering needs every stage — a simple self-paced offering can go `started` →
|
|
`confirmed` → `enrollment_activated` directly; a stage is a real state a registration CAN pass
|
|
through, not one it MUST. `enrollment_activated` and `cancelled` are terminal.
|
|
|
|
A registration may exist with `student_id = NULL` (a guardian registering a not-yet-created
|
|
student). `convertRegistrationToEnrollment()` is the one place a registration becomes a real
|
|
academic `enrollment` row — it reuses `enrollment.js`'s existing `resolveOrCreateStudent`/
|
|
`createManualEnrollment` rather than duplicating student-matching or enrollment-creation logic,
|
|
and creates `cohort_enrollments` membership too if the offering has a `cohort_id`.
|
|
|
|
## Minor registrations
|
|
|
|
`offering_registrations.is_minor = true` requires a non-null `guardian_id` — enforced by a
|
|
database CHECK constraint, not just application logic, so it holds even against a direct/future
|
|
API caller that skips the usual route. The guardian is a real `guardians` row (Phase 7's new
|
|
model), never a duplicated email on the eventual student record.
|
|
|
|
## Agreements — acceptance records, not legal text
|
|
|
|
`agreement_definitions` stores a title and a plain-language `body_summary` for admin-UI display
|
|
— explicitly **not** attorney-approved legal language (see `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s
|
|
legal-review list). `agreement_acceptances` snapshots the `accepted_version` at acceptance time
|
|
(not just a live foreign key to the current version), so a later edit to an agreement's text
|
|
never retroactively reinterprets what someone already agreed to — the same principle
|
|
`COURSE_CONTENT_MODEL.md` §6b already established for assessment session versioning. An
|
|
acceptance is tied to a student OR a guardian (a CHECK constraint requires at least one), and
|
|
optionally to the registration/enrollment context it was collected for.
|
|
|
|
## Curriculum packages — reference, never clone
|
|
|
|
A `curriculum_package` names a versioned subset of an existing program's courses via
|
|
`curriculum_package_items` (`course_id` + `sequence`) — a pure reference list, storing no
|
|
content of its own. Worked example, matching `docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md`'s
|
|
Option A:
|
|
|
|
```
|
|
curriculum_package_id: winter-linux-systems-v1
|
|
program_id: root-kubernetes-foundations
|
|
items: [ (root-01-computing-linux-foundations, seq 1), (root-02-linux-administration-networking, seq 2) ]
|
|
```
|
|
|
|
An offering referencing this package still points its students at the real, unmodified ROOT 01/
|
|
02 courses — nothing is cloned, no activity is duplicated, and any future content fix to ROOT 01/
|
|
02 is automatically reflected for every offering referencing the package. Historical offerings
|
|
stay stable against a **specific version** of the package (bump `curriculum_packages.version` for
|
|
a semantic change, exactly like every other versioned row in this schema) rather than silently
|
|
picking up a redefinition.
|
|
|
|
## Offering → cohort
|
|
|
|
For cohort/camp/workshop delivery, an offering's `cohort_id` (nullable) points at an existing
|
|
`cohorts` row, unchanged from Phase 2A. `convertRegistrationToEnrollment()` inserts the matching
|
|
`cohort_enrollments` row when present — a student registering into a cohort-backed offering ends
|
|
up on the real classroom roster automatically, with zero duplicated roster state. A `self_paced`
|
|
offering simply has `cohort_id = NULL`; there is no cohort to join.
|
|
|
|
## What was deliberately not built
|
|
|
|
Payment processing, a public-facing registration form, pricing display, and subscription
|
|
billing — all explicitly out of Phase 7's scope. `offerings.metadata` (JSONB) has room for
|
|
future descriptive pricing/marketing fields, but nothing writes payment data into it.
|