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]>
8.3 KiB
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:
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.