New: ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md (canonical business/platform bridge — course families, delivery modes, BUILT NOW/PHASE 7 BUILT/FUTURE), OFFERING_AND_ REGISTRATION_MODEL.md (lifecycle diagrams, capacity mechanics), STUDENT_GUARDIAN_DATA_ MODEL.md (data minimization, RBAC, legal-review items), SCHOOL_AND_COMMUNITY_DELIVERY_ ARCHITECTURE.md (organization/location mapping, explicitly not multi-tenancy), PHASE7_PRODUCT_ARCHITECTURE.md (phase summary). Updated: WEBSITE_ENROLLMENT_INTEGRATION.md (documented future offering/registration flow, current live contract unchanged), platform/README.md (test list). Co-Authored-By: Claude Sonnet 5 <[email protected]>
6.8 KiB
Offering & Registration Model
Phase 7 Steps 3/6/12-14/18-20 — the schema and lifecycle for offerings, capacity, registrations,
agreements, and curriculum packages. See platform/api/migrations/034_phase7_offerings_ registrations_agreements.sql for the exact DDL and platform/api/src/offerings.js for the
implementation.
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.
A real ordering bug found and fixed during this phase's own testing: the registration route
originally reserved the seat before validating the registration's own fields (e.g. a minor
registration missing a guardianId). Under a full offering, that let a plain validation error
get masked behind a false OFFERING_FULL, and even when the check passed, a request that would
later fail validation had already consumed a real seat. Fixed by creating (and validating) the
registration row first, then reserving the seat, rolling back (deleting) the registration row if
the seat reservation itself then fails — see admin-offerings-routes.js's own 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.