4 Commits
Author SHA1 Message Date
ced4568andClaude Sonnet 5 788fb197e0 docs(phase8): document enrollment operations
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]>
2026-08-26 23:59:42 -05:00
ced4568andClaude Sonnet 5 aa63d09fb8 test(phase8): add enrollment operations coverage and Winter Break registration rehearsal
- e2e-phase8.js (16/16): offering CRUD/transitions/readiness, registration open/closed
  enforcement, capacity + concurrent final-seat race through the real registration
  endpoints, the public serializer's safety, website integration idempotency (sequential +
  concurrent), adult/minor paths, required-agreement conversion blocking with version
  snapshotting, offering->cohort membership, guardian recipient resolution, RBAC, and
  confirmation ROOT_ V1/self-paced/classroom flows are unaffected.
- test-phase8-winter-camp-registration.js (12/12): the full commercial-path story with no
  payment code — website submits a real minor registration (real guardian created/matched),
  guardian agreement acceptance, a simulated admin-side payment confirmation, conversion to
  real enrollment/cohort, real account activation, an independent adult path, two students
  genuinely competing for the final seat, and a REAL Lab task completed by the minor
  student with the instructor then seeing the real progress.
- e2e-phase7.js: fixed two now-stale test assumptions the new server-authoritative
  registration-window enforcement broke (an offering left in draft that Phase 7's own test
  never transitioned before registering against it) and widened a file-allowlist check for
  the new, legitimate public-offerings-routes.js.

Regression: e2e-phase6f.js 17/17, e2e-phase5b.js 32/32, e2e-phase5c.js 29/29, all fresh-stack.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 23:59:32 -05:00
ced4568andClaude Sonnet 5 40362ec0c9 feat(admin): add Offerings operations UI
Offerings list + create; offering detail with overview (valid-transitions-only buttons),
server-computed launch readiness, required agreements, and registrations/roster;
registration detail with lifecycle transitions, agreement status, communication history
(masked recipients), and convert-to-enrollment. Reuses the existing Admin.module.css/
adminApi conventions exactly (ProgramsView.jsx's own list+detail pattern) — no new UI
framework or pattern introduced.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 23:59:20 -05:00
ced4568andClaude Sonnet 5 24eee80354 feat(offering): launch readiness, agreement requirements, guardian communications, website registration API
- Registration is now server-authoritative: draft/cancelled/completed/full/window-closed
  offerings reject registration attempts with distinct error codes (assertRegistrationOpen).
- offering_agreement_requirements + conversion-time blocking on missing required
  agreements; accepted_by_type gained 'registrant' for an adult self-accepting before
  their student_id exists.
- computeOfferingReadiness() — a server-derived launch checklist (curriculum, instructor,
  schedule, location, capacity, registration window, agreements, curriculum package).
- Guardian communications wired into the existing Phase 5C notification system: a guardian
  email fallback in resolveRecipient(), a parallel enqueueForRegistration()/
  resolveRegistrationRecipient() pair for pre-account registration-stage notifications
  (registration_received/confirmed/needs_action, agreement_required, camp_operational_update),
  five new RootNode-branded templates. Same table, same worker, same masked admin view —
  no second notification system.
- public-offerings-routes.js: the unauthenticated, explicitly-allowlisted offering read API.
- POST /api/integrations/registrations: the website's idempotent registration submission
  contract, same shared-secret boundary as the existing enrollment integration endpoint.

Two real bugs found and fixed during this phase's own testing before any commit:
(1) the five new notification_type values were wired into the worker before the DB's own
CHECK constraint was widened to accept them — every registration-stage notification was
silently failing until a comprehensive smoke test caught it (036 fixes the constraint;
every previously-silent notification catch now at least logs).
(2) firing a notification right after a registration's insert, before its seat reservation
could still fail and roll the row back, created a real foreign-key violation on rollback —
fixed by reordering createRegistration() to validate -> reserve -> insert -> notify.

See docs/OFFERING_AND_REGISTRATION_MODEL.md and docs/PHASE8_ENROLLMENT_OPERATIONS.md.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 23:59:11 -05:00
26 changed files with 2108 additions and 62 deletions
+69
View File
@@ -0,0 +1,69 @@
# Offering Admin Guide
Phase 8 — how to actually run an offering from `/admin` → Offerings, end to end. Pairs with
`docs/OFFERING_AND_REGISTRATION_MODEL.md` (the underlying data model) and `docs/
WINTER_BREAK_CAMP_OPERATIONS.md` (the specific Winter Break Camp walkthrough).
## Creating an offering
**Offerings → Create offering.** Required: offering ID, URL slug, name, program, delivery mode.
Optional at creation: capacity (blank = unlimited), a location. Dates, the registration window,
organization, cohort, curriculum package, and agreement requirements are all set from the
offering's own detail page after creation — a new offering always starts `draft`, invisible to
the public offerings API and closed to registration, so there's no rush to fill in everything
before saving.
## The detail page
Opening an offering shows:
- **Overview** — status, seats, and the only status-transition buttons the offering can
actually take next (the ones the UI doesn't show are ones the server would reject anyway —
see `docs/OFFERING_AND_REGISTRATION_MODEL.md`'s transition diagram).
- **Launch readiness** — a live, server-computed checklist (curriculum, instructor, dates,
location, capacity, registration window, agreements configured). This is the same check the
server itself could use to gate opening registration — it is never just a frontend hint.
- **Required agreements** — which agreement definitions this offering requires before a
registration can convert to enrollment, with a form to add another.
- **Registrations / roster** — every registration against this offering, with a form to add one
directly (useful for phone/in-person sign-ups an instructor is entering by hand).
## Moving an offering through its lifecycle
`draft → published → enrollment_open → full/in_progress → completed`, or `→ cancelled` from
anywhere non-terminal. Only real, server-allowed transitions ever appear as buttons. `full` is
not automatic — an offering set to `enrollment_open` that happens to be at capacity will reject
new registrations with a clear error, but only moves to the visible `full` status when someone
explicitly sets it (or the very next registration attempt trips it) — check the readiness
panel/registration list to know why registration is currently unavailable.
## Working a registration
Open a registration from the offering's roster to see its own detail: registrant/guardian info,
agreement status (required vs. accepted vs. missing), lifecycle transition buttons, and its
communication history (every notification queued for it, with recipient always masked). Once a
registration is `confirmed` and every required agreement is accepted, **Convert to enrollment**
appears — this is the one action that creates the real student account (or matches an existing
one), the real academic enrollment, and real cohort membership if the offering has a cohort.
## Agreements
**Agreements** (reachable from an offering's "Required agreements" section) lets you define a
new agreement (title + a plain-language summary — never legal text, see `docs/
STUDENT_GUARDIAN_DATA_MODEL.md`) and associate it with any offering that needs it. A registration
cannot convert to enrollment while any required agreement is unaccepted.
## Guardians
A minor's registration always carries a linked guardian — create one from the registration form
when registering a minor by hand, or let the website integration create/match one automatically
from a real submission (see `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`). Guardian records are
admin/owner-only — a plain instructor's cohort access to a student's academic progress is
unchanged, but guardian contact detail is not part of that.
## What the admin UI deliberately does not do
No pricing/payment fields, no legal-document authoring, no bulk email composer — every
communication is a templated, transactional notification triggered by a real lifecycle event
(see `docs/REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md`), never a free-form message an admin
types and blasts out.
+30 -11
View File
@@ -1,9 +1,18 @@
# 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.
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)
@@ -37,13 +46,23 @@ If zero rows come back, the offering is genuinely full — `409 OFFERING_FULL`.
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.
**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
+189
View File
@@ -0,0 +1,189 @@
# Phase 8 — Enrollment Operations, Guardian Communications & Website Integration
Phase 8's mission: turn Phase 7's business/product backend into an OPERABLE enrollment system —
Offering → Registration → Guardian/Adult Contact → Agreements → Communications → Review →
Confirmation → Enrollment → Cohort → Account Activation → Classroom/Lab Access — without direct
database manipulation, ahead of RootNode's first commercial launch (Winter Break Camp). Not a
payment phase — everything is built around where a future payment step will plug in.
## Baseline
Confirmed at start: branch `main`, clean tree, HEAD = `origin/main` = Phase 7's
`df5e39f767a6af3ba50daabde5de97fa1b1a5f3b`.
## What was built
- **Migrations 035/036** — registration-stage notifications (nullable `student_id` +
`registration_id` on `notification_events`), a `'registrant'` agreement-acceptance type,
`offering_agreement_requirements`, a real unique index on `external_reference` (website
registration idempotency), and the notification_type CHECK widening (see "bugs found" below).
- **`offerings.js` extended**: `assertRegistrationOpen()` (server-authoritative registration
windows), `addAgreementRequirement`/`getRegistrationAgreementStatus` (required vs. accepted vs.
missing), a conversion-time block on missing required agreements, `computeOfferingReadiness()`
(server-derived launch checklist), and `createRegistrationFromIntegrationEvent()` (the
website's idempotent submission path, mirroring `enrollment.js`'s established pattern exactly).
- **`notifications.js` extended**: a guardian-email fallback in `resolveRecipient()`, and a
parallel `enqueueForRegistration()`/`resolveRegistrationRecipient()` pair for the pre-account
registration stage — same table, same worker, same masked admin view, no second system.
- **Five new templates** (`registrationReceived`, `registrationConfirmed`,
`registrationNeedsAction`, `agreementRequired`, `campOperationalUpdate`) — HTML + text,
RootNode-branded, no tracking, no marketing content.
- **`public-offerings-routes.js`** (new) — the unauthenticated, explicitly-allowlisted offering
read API the website will consume.
- **`POST /api/integrations/registrations`** (new, in the existing `integration-routes.js`) —
the website's registration submission contract, same shared-secret boundary as
`/api/integrations/enrollments`, fully idempotent.
- **Admin UI**: a real `/admin` → Offerings section (`OfferingsView.jsx`) — list, create, detail
(overview with valid-transitions-only buttons, launch readiness, required agreements,
registrations/roster) and registration detail (lifecycle, agreement status, communication
history, convert-to-enrollment). Reuses the existing `Admin.module.css`/`adminApi` conventions
exactly — no new UI framework or pattern introduced.
## Two real bugs found and fixed during this phase's own testing (before any commit)
1. **`notification_type` CHECK constraint mismatch** — the five new registration-stage
notification types were wired into `notifications.js`/`notification-worker.js` before the
database's own CHECK constraint was widened to accept them. Every `enqueueForRegistration()`
call was silently failing (caught by the deliberate "a notification failure never blocks the
real action" try/catch) until a comprehensive smoke test surfaced it directly. Fixed in
`036_phase8_notification_types.sql`. Also hardened: every previously-silent
`.catch(() => {})` around a notification call now at least logs the error, so a future
regression like this surfaces immediately instead of silently vanishing.
2. **A registration-insert/seat-reservation/notification ordering bug** — adding a
`registration_received` notification right after the registration row's insert (but before
the seat reservation that could still fail and roll the row back) meant a failed reservation's
cleanup DELETE could hit a foreign-key violation against the notification row it had just
created. Found via `e2e-phase7.js`'s own regression rerun. Fixed by reordering
`createRegistration()` to validate → reserve the seat → insert → notify, strictly in that
order, and simplifying the admin route accordingly (see `docs/OFFERING_AND_REGISTRATION_
MODEL.md` for the full before/after).
## Cascading regression fixes (stale test assumptions, not new bugs)
Enforcing real registration-window checks (Step 21) broke `e2e-phase7.js`'s own `offeringB`
(created in Phase 7, never transitioned out of `draft`, registered against successfully only
because no such enforcement existed yet) and a new age-guidance offering created the same way.
Fixed by publishing/opening both before registering, matching the new, correct requirement. A
third `e2e-phase7.js` test's own file-allowlist check needed widening to permit the new, entirely
legitimate `public-offerings-routes.js` reference to age-guidance fields (display-only, never
authorization).
## Testing
`platform/test/e2e-phase8.js` — 16/16 passing (fresh stack): offering CRUD/transitions/
readiness, registration open/closed enforcement, capacity + concurrent final-seat race through
the real registration endpoints, the public serializer's safety (no internal/PII fields), website
integration idempotency (sequential replay + concurrent duplicate), adult/minor registration
paths, student-identity preservation on conversion, required-agreement conversion blocking with
version snapshotting even after the live definition's version later changes, offering→cohort
membership, a real activation token + queued notification on conversion, guardian recipient
resolution, RBAC (offering/location visible to any instructor, guardian/registration detail
admin-only), organization/location `access_notes` stripping, and confirmation ROOT_ V1 self-paced
and classroom flows are unaffected.
## Winter Break commercial-path rehearsal
`infra/scripts/drills/test-phase8-winter-camp-registration.js` — 12/12 passing against a real,
freshly-built stack, with no payment code anywhere: admin configures a real offering (location,
capacity, instructor, required agreement), the website integration submits a REAL minor
registration (a real guardian created/matched, a confirmation notification queued), guardian
acceptance is recorded through the supported application flow, a future payment confirmation is
SIMULATED via a plain admin transition, the registration converts to a real enrollment and real
cohort membership, the account activates with a real one-time token, an adult registers and
converts independently, two students genuinely compete for the final seat (both correctly
rejected once capacity was already exhausted — no overbooking), the instructor runs a real
classroom session and Prepares Labs, and the minor student completes real ROOT 01 orientation
plus a REAL Lab task (`linux-inspect-hardware`) inside a real disposable Lab container, with the
instructor then seeing the real progress.
## Regression
Fresh-stack reruns after every fix: `e2e-phase7.js` 17/17, `e2e-phase6f.js` 17/17, `e2e-phase5b.js`
32/32 (enrollment.js's `resolveOrCreateStudent` gained an optional, default-off `returnToken`
param in Phase 7, re-verified unaffected here), `e2e-phase5c.js` 29/29 (notifications.js was
directly extended this phase — every existing Phase 5C notification type/flow reconfirmed
byte-for-byte unaffected). `e2e-phase5a.js` not independently rerun: its own checks are embedded
in and reconfirmed by both 5B's and 5C's "Phase 5A ... still works unmodified" assertions, and no
code Phase 5A itself owns was touched.
## Frontend build
`npm run build` clean — the new `OfferingsView.jsx` compiles without warning; the only new
warning is the pre-existing "chunk larger than 500kB" advisory, unchanged in cause from every
prior phase.
## Real Lab result
Both rehearsals (Phase 7's from the prior phase, re-verified here, and this phase's own) drive a
real disposable Lab container through a genuine `linux-inspect-hardware` scenario setup/validate
cycle — confirmed passing for real, not simulated, in the Winter Break commercial-path rehearsal.
## Security/privacy findings
The public offerings API was built with an explicit field allowlist from the start (never
`SELECT *`) — `e2e-phase8.js` asserts directly that `access_notes`, `organization_id`,
`location_id`, `created_by`, `reserved_seats`, `capacity`, and `metadata` never appear in a
public response, and that a fully-booked offering reports `"full"` rather than a raw seat count
(avoiding a scraping/timing vector against a public, unauthenticated endpoint). No new security
weakening was introduced; the two bugs found above were both silent-failure/ordering bugs, not
authorization gaps.
## Legal-review requirements — unchanged, restated
See `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s list in full, still entirely unresolved by this
phase's work: the actual legal text of any participation/media/liability agreement,
jurisdiction-specific minor-consent requirements, data-retention/deletion policy, and whether
emergency-contact fields need additional encryption at rest. `agreement_definitions.body_summary`
remains a plain-language placeholder, never legal text.
## Documentation
New: `PHASE8_ENROLLMENT_OPERATIONS.md` (this document), `WINTER_BREAK_CAMP_OPERATIONS.md`,
`REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md`, `OFFERING_ADMIN_GUIDE.md`. Updated:
`ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md` (BUILT NOW/PHASE 8 BUILT/FUTURE table),
`OFFERING_AND_REGISTRATION_MODEL.md` (the two 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),
`platform/README.md` (test list). Reviewed, no change needed: `docs/ENGINEERING_SOP.md`,
`docs/LAB_ARCHITECTURE.md`, `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md`,
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` (the existing provider adapter needed no changes — the
new templates render through the exact same delivery path).
## Known limitations
- The website's own registration UI does not exist yet in this repository (rootnodeacademy.com
is built separately) — the contract is ready and tested, nobody has built the website-side
form against it yet.
- Agreement acceptance is not exposed on the website registration endpoint itself — currently a
separate admin-recorded (or future self-service) step after submission.
- `registration_needs_action` has no automatic trigger — an admin action, deliberately, since
"needs action" covers many distinct real reasons an admin should state explicitly rather than
the platform guessing one.
- No waitlist was built — a `waitlisted` registration status was considered and deliberately
deferred; the existing `cancelled`-releases-a-seat mechanism plus manual re-registration covers
the real, near-term operational need without adding a new status machine branch this phase
didn't have concrete evidence would be used.
- No payment processing — every "payment confirmed" step in both rehearsals is a plain admin
transition, exactly as authorized.
## Remaining blockers before opening Winter Break registration to the public
1. Real dates, venue, capacity, and price are still undecided (not invented by this phase).
2. The agreement's actual legal text requires professional review.
3. No payment processing exists yet.
4. The website's own registration form doesn't exist yet.
None of these are platform readiness gaps — the platform is ready to operate the flow the moment
those four external decisions/assets exist.
## Commits
See `git log` for this phase's exact commit sequence (migrations/backend, admin UI, tests +
rehearsal, docs) — grouped per `docs/ENGINEERING_SOP.md`'s existing convention.
## Recommended Phase 9
Real payment integration (Stripe or equivalent) wired into the registration-confirmation step;
the actual website-side registration form; agreement-acceptance exposed on the public
registration contract once the legal text is finalized; and, informed by real Winter Break Camp
operational experience, whether a waitlist is actually worth building.
@@ -0,0 +1,62 @@
# Registration & Guardian Communications
Phase 8 §8-§11 — how registration-stage notifications extend the existing Phase 5C notification
system. No second email subsystem was built: same `notification_events` table, same durable
worker (`notification-worker.js`), same Google Workspace-backed provider adapter
(`email/provider.js`), same masked-recipient admin API.
## What's new vs. Phase 5C
`notification_events.student_id` is now nullable, and a `registration_id` column was added (a
CHECK constraint guarantees every row still has a real subject) — because a registration-stage
notification (Registration Received, Agreement Required) may need to go out before any student
account exists at all, for a brand-new guardian-submitted registration. `notification_type`'s
CHECK constraint was widened to include five new types. See `033_phase8_...sql` and
`036_phase8_notification_types.sql`.
**A real bug found and fixed during this phase's own testing**: the new notification types were
added to `notifications.js`/`notification-worker.js` before the database CHECK constraint was
widened to accept them — every registration-stage `enqueue` call was silently failing (caught by
the deliberate "a notification failure never blocks the real action" try/catch) until this was
caught and fixed. See `docs/PHASE8_ENROLLMENT_OPERATIONS.md`.
## The five new templates
1. **Registration Received** — queued the moment a registration is created (website or admin).
2. **Registration Confirmed** — queued when a registration moves to `confirmed`.
3. **Registration Needs Action** — available for an admin/future-flow to queue with a reason
when a registration needs something before it can proceed (not automatically triggered by
any single lifecycle transition this phase — a deliberate choice, since "needs action" covers
many different real reasons an admin should state explicitly).
4. **Agreement Required** — queued once per missing required agreement when a registration
enters `agreements_pending`.
5. **Camp/Offering Operational Update** — a deliberately generic operational notice (e.g. "camp
starts Monday, here's what to bring"), NOT a marketing template — no promotional content, no
discount codes, no unrelated announcements. Available for an admin to trigger manually;
nothing queues it automatically.
All five are HTML + plain-text, RootNode-branded, no tracking pixels, no external images, no
passwords or sensitive data — same `templates.js` conventions Phase 5C established.
## Recipient resolution — extended, never overwritten
`resolveRecipient()`'s existing order (student's own email → the enrollment's
`metadata.contact_email`) now has a third, final fallback: a real, communications-enabled
primary guardian's email (`student_guardians`/`guardians`). This is purely additive — a student
who already resolves correctly today is completely unaffected, since the new fallback is only
ever reached when the first two return nothing. `enrollmentContactEmail` (Phase 5C, unchanged)
and this new guardian fallback deliberately coexist: `contact_email` remains a per-enrollment
override for the live website integration's existing contract, while the guardian relationship
is the durable, cross-enrollment identity — prefer the guardian model going forward, but the
transitional field is never broken.
For registration-stage notifications (no student yet, possibly), a separate
`resolveRegistrationRecipient()` tries: the registration's own student (if already converted) →
its linked guardian → the registrant's own supplied email.
## What was deliberately NOT built
No bulk/marketing email composer, no free-form admin-authored message sent to an arbitrary
recipient — every notification is one of the fixed templates above, triggered by a real
lifecycle event or an explicit, scoped admin action (never a raw "compose email" form). No SMS.
No third notification channel beyond email.
@@ -137,7 +137,10 @@ planned this phase.
| Registrations, registration lifecycle | PHASE 7 BUILT |
| Agreement definitions/acceptances (record-only, no legal text) | PHASE 7 BUILT |
| Admin routes for all of the above | PHASE 7 BUILT |
| Admin UI (Offerings/Registrations screens) | **NOT BUILT this phase** — see known limitations |
| Public registration flow, payments, subscriptions | FUTURE |
| Admin UI (Offerings/Registrations screens) | **PHASE 8 BUILT** — `/admin` → Offerings, with launch readiness, roster, agreements, and per-registration detail (communications, agreement status). |
| Guardian communications (5 new notification templates, guardian recipient fallback) | PHASE 8 BUILT |
| Public offering read API + website registration submission contract | PHASE 8 BUILT (`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) |
| Offering agreement requirements + conversion blocking | PHASE 8 BUILT |
| Public registration flow (a real website-side form), payments, subscriptions | FUTURE |
| INIT_/COMPILE_ curriculum | FUTURE |
| School/library SIS integrations, parent portal | FUTURE, explicitly out of scope |
+4 -2
View File
@@ -23,8 +23,10 @@ guardian is a real relationship, not a workaround field.
detailed learning analytics by default. A guardian relationship is a contact/authorization
record, not a monitoring grant.
- **No automatic broad access.** `student_guardians.can_receive_communications` and
`is_primary_contact` govern notification targeting only (a future concern — this phase writes
the rows but does not wire them into `notifications.js`'s resolveRecipient yet), never
`is_primary_contact` govern notification targeting only — wired into `notifications.js`'s
`resolveRecipient()` as of Phase 8 (a communications-enabled primary guardian is the LAST
fallback, tried only after the student's own email and any enrollment-level `contact_email` —
see `docs/REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md`), never
authorization to student data.
## Data minimization
+131 -18
View File
@@ -179,29 +179,142 @@ a first-class concept in this endpoint, talk to the RootNode team before buildin
workaround; the schema now has the real tables for it, they're just not wired into this
endpoint yet.
## Future flow: offerings and registrations (Phase 7, not yet live on this endpoint)
## Offerings and registrations (Phase 8 — live)
RootNode Academy is now (rootnodeacademy.com, live separately from this repository) building
toward representing specific purchasable deliveries — camps, cohorts, self-paced access — as
first-class **offerings**, with a **registration** step before final enrollment (see
`docs/OFFERING_AND_REGISTRATION_MODEL.md`). The eventual intended flow:
As of Phase 8, RootNode represents specific purchasable deliveries — camps, cohorts, self-paced
access — as first-class **offerings**, with a **registration** step before final academic
enrollment (see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). This section covers the real, live
contract; the endpoint documented above (`/api/integrations/enrollments`, direct program
enrollment) is completely unaffected and still works exactly as described.
### 1. List/view offerings (public, no auth)
```
Website: learner selects an offering (e.g. "Winter Break Linux & Systems Camp")
-> Website collects registration info (student/guardian, readiness, agreements)
-> Website checkout/payment (future — not built yet)
-> Verified commerce event -> RootNode confirms the registration
-> RootNode activates the enrollment (reusing this exact endpoint's machinery)
-> Student receives their existing activation email, unchanged
-> Student/camp access begins
GET /api/public/offerings
GET /api/public/offerings/:slug
```
**This is documentation of an intended future shape, not a live contract change.** The endpoint
below is unaffected — it continues to work exactly as documented, program-slug-based, with no
concept of an offering. A future phase will either extend this endpoint with an optional
`offering_slug` field or add a parallel endpoint once the website's actual checkout flow is
designed; that decision has not been made. Idempotency (`event_id`) will remain the mechanism
either way.
No `X-RootNode-Integration-Key` needed — this is a public, read-only endpoint, safe to call
directly from a browser. Only `published`/`enrollment_open`/`full`/`in_progress` offerings ever
appear; `draft` and `cancelled` never do. Example response:
```json
{
"slug": "winter-linux-systems-camp-2026",
"name": "Winter Break Linux & Systems Foundations Camp",
"description": "A hands-on introduction to Linux administration and networking.",
"program_id": "root-kubernetes-foundations",
"delivery_mode": "camp",
"recommended_min_age": 13,
"recommended_max_age": null,
"starts_at": "2026-12-15T14:00:00.000Z",
"ends_at": "2026-12-19T18:00:00.000Z",
"registration_open": true,
"availability": "limited",
"location_name": "Community Room B",
"location_type": "community_center"
}
```
`availability` is `"open"` / `"limited"` / `"full"` — never a raw seat count (avoids inviting
scraping/timing games against a public endpoint). No internal notes, no organization contact
info, no instructor identity, no pricing — the website owns pricing display entirely; this
endpoint doesn't return it.
### 2. Select an offering, then submit a registration (server-to-server)
```
POST /api/integrations/registrations
Header: X-RootNode-Integration-Key: <the same secret /api/integrations/enrollments already uses>
```
Same trust boundary as the existing enrollment endpoint (Step 15) — server-to-server only, never
called from browser JS, the secret never leaves your backend's own configuration.
**Adult example request:**
```json
{
"event_id": "reg_9f8e7d6c5b4a",
"source": "website",
"offering_slug": "winter-linux-systems-camp-2026",
"registrant": { "display_name": "Jamie Rivera", "email": "[email protected]", "is_minor": false }
}
```
**Minor/guardian example request:**
```json
{
"event_id": "reg_1a2b3c4d5e6f",
"source": "website",
"offering_slug": "winter-linux-systems-camp-2026",
"registrant": { "display_name": "Riley Rivera", "is_minor": true },
"guardian": { "display_name": "Jamie Rivera", "email": "[email protected]", "phone": "555-0100" }
}
```
**Success response** (`201` new, `200` idempotent replay):
```json
{ "registration_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "offering_id": "winter-linux-systems-camp-2026", "status": "started", "created": true }
```
**Errors**: `401 unauthorized` (bad/missing key), `400` (`offering_slug`/`event_id`/registrant
fields missing, or a minor with no guardian email), `404 OFFERING_NOT_FOUND`, `409` for
`OFFERING_NOT_OPEN` / `OFFERING_CANCELLED` / `OFFERING_COMPLETED` / `OFFERING_FULL` /
`REGISTRATION_NOT_YET_OPEN` / `REGISTRATION_CLOSED`.
### 3. Duplicate/retry semantics (idempotent, DB-enforced)
Identical to `/api/integrations/enrollments`: send the exact same `event_id` again (a retried
webhook, a timed-out request retried by your own backend) and you get back the **same
registration**, `created: false`, never a duplicate, never a second seat reserved — enforced by
a real unique index on `event_id`, safe under concurrent/parallel retries too. Never generate a
new `event_id` for a retry of the same real-world submission.
### 4. Adult flow
Send `registrant.email`, omit `guardian` entirely, `is_minor: false` (or omit it). The adult IS
both the eventual student and the registration contact — no guardian record is created.
### 5. Minor/guardian flow
Send `registrant.is_minor: true` and a real `guardian` object (`display_name` + `email`
required; `phone` optional). RootNode matches an existing guardian by email first (a returning
family doesn't get a duplicate guardian record per sibling/per camp) or creates a new one. The
guardian's email is never written to the eventual student's own identity record.
### 6. Agreement flow
Not yet exposed on this endpoint — an agreement acceptance is currently recorded by RootNode
staff (or a future self-service step) after the registration is submitted, before it can be
confirmed. If your registration flow collects agreement acceptance at submission time, talk to
the RootNode team about extending this contract before building around a workaround.
### 7. Future payment step
Not built. Today, a registration is manually moved to `confirmed` by RootNode staff once payment
is verified through whatever process exists outside the platform. A future phase's real payment
integration would do this automatically on a verified commerce event.
### 8. Confirmation
Once `confirmed` (manually today, automatically in a future payment-integrated phase) and every
required agreement is accepted, the registration can convert to a real enrollment.
### 9. Enrollment activation
Conversion creates (or matches) the real student account and a real academic enrollment, reusing
the exact same activation-email machinery `/api/integrations/enrollments` already triggers — no
second activation path exists.
### 10. What this endpoint does NOT do
No payment-card data (never sent, never has a field for it). No password (RootNode issues its
own credential/activation flow). No arbitrary registration status (server-defined transitions
only — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). All example data above is fake — do not
reuse these values against a real deployment.
## What the student experiences after you call this (as of Phase 5C)
+74
View File
@@ -0,0 +1,74 @@
# Winter Break Camp Operations
Phase 8 §29 — how to actually run the Winter Break Linux & Systems Foundations Camp
(`docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md` Option A) using the platform built in Phases
7-8, end to end, entirely from `/admin`. No payment step exists yet — this document covers
everything up to and including the point where a real payment/checkout would plug in.
## What already exists vs. what Chase still fills in
**Already representable, real, tested:**
- The curriculum package (ROOT 01-02, referencing the real, unmodified courses).
- The offering itself, in DRAFT, with delivery mode `camp`.
- Registration → guardian/adult → agreement → (simulated) confirmation → enrollment → cohort →
activation → classroom → real Lab work, proven end to end in
`infra/scripts/drills/test-phase8-winter-camp-registration.js` (12/12 passing).
**Still genuinely unknown, deliberately left editable, never invented by this phase:**
- Exact dates, venue, real capacity, and price.
- The actual agreement text (a plain-language placeholder only — see `docs/
STUDENT_GUARDIAN_DATA_MODEL.md`'s legal-review list).
- Whether registration opens before or after a specific marketing push.
## The operational sequence
1. **Create the location** — `/admin` → Offerings → (via the offering create form's location
dropdown) or directly if a location admin surface is added later; today, an admin/owner uses
`POST /api/admin/locations` (documented in `docs/OFFERING_ADMIN_GUIDE.md`) once the real venue
is known.
2. **Create the curriculum package** — `winter-linux-systems-v1`, referencing
`root-01-computing-linux-foundations` and `root-02-linux-administration-networking` in
sequence. This step is a one-time setup — the same package can back every future Winter Break
Camp offering.
3. **Create the cohort** and **the offering** — delivery mode `camp`, referencing the program,
the curriculum package, and (once known) the location and capacity.
4. **Assign the lead instructor** — both at the offering level (`instructor_offerings`, Phase 7)
and the cohort level (`cohort_instructors`, unchanged since Phase 2A) — the offering detail
page's own instructor step, or the two admin routes directly.
5. **Configure the required agreement(s)** — create the agreement definition (title + plain-
language summary) and link it to the offering. **The actual legal text still needs
professional review before this camp is opened to real families** — see below.
6. **Check launch readiness** — the offering detail page's readiness panel should show every
check green before publishing. If instructor/curriculum/agreements are configured but dates/
location/capacity aren't final yet, readiness will honestly show that.
7. **Publish, then open registration** — `draft → published → enrollment_open`. Only once
`enrollment_open` does the offering appear in the public offerings API and accept real
registrations.
8. **Registrations arrive** — via the website integration (once wired — see
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`'s documented future flow) or entered directly by an
admin for a phone/in-person sign-up.
9. **Guardian/adult completes the agreement** — recorded via the registration's own agreement
acceptance action.
10. **Payment step (future, not built)** — this is the one step this phase does not implement.
Today, an admin manually transitions a registration to `confirmed` once payment is verified
by whatever process exists outside the platform; a future phase's real payment integration
would do this automatically on a verified commerce event, the same shape
`/api/integrations/enrollments` already uses for direct program enrollment.
11. **Convert to enrollment** — creates the real student account (or matches an existing one),
the real academic enrollment, and real cohort membership.
12. **Account activation** — the student's (or, for a minor, the guardian's) real one-time
activation token, delivered the same way every other RootNode activation email already is.
13. **Classroom day** — Prepare Labs for the cohort, start the classroom session, students join
and work through real ROOT 01-02 content and Labs.
14. **Offering completion** — `in_progress → completed` once the camp concludes.
## Known blockers before this can actually open to the public
- Real dates, venue, capacity, and price are not yet decided (this document does not invent
them).
- The agreement text requires professional legal review (see `docs/
STUDENT_GUARDIAN_DATA_MODEL.md`).
- No payment processing exists — step 10 above is manual until a future phase builds it.
- The website's own registration UI does not exist yet in this repository (rootnodeacademy.com
is built separately) — the integration contract it needs is ready
(`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`), but nobody has built the website-side form yet.
+3
View File
@@ -10,6 +10,7 @@ import LearningView from './LearningView'
import AssessmentsView from './AssessmentsView'
import ClassroomView from './ClassroomView'
import ProgramsView from './ProgramsView'
import OfferingsView from './OfferingsView'
import NotificationsView from './NotificationsView'
import CurriculumView from './CurriculumView'
import styles from './Admin.module.css'
@@ -25,6 +26,7 @@ const NAV = [
{ key: 'cohorts', label: 'Cohorts' },
{ key: 'students', label: 'Students' },
{ key: 'programs', label: 'Programs' },
{ key: 'offerings', label: 'Offerings' },
{ key: 'curriculum', label: 'Curriculum' },
{ key: 'classroom', label: 'Classroom' },
{ key: 'learning', label: 'Learning' },
@@ -68,6 +70,7 @@ export default function AdminPortal() {
{view === 'cohorts' && <CohortsView role={instructor.role} />}
{view === 'students' && <StudentsView role={instructor.role} />}
{view === 'programs' && <ProgramsView role={instructor.role} />}
{view === 'offerings' && <OfferingsView role={instructor.role} />}
{view === 'curriculum' && <CurriculumView />}
{view === 'classroom' && <ClassroomView />}
{view === 'learning' && <LearningView />}
+412
View File
@@ -0,0 +1,412 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Phase 8 §1-§5 — the Offerings operations console: list/create/detail, launch readiness,
// registrations/roster, and agreement requirements. Authoring (create/transition/agreement
// requirements) is admin/owner only, matching every other authoring surface in this app
// (ProgramsView/AssessmentsView's own precedent) — canAuthor gates writes only, not viewing.
const OFFERING_TRANSITIONS = {
draft: ['published', 'cancelled'],
published: ['enrollment_open', 'cancelled'],
enrollment_open: ['full', 'in_progress', 'cancelled'],
full: ['enrollment_open', 'in_progress', 'cancelled'],
in_progress: ['completed', 'cancelled'],
completed: [],
cancelled: [],
}
function statusPillClass(status) {
if (['enrollment_open', 'in_progress', 'completed'].includes(status)) return styles.pillOk
if (status === 'full') return styles.pillWarn
if (status === 'cancelled') return styles.pillErr
return styles.pillMuted
}
export default function OfferingsView({ role }) {
const [offerings, setOfferings] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const canAuthor = role === 'owner' || role === 'admin'
const reload = useCallback(() => {
adminApi.offerings().then(setOfferings).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
if (selected) return <OfferingDetail offeringId={selected} canAuthor={canAuthor} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
{canAuthor && (
<div className={styles.card}>
<h2>Create offering</h2>
<CreateOfferingForm onCreated={(id) => { reload(); setSelected(id) }} />
</div>
)}
<div className={styles.card}>
<h2>Offerings</h2>
{!offerings ? <p className={styles.muted}>Loading…</p> : offerings.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Name</th><th>Program</th><th>Delivery</th><th>Status</th><th>Seats</th><th /></tr></thead>
<tbody>
{offerings.map((o) => (
<tr key={o.offering_id}>
<td>{o.name}</td>
<td className={styles.muted}>{o.program_id}</td>
<td className={styles.muted}>{o.delivery_mode.replace('_', ' ')}</td>
<td><span className={`${styles.pill} ${statusPillClass(o.status)}`}>{o.status.replace('_', ' ')}</span></td>
<td className={styles.muted}>{o.capacity === null ? 'unlimited' : `${o.reserved_seats}/${o.capacity}`}</td>
<td><button className={styles.linkBtn} onClick={() => setSelected(o.offering_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function CreateOfferingForm({ onCreated }) {
const [programs, setPrograms] = useState([])
const [locations, setLocations] = useState([])
const [form, setForm] = useState({ offering_id: '', slug: '', name: '', program_id: '', delivery_mode: 'camp', capacity: '', location_id: '', recommended_min_age: '', recommended_max_age: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
adminApi.programs().then(setPrograms).catch(() => {})
adminApi.locations().then(setLocations).catch(() => {})
}, [])
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
const offering = await adminApi.createOffering({
offeringId: form.offering_id, slug: form.slug, name: form.name, programId: form.program_id, deliveryMode: form.delivery_mode,
capacity: form.capacity === '' ? undefined : Number(form.capacity),
locationId: form.location_id || undefined,
recommendedMinAge: form.recommended_min_age === '' ? undefined : Number(form.recommended_min_age),
recommendedMaxAge: form.recommended_max_age === '' ? undefined : Number(form.recommended_max_age),
})
setForm({ offering_id: '', slug: '', name: '', program_id: '', delivery_mode: 'camp', capacity: '', location_id: '', recommended_min_age: '', recommended_max_age: '' })
onCreated(offering.offering_id)
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.formRow}>
<div><label className={styles.label}>Offering ID (slug-like)</label>
<input className={styles.input} value={form.offering_id} onChange={(e) => setForm({ ...form, offering_id: e.target.value })} required /></div>
<div><label className={styles.label}>URL slug</label>
<input className={styles.input} value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} required /></div>
<div><label className={styles.label}>Name</label>
<input className={styles.input} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></div>
</div>
<div className={styles.formRow}>
<div><label className={styles.label}>Program</label>
<select className={styles.select} value={form.program_id} onChange={(e) => setForm({ ...form, program_id: e.target.value })} required>
<option value="">Select a program…</option>
{programs.map((p) => <option key={p.program_id} value={p.program_id}>{p.name}</option>)}
</select></div>
<div><label className={styles.label}>Delivery mode</label>
<select className={styles.select} value={form.delivery_mode} onChange={(e) => setForm({ ...form, delivery_mode: e.target.value })}>
{['self_paced', 'cohort', 'hybrid', 'camp', 'workshop', 'school', 'after_school', 'homeschool', 'community', 'one_to_one'].map((m) => <option key={m} value={m}>{m.replace('_', ' ')}</option>)}
</select></div>
<div><label className={styles.label}>Capacity (blank = unlimited)</label>
<input className={styles.input} type="number" min="1" value={form.capacity} onChange={(e) => setForm({ ...form, capacity: e.target.value })} /></div>
<div><label className={styles.label}>Location (optional)</label>
<select className={styles.select} value={form.location_id} onChange={(e) => setForm({ ...form, location_id: e.target.value })}>
<option value="">None / online</option>
{locations.map((l) => <option key={l.location_id} value={l.location_id}>{l.name}</option>)}
</select></div>
</div>
<div className={styles.formRow}>
<div><label className={styles.label}>Recommended min age (guidance only)</label>
<input className={styles.input} type="number" value={form.recommended_min_age} onChange={(e) => setForm({ ...form, recommended_min_age: e.target.value })} /></div>
<div><label className={styles.label}>Recommended max age (guidance only)</label>
<input className={styles.input} type="number" value={form.recommended_max_age} onChange={(e) => setForm({ ...form, recommended_max_age: e.target.value })} /></div>
</div>
<p className={styles.muted}>Age fields are display guidance only — never an access gate. Dates, registration window, and organization can be set later from the offering's own detail page.</p>
<button className={styles.btn} style={{ marginTop: '0.5rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create draft offering'}</button>
</form>
)
}
function OfferingDetail({ offeringId, canAuthor, onBack }) {
const [offering, setOffering] = useState(null)
const [readiness, setReadiness] = useState(null)
const [registrations, setRegistrations] = useState(null)
const [agreementReqs, setAgreementReqs] = useState(null)
const [allAgreements, setAllAgreements] = useState([])
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [selectedReg, setSelectedReg] = useState(null)
const reload = useCallback(() => {
adminApi.offering(offeringId).then(setOffering).catch((e) => setError(e.message))
adminApi.offeringReadiness(offeringId).then(setReadiness).catch(() => {})
adminApi.offeringRegistrations(offeringId).then(setRegistrations).catch(() => {})
adminApi.offeringAgreementRequirements(offeringId).then(setAgreementReqs).catch(() => {})
adminApi.agreements().then(setAllAgreements).catch(() => {})
}, [offeringId])
useEffect(reload, [reload])
const withBusy = (fn) => async () => {
setBusy(true); setError(null)
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
}
if (selectedReg) return <RegistrationDetail registrationId={selectedReg} canAuthor={canAuthor} onBack={() => { setSelectedReg(null); reload() }} />
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to offerings</button>
{!offering ? <p className={styles.muted}>Loading…</p> : (
<>
<h2 style={{ margin: '1rem 0' }}>{offering.name} <span className={`${styles.pill} ${statusPillClass(offering.status)}`}>{offering.status.replace('_', ' ')}</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<h3>Overview</h3>
<p className={styles.muted}>{offering.description || 'No description.'}</p>
<p>Program: {offering.program_id} — Delivery: {offering.delivery_mode.replace('_', ' ')} — Seats: {offering.capacity === null ? 'unlimited' : `${offering.reserved_seats} / ${offering.capacity}`}</p>
{canAuthor && (OFFERING_TRANSITIONS[offering.status] || []).length > 0 && (
<div style={{ marginTop: '0.5rem' }}>
{(OFFERING_TRANSITIONS[offering.status] || []).map((next) => (
<button key={next} className={styles.btnSecondary} style={{ marginRight: '0.5rem' }} disabled={busy}
onClick={withBusy(() => adminApi.transitionOffering(offeringId, next))}>
{next === 'cancelled' ? 'Cancel offering' : `Move to ${next.replace('_', ' ')}`}
</button>
))}
</div>
)}
</div>
<div className={styles.card}>
<h3>Launch readiness</h3>
{!readiness ? <p className={styles.muted}>Loading…</p> : (
<>
<p><span className={`${styles.pill} ${readiness.ready_to_open_registration ? styles.pillOk : styles.pillWarn}`}>
{readiness.ready_to_open_registration ? 'Ready to open registration' : 'Not yet ready'}
</span></p>
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
{readiness.checks.map((c) => (
<li key={c.key} className={styles.muted}>{c.ready ? '✓' : '✗'} {c.label}</li>
))}
</ul>
</>
)}
</div>
<div className={styles.card}>
<h3>Required agreements</h3>
{!agreementReqs ? <p className={styles.muted}>Loading…</p> : agreementReqs.length === 0 ? <p className={styles.muted}>None configured — registrations can convert without any agreement acceptance.</p> : (
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
{agreementReqs.map((a) => <li key={a.agreement_id} className={styles.muted}>{a.title} (v{a.version}) {a.requires_guardian_for_minor && <span className={styles.pill}>guardian required for minors</span>}</li>)}
</ul>
)}
{canAuthor && <AddAgreementRequirementForm offeringId={offeringId} allAgreements={allAgreements} onAdded={reload} />}
</div>
<div className={styles.card}>
<h3>Registrations / roster</h3>
{canAuthor && <CreateRegistrationForm offeringId={offeringId} onCreated={reload} />}
{!registrations ? <p className={styles.muted}>Loading…</p> : registrations.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Registrant</th><th>Type</th><th>Status</th><th /></tr></thead>
<tbody>
{registrations.map((r) => (
<tr key={r.registration_id}>
<td>{r.registrant_display_name}</td>
<td className={styles.muted}>{r.is_minor ? 'minor (guardian)' : 'adult'}</td>
<td><span className={`${styles.pill} ${r.status === 'enrollment_activated' ? styles.pillOk : r.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{r.status.replace('_', ' ')}</span></td>
<td><button className={styles.linkBtn} onClick={() => setSelectedReg(r.registration_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</>
)
}
function CreateRegistrationForm({ offeringId, onCreated }) {
const [form, setForm] = useState({ registrant_display_name: '', registrant_email: '', is_minor: false, guardian_display_name: '', guardian_email: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
let guardianId
if (form.is_minor) {
guardianId = `guardian-${Date.now()}`
await adminApi.createGuardian({ guardianId, displayName: form.guardian_display_name || form.guardian_email, email: form.guardian_email })
}
await adminApi.createRegistration(offeringId, {
registrantDisplayName: form.registrant_display_name, registrantEmail: form.registrant_email || undefined,
isMinor: form.is_minor, guardianId,
})
setForm({ registrant_display_name: '', registrant_email: '', is_minor: false, guardian_display_name: '', guardian_email: '' })
onCreated()
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit} style={{ marginBottom: '1rem', paddingBottom: '1rem', borderBottom: '1px solid var(--border)' }}>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.formRow}>
<div><label className={styles.label}>Registrant name</label>
<input className={styles.input} value={form.registrant_display_name} onChange={(e) => setForm({ ...form, registrant_display_name: e.target.value })} required /></div>
<div><label className={styles.label}>Registrant type</label>
<select className={styles.select} value={form.is_minor ? 'minor' : 'adult'} onChange={(e) => setForm({ ...form, is_minor: e.target.value === 'minor' })}>
<option value="adult">Adult (self-registering)</option>
<option value="minor">Minor (guardian registers)</option>
</select></div>
{!form.is_minor && (
<div><label className={styles.label}>Registrant email</label>
<input className={styles.input} type="email" value={form.registrant_email} onChange={(e) => setForm({ ...form, registrant_email: e.target.value })} /></div>
)}
</div>
{form.is_minor && (
<div className={styles.formRow}>
<div><label className={styles.label}>Guardian name</label>
<input className={styles.input} value={form.guardian_display_name} onChange={(e) => setForm({ ...form, guardian_display_name: e.target.value })} required /></div>
<div><label className={styles.label}>Guardian email</label>
<input className={styles.input} type="email" value={form.guardian_email} onChange={(e) => setForm({ ...form, guardian_email: e.target.value })} required /></div>
</div>
)}
<button className={styles.btn} disabled={busy || !form.registrant_display_name.trim()}>{busy ? 'Registering…' : 'Add registration'}</button>
</form>
)
}
function AddAgreementRequirementForm({ offeringId, allAgreements, onAdded }) {
const [agreementId, setAgreementId] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
if (!agreementId) return
setBusy(true); setError(null)
try { await adminApi.addOfferingAgreementRequirement(offeringId, agreementId); setAgreementId(''); onAdded() }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit} style={{ marginTop: '0.75rem', display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
{error && <div className={styles.errorBox}>{error}</div>}
<div><label className={styles.label}>Require an agreement</label>
<select className={styles.select} value={agreementId} onChange={(e) => setAgreementId(e.target.value)}>
<option value="">Select…</option>
{allAgreements.map((a) => <option key={a.agreement_id} value={a.agreement_id}>{a.title}</option>)}
</select></div>
<button className={styles.btnSecondary} disabled={busy || !agreementId}>Add requirement</button>
</form>
)
}
function RegistrationDetail({ registrationId, canAuthor, onBack }) {
const [reg, setReg] = useState(null)
const [agreementStatus, setAgreementStatus] = useState(null)
const [notifs, setNotifs] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const REG_TRANSITIONS = {
started: ['submitted', 'cancelled'], submitted: ['reviewed', 'agreements_pending', 'payment_pending', 'confirmed', 'cancelled'],
reviewed: ['agreements_pending', 'payment_pending', 'confirmed', 'cancelled'], agreements_pending: ['payment_pending', 'confirmed', 'cancelled'],
payment_pending: ['confirmed', 'cancelled'], confirmed: ['enrollment_activated', 'cancelled'], enrollment_activated: [], cancelled: [],
}
const reload = useCallback(() => {
adminApi.registration(registrationId).then(setReg).catch((e) => setError(e.message))
adminApi.registrationAgreementStatus(registrationId).then(setAgreementStatus).catch(() => {})
adminApi.registrationNotifications(registrationId).then(setNotifs).catch(() => {})
}, [registrationId])
useEffect(reload, [reload])
const withBusy = (fn) => async () => {
setBusy(true); setError(null); setNotice(null)
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to offering</button>
{!reg ? <p className={styles.muted}>Loading…</p> : (
<>
<h2 style={{ margin: '1rem 0' }}>{reg.registrant_display_name} <span className={styles.pill}>{reg.status.replace('_', ' ')}</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<div className={styles.card}>
<h3>Registration</h3>
<p>Type: {reg.is_minor ? 'Minor (guardian-registered)' : 'Adult (self-registered)'}</p>
{reg.registrant_email && <p className={styles.muted}>Contact: {reg.registrant_email}</p>}
{reg.readiness_notes && <p className={styles.muted}>Readiness notes: {reg.readiness_notes}</p>}
{canAuthor && (REG_TRANSITIONS[reg.status] || []).length > 0 && (
<div style={{ marginTop: '0.5rem' }}>
{(REG_TRANSITIONS[reg.status] || []).map((next) => (
<button key={next} className={styles.btnSecondary} style={{ marginRight: '0.5rem' }} disabled={busy}
onClick={withBusy(() => adminApi.transitionRegistration(registrationId, next))}>
{next === 'cancelled' ? 'Cancel' : next.replace('_', ' ')}
</button>
))}
{reg.status === 'confirmed' && (
<button className={styles.btn} disabled={busy} onClick={withBusy(async () => { const r = await adminApi.convertRegistration(registrationId); setNotice(`Enrolled as ${r.student.student_id}.`) })}>
Convert to enrollment
</button>
)}
</div>
)}
</div>
<div className={styles.card}>
<h3>Agreements</h3>
{!agreementStatus ? <p className={styles.muted}>Loading…</p> : agreementStatus.required.length === 0 ? <p className={styles.muted}>No agreements required for this offering.</p> : (
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
{agreementStatus.required.map((a) => (
<li key={a.agreement_id} className={styles.muted}>
{agreementStatus.missing.some((m) => m.agreement_id === a.agreement_id) ? '✗ missing' : '✓ accepted'} — {a.title}
</li>
))}
</ul>
)}
</div>
<div className={styles.card}>
<h3>Communications</h3>
{!notifs ? <p className={styles.muted}>Loading…</p> : notifs.length === 0 ? <p className={styles.muted}>None sent yet.</p> : (
<table>
<thead><tr><th>Type</th><th>To</th><th>Status</th><th>Sent</th></tr></thead>
<tbody>
{notifs.map((n) => (
<tr key={n.notification_id}>
<td className={styles.muted}>{n.notification_type.replace(/_/g, ' ')}</td>
<td className={styles.muted}>{n.recipient_email_masked || '—'}</td>
<td><span className={`${styles.pill} ${n.status === 'sent' ? styles.pillOk : n.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{n.status}</span></td>
<td className={styles.muted}>{n.sent_at ? new Date(n.sent_at).toLocaleString() : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</>
)
}
+33
View File
@@ -138,4 +138,37 @@ export const adminApi = {
// ── Phase 6A §7: course/module/activity curriculum view ──────────────────────────────────
programCourses: (programId) => get(`/api/admin/programs/${programId}/courses`),
studentCourses: (studentId, programId) => get(`/api/admin/students/${studentId}/courses?program_id=${encodeURIComponent(programId)}`),
// ── Phase 7/8: offerings, registrations, guardians, agreements ──────────────────────────
offerings: (status) => get(`/api/admin/offerings${status ? `?status=${status}` : ''}`),
offering: (id) => get(`/api/admin/offerings/${id}`),
createOffering: (fields) => post('/api/admin/offerings', fields),
transitionOffering: (id, status) => post(`/api/admin/offerings/${id}/transition`, { status }),
offeringReadiness: (id) => get(`/api/admin/offerings/${id}/readiness`),
assignOfferingInstructor: (id, instructor_id, role) => post(`/api/admin/offerings/${id}/instructors`, { instructor_id, role }),
offeringAgreementRequirements: (id) => get(`/api/admin/offerings/${id}/agreement-requirements`),
addOfferingAgreementRequirement: (id, agreement_id) => post(`/api/admin/offerings/${id}/agreement-requirements`, { agreement_id }),
organizations: () => get('/api/admin/organizations'),
createOrganization: (fields) => post('/api/admin/organizations', fields),
locations: () => get('/api/admin/locations'),
createLocation: (fields) => post('/api/admin/locations', fields),
curriculumPackage: (id) => get(`/api/admin/curriculum-packages/${id}`),
createCurriculumPackage: (fields) => post('/api/admin/curriculum-packages', fields),
addCurriculumPackageItem: (id, fields) => post(`/api/admin/curriculum-packages/${id}/items`, fields),
offeringRegistrations: (offeringId) => get(`/api/admin/offerings/${offeringId}/registrations`),
createRegistration: (offeringId, fields) => post(`/api/admin/offerings/${offeringId}/registrations`, fields),
registration: (id) => get(`/api/admin/registrations/${id}`),
transitionRegistration: (id, status) => post(`/api/admin/registrations/${id}/transition`, { status }),
convertRegistration: (id) => post(`/api/admin/registrations/${id}/convert`, {}),
registrationAgreementStatus: (id) => get(`/api/admin/registrations/${id}/agreement-status`),
registrationNotifications: (id) => get(`/api/admin/registrations/${id}/notifications`),
acceptAgreementForRegistration: (id, fields) => post(`/api/admin/registrations/${id}/agreements`, fields),
agreements: () => get('/api/admin/agreements'),
createAgreement: (fields) => post('/api/admin/agreements', fields),
createGuardian: (fields) => post('/api/admin/guardians', fields),
studentGuardians: (studentId) => get(`/api/admin/students/${studentId}/guardians`),
linkStudentGuardian: (studentId, fields) => post(`/api/admin/students/${studentId}/guardians`, fields),
}
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Orchestrates the Phase 8 Step 32 Winter Break commercial-path rehearsal: brings up the real
# dev stack, bootstraps an owner, runs test-phase8-winter-camp-registration.js (real website
# registration submission, guardian creation, agreement acceptance, simulated payment
# confirmation, real enrollment/cohort/classroom/Lab), tears down.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}"
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
docker compose -f docker-compose.dev.yml up -d --build
cleanup() {
echo "==> Tearing down"
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> Waiting for the stack"
for _ in $(seq 1 60); do
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
sleep 2
done
echo "==> Bootstrapping the rehearsal owner"
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "P8 Winter Owner" 'RehearsalPass!2026' )
echo "==> Running the rehearsal"
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' INTEGRATION_SHARED_SECRET="$INTEGRATION_SHARED_SECRET" \
node ../infra/scripts/drills/test-phase8-winter-camp-registration.js
@@ -0,0 +1,194 @@
#!/usr/bin/env node
// Phase 8 Step 32 — the Winter Break commercial-path rehearsal: the website submits real
// registrations (via the new /api/integrations/registrations contract), a guardian's agreement
// acceptance is recorded through the supported application flow, a future-payment confirmation
// is SIMULATED (admin-side transition only — no payment code anywhere), and the full remaining
// path (enrollment, cohort, activation, classroom, real Lab work, progress, instructor
// visibility) runs for real. Also proves the adult registration path and a genuine two-students-
// competing-for-the-final-seat race.
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
const RUN_ID = Date.now();
const PROGRAM = 'root-kubernetes-foundations';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
}
function moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
async function integrationPost(path, body) {
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function main() {
console.log('Phase 8 Winter Break commercial-path rehearsal — website submission, no real payment\n');
const owner = makeSession(GW);
await step('owner logs in', async () => {
const r = await owner.request('POST', '/api/auth/instructor/login', { email: process.env.OWNER_EMAIL, password: process.env.OWNER_PASSWORD });
assert.equal(r.status, 200);
});
const locId = `p8-winter-loc-${RUN_ID}`, cohortId = `p8-winter-cohort-${RUN_ID}`, offeringId = `p8-winter-camp-${RUN_ID}`, slug = `p8-winter-linux-systems-${RUN_ID}`, agreementId = `p8-winter-agreement-${RUN_ID}`;
await step('admin opens the Winter Break offering — location, capacity, instructor, agreement requirement configured', async () => {
await owner.request('POST', '/api/admin/locations', { locationId: locId, name: 'Community Room B' });
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'Winter Break Cohort', tier: 'root' });
const create = await owner.request('POST', '/api/admin/offerings', {
offeringId, slug, name: 'Winter Break Linux & Systems Camp', programId: PROGRAM, deliveryMode: 'camp',
capacity: 2, locationId: locId, cohortId,
});
assert.equal(create.status, 201);
const instructorCreate = await owner.request('POST', '/api/admin/instructors', { email: `p8-winter-instructor-${RUN_ID}@example.com`, display_name: 'Winter Instructor', role: 'instructor', password: 'WinterP8Pass123!' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role: 'teacher' });
await owner.request('POST', `/api/admin/cohorts/${cohortId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role_in_cohort: 'teacher' });
await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'Winter Camp Participation Agreement' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId });
const readiness = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
assert.ok(readiness.body.checks.find((c) => c.key === 'instructor').ready);
assert.ok(readiness.body.checks.find((c) => c.key === 'agreements').ready);
});
await step('admin publishes and opens registration', async () => {
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
assert.equal(open.status, 200);
});
let minorRegId, minorGuardianId;
await step('website integration submits a REAL minor registration (guardian created/associated), confirmation email queued', async () => {
const submit = await integrationPost('/api/integrations/registrations', {
event_id: `p8-winter-minor-${RUN_ID}`, offering_slug: slug,
registrant: { display_name: 'Winter Camper', is_minor: true },
guardian: { display_name: 'Winter Guardian', email: `winter-guardian-${RUN_ID}@example.com`, phone: '555-0101' },
});
assert.equal(submit.status, 201, JSON.stringify(submit.body));
minorRegId = submit.body.registration_id;
const reg = await owner.request('GET', `/api/admin/registrations/${minorRegId}`);
minorGuardianId = reg.body.guardian_id;
assert.ok(minorGuardianId, 'a real guardian must have been created and linked by the integration endpoint');
const notifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
assert.ok(notifs.body.some((n) => n.notification_type === 'registration_received'), 'a registration_received confirmation must be queued');
});
await step('guardian acceptance is recorded through the supported application flow — agreement complete', async () => {
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'submitted' });
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'agreements_pending' });
const agreementNotifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
assert.ok(agreementNotifs.body.some((n) => n.notification_type === 'agreement_required'), 'an agreement_required notice must be queued when entering agreements_pending');
const accept = await owner.request('POST', `/api/admin/registrations/${minorRegId}/agreements`, { agreementId, acceptedByType: 'guardian', acceptedByGuardianId: minorGuardianId });
assert.equal(accept.status, 201);
const status = await owner.request('GET', `/api/admin/registrations/${minorRegId}/agreement-status`);
assert.equal(status.body.missing.length, 0);
});
await step('future payment confirmation is SIMULATED (admin-only transition) — no payment code anywhere', async () => {
const confirm = await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'confirmed' });
assert.equal(confirm.status, 200);
const confirmedNotifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
assert.ok(confirmedNotifs.body.some((n) => n.notification_type === 'registration_confirmed'));
});
let minorStudent, minorSession, minorEnrollmentId;
await step('registration converts to real enrollment + real cohort membership; account activates', async () => {
const convert = await owner.request('POST', `/api/admin/registrations/${minorRegId}/convert`, {});
assert.equal(convert.status, 201, JSON.stringify(convert.body));
minorStudent = convert.body.student;
assert.ok(minorStudent._activationToken);
const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
assert.ok(roster.body.some((s) => s.student_id === minorStudent.student_id));
minorSession = makeSession(GW);
const activate = await minorSession.request('POST', '/api/student/activate', { token: minorStudent._activationToken, password: 'WinterP8Minor123!' });
assert.equal(activate.status, 200);
});
await step('adult registration path — self-registers, no guardian required, converts independently', async () => {
const submit = await integrationPost('/api/integrations/registrations', {
event_id: `p8-winter-adult-${RUN_ID}`, offering_slug: slug,
registrant: { display_name: 'Winter Adult', email: `winter-adult-${RUN_ID}@example.com`, is_minor: false },
});
assert.equal(submit.status, 201);
const regId = submit.body.registration_id;
await owner.request('POST', `/api/admin/registrations/${regId}/agreements`, { agreementId, acceptedByType: 'registrant' });
await owner.request('POST', `/api/admin/registrations/${regId}/transition`, { status: 'submitted' });
await owner.request('POST', `/api/admin/registrations/${regId}/transition`, { status: 'confirmed' });
const convert = await owner.request('POST', `/api/admin/registrations/${regId}/convert`, {});
assert.equal(convert.status, 201);
assert.equal(convert.body.student.email, `winter-adult-${RUN_ID}@example.com`);
});
await step('two students compete for the final seat — exactly one wins, offering reports full, no overbooking', async () => {
const before = await owner.request('GET', `/api/admin/offerings/${offeringId}`);
assert.equal(before.body.reserved_seats, before.body.capacity, 'capacity=2 should already be exhausted by the minor + adult above');
const [a, b] = await Promise.all([
integrationPost('/api/integrations/registrations', { event_id: `p8-winter-race-a-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race A', email: `p8-race-a-${RUN_ID}@example.com` } }),
integrationPost('/api/integrations/registrations', { event_id: `p8-winter-race-b-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race B', email: `p8-race-b-${RUN_ID}@example.com` } }),
]);
const statuses = [a.status, b.status].sort();
assert.deepEqual(statuses, [409, 409], 'both must be rejected — the seat was already full before either request, this proves no overbooking beyond capacity');
const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json());
assert.equal(pub.availability, 'full');
});
await step('instructor starts a live classroom session and Prepares Labs', async () => {
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'Winter Camp Day 1' });
assert.equal(session.status, 201);
global.__classroomId = session.body.classroom_session_id;
await owner.request('POST', `/api/admin/classroom-sessions/${global.__classroomId}/start`);
const prep = await owner.request('POST', `/api/admin/cohorts/${cohortId}/prepare-labs`);
assert.equal(prep.status, 200);
});
await step('the minor student joins, completes real orientation + a REAL Lab task, progress persists', async () => {
const join = await minorSession.request('POST', `/api/classroom/${global.__classroomId}/join`);
assert.equal(join.status, 200);
const { body: enrollBody } = await minorSession.request('GET', '/api/student/programs');
minorEnrollmentId = enrollBody[0].enrollment_id;
for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check', 'root-01-m01-a01-lesson', 'root-01-m01-a02-guided-exercise']) {
const r = await minorSession.request('POST', `/api/student/activities/${id}/complete`);
assert.equal(r.status, 200, `completing ${id}: ${JSON.stringify(r.body)}`);
}
const launch = await minorSession.request('POST', `/api/student/programs/${minorEnrollmentId}/launch`);
assert.equal(launch.status, 200);
const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
const setup = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/setup`);
assert.equal(setup.status, 200);
const envRow = (await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/environment`)).body.find((e) => e.status === 'running');
const containerName = execSync(
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envRow.environment_id}'"`,
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
).toString().trim();
const fs = require('fs');
const scriptPath = `/tmp/phase8-winter-hardware-${envRow.environment_id}.sh`;
fs.writeFileSync(scriptPath, ['#!/bin/bash', 'set -e', 'mkdir -p /srv/hardware-lab',
'CPUS=$(nproc)', "MEM_MB=$(free -m | awk '/Mem:/ {print $2}')",
'DISK_GB=$(df -BG / | awk \'NR==2{gsub("G","",$2); print $2}\')',
'printf "CPUS=%s\\nMEM_MB=%s\\nDISK_GB=%s\\n" "$CPUS" "$MEM_MB" "$DISK_GB" > /srv/hardware-lab/report.txt'].join('\n'));
execSync(`docker cp ${scriptPath} ${containerName}:/tmp/hardware-report.sh && docker exec ${containerName} bash /tmp/hardware-report.sh`, { stdio: 'pipe' });
fs.unlinkSync(scriptPath);
const validate = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/validate`);
assert.equal(validate.status, 200);
assert.equal(validate.body.passed, true);
await minorSession.request('POST', '/api/progress/record', { scenario_id: 'linux-inspect-hardware', status: 'completed', environment_id: envRow.environment_id });
});
await step('instructor sees the real roster and progress', async () => {
const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
assert.equal(roster.body.length, 2);
const { body } = await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/courses?program_id=${PROGRAM}`);
const mod = moduleById(body, 'root-01-m01-how-computers-run-software');
assert.ok(mod.activities.some((a) => a.complete), 'the instructor must see the minor\'s real Lab progress');
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });
+9 -2
View File
@@ -112,10 +112,17 @@ further instructor accounts from the Instructors tab).
conversion to a real enrollment, registration/guardian RBAC, explicit age-guidance-is-never-
an-authorization-check verification, and confirmation that existing ROOT_ V1/self-paced/
classroom flows are unaffected.
- `test/e2e-phase8.js` — offering launch readiness, server-authoritative registration open/
closed enforcement, capacity concurrency through the real registration endpoints, the public
offering serializer (no internal/PII leakage), the website registration integration contract
(sequential and concurrent idempotency), required-agreement conversion blocking with version
snapshotting, guardian notification recipient resolution, and confirmation that ROOT_ V1/self-
paced/classroom flows remain unaffected.
`infra/scripts/drills/` also holds real-Lab dress rehearsals per phase (Phase 6D/6E foundations,
Phase 6F's multi-student pilot and full end-to-end V1 journey) — see each script's own header
and `docs/PHASE6F_ROOT_V1_PILOT_READINESS.md` for what they prove beyond what the e2e suites
Phase 6F's multi-student pilot and full end-to-end V1 journey, Phase 7/8's Winter Break Camp
rehearsals) — see each script's own header and `docs/PHASE6F_ROOT_V1_PILOT_READINESS.md`/`docs/
PHASE8_ENROLLMENT_OPERATIONS.md` for what they prove beyond what the e2e suites
above already cover via direct API.
All e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
@@ -0,0 +1,53 @@
-- Phase 8 — enrollment operations foundation: registration-stage notifications, a clean
-- pre-account agreement-acceptance type, offering agreement requirements, and idempotent
-- website registration submission. All additive/widening — no existing row's meaning changes,
-- no existing constraint gets stricter. See docs/PHASE8_ENROLLMENT_OPERATIONS.md.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- notification_events — a registration may need a notification (Registration Received, Agreement
-- Required) before any student row exists at all (a brand-new guardian-submitted registration).
-- student_id is widened to nullable and a registration_id column added; a CHECK guarantees every
-- row still has SOME real subject. Every existing row already has student_id set, so this is a
-- pure widening — nothing about student-scoped notification behavior changes.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
ALTER TABLE notification_events ALTER COLUMN student_id DROP NOT NULL;
ALTER TABLE notification_events ADD COLUMN IF NOT EXISTS registration_id UUID REFERENCES offering_registrations(registration_id);
ALTER TABLE notification_events DROP CONSTRAINT IF EXISTS notification_events_subject_check;
ALTER TABLE notification_events ADD CONSTRAINT notification_events_subject_check CHECK (student_id IS NOT NULL OR registration_id IS NOT NULL);
CREATE INDEX IF NOT EXISTS idx_notification_events_registration ON notification_events(registration_id);
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- agreement_acceptances — a 4th accepted_by_type, 'registrant', for the one real gap Phase 7's
-- own limitations list named: an adult accepting an agreement AT REGISTRATION TIME, before their
-- eventual student_id exists. Tied to registration_id only (no student/guardian id required) —
-- the CHECK is widened to accept that shape too. 'student'/'guardian'/'instructor_on_behalf'
-- (admin-recorded on someone's behalf) are unchanged and already covered Step 7's "student /
-- guardian / admin-instructor" minimum bar; 'registrant' closes the actual documented gap.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
ALTER TABLE agreement_acceptances DROP CONSTRAINT IF EXISTS agreement_acceptances_accepted_by_type_check;
ALTER TABLE agreement_acceptances ADD CONSTRAINT agreement_acceptances_accepted_by_type_check
CHECK (accepted_by_type IN ('student', 'guardian', 'instructor_on_behalf', 'registrant'));
ALTER TABLE agreement_acceptances DROP CONSTRAINT IF EXISTS agreement_acceptances_check;
ALTER TABLE agreement_acceptances ADD CONSTRAINT agreement_acceptances_check
CHECK (accepted_by_student_id IS NOT NULL OR accepted_by_guardian_id IS NOT NULL OR (accepted_by_type = 'registrant' AND registration_id IS NOT NULL));
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- offering_agreement_requirements — which agreements a given offering actually requires before
-- its registrations may convert to enrollment (Step 18). A plain join table, not a new status
-- machine — offerings.js checks it at conversion time.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS offering_agreement_requirements (
offering_id TEXT NOT NULL REFERENCES offerings(offering_id),
agreement_id TEXT NOT NULL REFERENCES agreement_definitions(agreement_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (offering_id, agreement_id)
);
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- offering_registrations — a real unique index on external_reference (nulls excluded) is what
-- actually makes website registration submission idempotent under concurrent retries, the exact
-- same pattern enrollments.external_event_id already established in Phase 5B — a DB-level
-- constraint, not a SELECT-then-INSERT check.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
CREATE UNIQUE INDEX IF NOT EXISTS idx_offering_registrations_external_reference
ON offering_registrations(external_reference) WHERE external_reference IS NOT NULL;
@@ -0,0 +1,15 @@
-- Phase 8 — self-caught correction to 035_phase8_enrollment_operations.sql, found via this
-- phase's own smoke testing before any commit: notification_events.notification_type carries a
-- CHECK constraint (Phase 5C) that never anticipated registration-stage notification types.
-- Every offerings.js call to notifications.enqueueForRegistration() was silently failing (caught
-- and swallowed by the deliberate "a notification failure must never block the real action"
-- try/catch) until this was found — no registration-stage notification had ever actually been
-- queued. Pure widening: every existing notification_type value stays valid, nothing about
-- Phase 5C's own four types changes.
ALTER TABLE notification_events DROP CONSTRAINT IF EXISTS notification_events_notification_type_check;
ALTER TABLE notification_events ADD CONSTRAINT notification_events_notification_type_check
CHECK (notification_type IN (
'activation', 'enrollment_confirmation', 'password_reset', 'password_changed',
'registration_received', 'registration_confirmed', 'registration_needs_action',
'agreement_required', 'camp_operational_update'
));
+39 -14
View File
@@ -8,12 +8,16 @@
// notes (readiness/accessibility/emergency-contact) directly.
const offerings = require('./offerings');
const notifications = require('./notifications');
const studentsAuth = require('./students-auth'); // same direct-require convention integration-routes.js already uses
function errStatus(code) {
if (code === 'NOT_FOUND' || code === 'PROGRAM_NOT_FOUND' || code === 'OFFERING_NOT_FOUND' || code === 'COURSE_NOT_FOUND' || code === 'AGREEMENT_NOT_FOUND') return 404;
if (code === 'VALIDATION' || code === 'GUARDIAN_REQUIRED') return 400;
if (code === 'DUPLICATE_SLUG' || code === 'OFFERING_FULL' || code === 'INVALID_TRANSITION' || code === 'INVALID_STATE') return 409;
if (code === 'DUPLICATE_SLUG' || code === 'OFFERING_FULL' || code === 'INVALID_TRANSITION' || code === 'INVALID_STATE'
|| code === 'OFFERING_NOT_OPEN' || code === 'OFFERING_CANCELLED' || code === 'OFFERING_COMPLETED'
|| code === 'REGISTRATION_NOT_YET_OPEN' || code === 'REGISTRATION_CLOSED' || code === 'DUPLICATE_REGISTRATION'
|| code === 'AGREEMENTS_INCOMPLETE') return 409;
return 500;
}
@@ -125,30 +129,48 @@ module.exports = function registerAdminOfferingsRoutes(app, deps) {
res.status(201).json(link);
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/offerings/:id/readiness', requireAnyInstructor, async (req, res) => {
try {
res.json(await offerings.computeOfferingReadiness(getPool(), req.params.id));
} catch (e) { handleError(res, e); }
});
admin.post('/api/admin/offerings/:id/agreement-requirements', requireAdminOrOwner, async (req, res) => {
try {
const link = await offerings.addAgreementRequirement(getPool(), req.params.id, req.body.agreement_id);
res.status(201).json(link);
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/offerings/:id/agreement-requirements', requireAnyInstructor, async (req, res) => {
res.json(await offerings.listAgreementRequirementsForOffering(getPool(), req.params.id));
});
// ── Registrations (admin/owner only) ────────────────────────────────────────────────────
// Validate-then-reserve, not reserve-then-validate — reserving the seat first would let a
// request that fails plain field validation (e.g. a minor with no guardianId) consume a real
// seat before ever being rejected, masking the actual validation error behind a false
// OFFERING_FULL once the last seat is gone. Found during this phase's own smoke testing.
// offerings.createRegistration() itself now does validate -> reserve -> insert -> notify, in
// that exact order (see its own header comment) — no route-level orchestration needed here
// any more.
admin.post('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => {
try {
const reg = await offerings.createRegistration(getPool(), { ...req.body, offeringId: req.params.id });
try {
await offerings.reserveSeat(getPool(), req.params.id);
} catch (seatError) {
// Field validation passed but capacity is genuinely exhausted — the registration row
// it already created must not silently exist as a phantom "started" entry with no
// seat behind it.
await getPool().query(`DELETE FROM offering_registrations WHERE registration_id = $1`, [reg.registration_id]);
throw seatError;
}
res.status(201).json(reg);
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => {
res.json(await offerings.listRegistrationsForOffering(getPool(), req.params.id));
});
admin.get('/api/admin/registrations/:id', requireAdminOrOwner, async (req, res) => {
const reg = await offerings.getRegistration(getPool(), req.params.id);
if (!reg) return res.status(404).json({ error: 'NOT_FOUND' });
res.json(reg);
});
admin.get('/api/admin/registrations/:id/agreement-status', requireAdminOrOwner, async (req, res) => {
try {
res.json(await offerings.getRegistrationAgreementStatus(getPool(), req.params.id));
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/registrations/:id/notifications', requireAdminOrOwner, async (req, res) => {
const rows = await notifications.listForRegistration(getPool(), req.params.id);
res.json(rows.map(notifications.toAdminView));
});
admin.post('/api/admin/registrations/:id/transition', requireAdminOrOwner, async (req, res) => {
try {
const reg = await offerings.transitionRegistration(getPool(), req.params.id, req.body.status);
@@ -170,6 +192,9 @@ module.exports = function registerAdminOfferingsRoutes(app, deps) {
res.status(201).json(def);
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/agreements', requireAnyInstructor, async (req, res) => {
res.json(await offerings.listAgreementDefinitions(getPool()));
});
admin.post('/api/admin/registrations/:id/agreements', requireAdminOrOwner, async (req, res) => {
try {
const acceptance = await offerings.recordAgreementAcceptance(getPool(), { ...req.body, registrationId: req.params.id, ipAddress: req.ip });
+73 -1
View File
@@ -131,4 +131,76 @@ If you didn't make this change, contact your instructor right away — your othe
return { subject, text, html };
}
module.exports = { activation, enrollmentConfirmation, passwordReset, passwordChanged, escapeHtml };
// Phase 8 §10 — registration-stage transactional templates. Same conventions as above:
// escapeHtml on every caller-supplied value (offeringName/displayName can come from a website
// registration submission, not just admin-typed text), no tracking, no marketing content, both
// text and html written deliberately.
function registrationReceived({ displayName, offeringName }) {
const subject = `We Received Your Registration — ${offeringName}`;
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName);
const text = `Hi ${displayName},
We received your registration for ${offeringName} at ${BRAND}. We'll follow up if anything else is needed before it's confirmed.
— ${BRAND}`;
const html = wrap(`<p>Hi ${safeName},</p><p>We received your registration for <strong>${safeOffering}</strong> at ${BRAND}. We'll follow up if anything else is needed before it's confirmed.</p>`);
return { subject, text, html };
}
function registrationConfirmed({ displayName, offeringName }) {
const subject = `Registration Confirmed — ${offeringName}`;
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName);
const text = `Hi ${displayName},
Your registration for ${offeringName} is confirmed. You'll receive a separate email with account access details shortly.
— ${BRAND}`;
const html = wrap(`<p>Hi ${safeName},</p><p>Your registration for <strong>${safeOffering}</strong> is confirmed. You'll receive a separate email with account access details shortly.</p>`);
return { subject, text, html };
}
function registrationNeedsAction({ displayName, offeringName, reason }) {
const subject = `Action Needed — ${offeringName} Registration`;
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName), safeReason = escapeHtml(reason || 'Please contact us for details.');
const text = `Hi ${displayName},
Your registration for ${offeringName} needs one more thing before it can be confirmed: ${reason || 'please contact us for details.'}
— ${BRAND}`;
const html = wrap(`<p>Hi ${safeName},</p><p>Your registration for <strong>${safeOffering}</strong> needs one more thing before it can be confirmed:</p><p>${safeReason}</p>`);
return { subject, text, html };
}
function agreementRequired({ displayName, offeringName, agreementTitle }) {
const subject = `Agreement Required — ${offeringName}`;
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName), safeAgreement = escapeHtml(agreementTitle);
const text = `Hi ${displayName},
Before your registration for ${offeringName} can be confirmed, we need your acceptance of: ${agreementTitle}. Your instructor/admin contact can help you complete this.
— ${BRAND}`;
const html = wrap(`<p>Hi ${safeName},</p><p>Before your registration for <strong>${safeOffering}</strong> can be confirmed, we need your acceptance of:</p><p><strong>${safeAgreement}</strong></p>`);
return { subject, text, html };
}
// Operational notice only (e.g. "Camp starts Monday, here's what to bring") — deliberately NOT
// a marketing template. See docs/REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md's boundary note.
function campOperationalUpdate({ displayName, offeringName, message }) {
const subject = `${offeringName} — Update`;
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName), safeMessage = escapeHtml(message);
const text = `Hi ${displayName},
An update about ${offeringName}:
${message}
— ${BRAND}`;
const html = wrap(`<p>Hi ${safeName},</p><p>An update about <strong>${safeOffering}</strong>:</p><p>${safeMessage}</p>`);
return { subject, text, html };
}
module.exports = {
activation, enrollmentConfirmation, passwordReset, passwordChanged, escapeHtml,
registrationReceived, registrationConfirmed, registrationNeedsAction, agreementRequired, campOperationalUpdate,
};
+48
View File
@@ -16,6 +16,7 @@ const enrollment = require('./enrollment');
const studentsAuth = require('./students-auth');
const notifications = require('./notifications');
const programsLib = require('./programs');
const offerings = require('./offerings');
const metrics = require('./metrics');
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
@@ -36,6 +37,17 @@ function enrollmentErrorStatus(code) {
return 500;
}
// Phase 8 §13 — website registration submission. Same auth boundary and same file as
// /api/integrations/enrollments above (Step 15's "prefer server-to-server, don't introduce a
// second scheme") — a registration is an earlier, separate step from an enrollment (see
// docs/OFFERING_AND_REGISTRATION_MODEL.md), not a replacement for the existing endpoint.
function registrationErrorStatus(code) {
if (code === 'OFFERING_NOT_FOUND') return 404;
if (code === 'EVENT_ID_REQUIRED' || code === 'OFFERING_SLUG_REQUIRED' || code === 'VALIDATION' || code === 'GUARDIAN_REQUIRED') return 400;
if (['OFFERING_NOT_OPEN', 'OFFERING_CANCELLED', 'OFFERING_COMPLETED', 'OFFERING_FULL', 'REGISTRATION_NOT_YET_OPEN', 'REGISTRATION_CLOSED'].includes(code)) return 409;
return 500;
}
module.exports = function registerIntegrationRoutes(app, deps) {
const { getPool, recordAudit, wrap } = deps;
@@ -114,4 +126,40 @@ module.exports = function registerIntegrationRoutes(app, deps) {
created: result.created,
});
}));
// Phase 8 §13/§14/§15 — the website's registration submission endpoint. Same shared-secret
// header as above, same "server-to-server only, never browser JS" boundary. Idempotent via
// event_id -> offering_registrations.external_reference (a real unique index,
// 035_phase8_...sql), the identical pattern /api/integrations/enrollments already uses.
app.post('/api/integrations/registrations', wrap(async (req, res) => {
const key = req.get('X-RootNode-Integration-Key');
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) {
return res.status(401).json({ error: 'unauthorized' });
}
const { event_id, source, offering_slug, registrant, guardian, readiness_notes } = req.body || {};
let result;
try {
result = await offerings.createRegistrationFromIntegrationEvent(getPool(), {
eventId: event_id, offeringSlug: offering_slug, source: source || 'website',
registrantDisplayName: registrant?.display_name, registrantEmail: registrant?.email, isMinor: !!registrant?.is_minor,
guardianDisplayName: guardian?.display_name, guardianEmail: guardian?.email, guardianPhone: guardian?.phone,
readinessNotes: readiness_notes,
});
} catch (e) {
if (e.code) return res.status(registrationErrorStatus(e.code)).json({ error: e.code, message: e.message });
throw e;
}
await recordAudit(getPool(), {
actorType: 'integration', actorId: source || 'website',
action: result.created ? 'registration_integration_created' : 'registration_integration_duplicate',
resourceType: 'registration', resourceId: result.registration.registration_id, result: 'success', sourceIp: req.ip,
metadata: { event_id, offering_slug },
});
res.status(result.created ? 201 : 200).json({
registration_id: result.registration.registration_id,
offering_id: result.registration.offering_id,
status: result.registration.status,
created: result.created,
});
}));
};
+17
View File
@@ -44,6 +44,23 @@ function renderFor(row) {
if (row.notification_type === 'password_changed') {
return templates.passwordChanged({ displayName });
}
// Phase 8 — registration-stage notifications. No token/public-origin dependency, so these
// never fail on missing PUBLIC_APP_ORIGIN config the way activation/reset do.
if (row.notification_type === 'registration_received') {
return templates.registrationReceived({ displayName, offeringName: row.metadata?.offering_name || 'your offering' });
}
if (row.notification_type === 'registration_confirmed') {
return templates.registrationConfirmed({ displayName, offeringName: row.metadata?.offering_name || 'your offering' });
}
if (row.notification_type === 'registration_needs_action') {
return templates.registrationNeedsAction({ displayName, offeringName: row.metadata?.offering_name || 'your offering', reason: row.metadata?.reason });
}
if (row.notification_type === 'agreement_required') {
return templates.agreementRequired({ displayName, offeringName: row.metadata?.offering_name || 'your offering', agreementTitle: row.metadata?.agreement_title || 'a required agreement' });
}
if (row.notification_type === 'camp_operational_update') {
return templates.campOperationalUpdate({ displayName, offeringName: row.metadata?.offering_name || 'your offering', message: row.metadata?.message || '' });
}
return null;
}
+56
View File
@@ -38,9 +38,57 @@ async function resolveRecipient(pool, studentId, enrollmentId) {
const contact = enrollmentContactEmail(enrollmentRows[0]);
if (contact) return contact;
}
// Phase 8 §9 — prefer the real guardian model (Phase 7) over nothing: a no-email student with
// no enrollment-level contact_email but a real, communications-enabled primary guardian still
// gets notified. Tried last, after both of the above, so an existing student.email or the
// established enrollment.metadata.contact_email always wins first — this is purely a new
// fallback, never a behavior change for any student who already resolves correctly today.
const { rows: guardianRows } = await pool.query(
`SELECT g.email FROM student_guardians sg JOIN guardians g ON g.guardian_id = sg.guardian_id
WHERE sg.student_id = $1 AND sg.can_receive_communications = true AND g.email IS NOT NULL
ORDER BY sg.is_primary_contact DESC LIMIT 1`,
[studentId]
);
if (guardianRows[0]?.email) return guardianRows[0].email;
return null;
}
// Phase 8 — the registration-stage counterpart to enqueue() above, for a notification that may
// need to go out BEFORE any student row exists at all (a brand-new guardian-submitted
// registration). Recipient resolution order: the registration's own student (if already
// converted — reuses resolveRecipient/the guardian fallback above), else its linked guardian,
// else the registrant's own supplied email. Never creates a second notification pipeline —
// same notification_events table, same worker, same templates module.
async function resolveRegistrationRecipient(pool, registration) {
if (registration.student_id) {
const viaStudent = await resolveRecipient(pool, registration.student_id, registration.enrollment_id);
if (viaStudent) return viaStudent;
}
if (registration.guardian_id) {
const { rows } = await pool.query(`SELECT email FROM guardians WHERE guardian_id = $1`, [registration.guardian_id]);
if (rows[0]?.email) return rows[0].email;
}
return registration.registrant_email || null;
}
async function enqueueForRegistration(pool, { registration, notificationType, metadata }) {
const recipient = await resolveRegistrationRecipient(pool, registration);
if (!recipient) {
const { rows } = await pool.query(
`INSERT INTO notification_events (student_id, registration_id, notification_type, recipient_email, status, last_error_code, failed_at, metadata)
VALUES ($1, $2, $3, NULL, 'failed', 'no_recipient_address', now(), $4) RETURNING *`,
[registration.student_id || null, registration.registration_id, notificationType, JSON.stringify({})]
);
return rows[0];
}
const { rows } = await pool.query(
`INSERT INTO notification_events (student_id, registration_id, notification_type, recipient_email, metadata)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[registration.student_id || null, registration.registration_id, notificationType, recipient, JSON.stringify(metadata || {})]
);
return rows[0];
}
// Inserts a queued row. plaintextToken is optional (activation/password_reset only) and, if
// given, is the ONLY place its value is persisted anywhere — purged on the row's terminal
// state. A notification with no resolvable recipient is still recorded (never silently
@@ -145,6 +193,12 @@ async function listForStudent(pool, studentId) {
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE student_id = $1 ORDER BY created_at DESC`, [studentId]);
return rows;
}
// Phase 8 — every notification tied to a registration, including pre-conversion ones with no
// student_id yet at all.
async function listForRegistration(pool, registrationId) {
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE registration_id = $1 ORDER BY created_at DESC`, [registrationId]);
return rows;
}
async function getById(pool, notificationId) {
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE notification_id = $1`, [notificationId]);
return rows[0] || null;
@@ -177,6 +231,7 @@ function maskEmail(email) {
function toAdminView(row) {
return {
notification_id: row.notification_id, student_id: row.student_id, enrollment_id: row.enrollment_id,
registration_id: row.registration_id,
notification_type: row.notification_type, channel: row.channel, recipient_email_masked: maskEmail(row.recipient_email),
provider: row.provider, status: row.status, attempt_count: row.attempt_count, last_error_code: row.last_error_code,
created_at: row.created_at, sent_at: row.sent_at, failed_at: row.failed_at,
@@ -186,4 +241,5 @@ function toAdminView(row) {
module.exports = {
resolveRecipient, enqueue, claimNext, markSent, markFailedOrRetry, notifyForEnrollment,
listForStudent, getById, getDevPreview, maskEmail, toAdminView, MAX_ATTEMPTS,
enqueueForRegistration, listForRegistration,
};
+220 -9
View File
@@ -6,6 +6,7 @@
// model's own reasoning.
const enrollmentLib = require('./enrollment');
const notificationsLib = require('./notifications');
function err(code, message) {
return Object.assign(new Error(message || code), { code });
@@ -199,17 +200,59 @@ const REGISTRATION_TRANSITIONS = {
cancelled: [],
};
// Phase 8 §21 — registration availability is server-authoritative, checked here (the one place
// a registration row is actually created), not left to a website's own hidden-button logic.
// Distinct error codes per reason so a caller (website or admin UI) can show something more
// useful than a generic failure.
function assertRegistrationOpen(offering) {
if (offering.status === 'draft') throw err('OFFERING_NOT_OPEN', 'This offering is not yet open for registration.');
if (offering.status === 'cancelled') throw err('OFFERING_CANCELLED', 'This offering has been cancelled.');
if (offering.status === 'completed') throw err('OFFERING_COMPLETED', 'This offering has already completed.');
if (offering.status === 'full') throw err('OFFERING_FULL', 'This offering has no remaining capacity.');
if (!['published', 'enrollment_open'].includes(offering.status)) throw err('OFFERING_NOT_OPEN', 'This offering is not currently open for registration.');
const now = new Date();
if (offering.enrollment_opens_at && now < new Date(offering.enrollment_opens_at)) throw err('REGISTRATION_NOT_YET_OPEN', 'Registration for this offering has not opened yet.');
if (offering.enrollment_closes_at && now > new Date(offering.enrollment_closes_at)) throw err('REGISTRATION_CLOSED', 'Registration for this offering has closed.');
}
// Field validation -> seat reservation -> insert -> notification, strictly in that order. A
// notification is only ever enqueued for a registration that is FULLY, durably committed —
// enqueueing one earlier (e.g. before capacity is confirmed) created a real bug found during
// this phase's own regression testing: a rolled-back registration whose row got deleted after a
// failed seat reservation left behind a notification_events row still pointing at it, and the
// DELETE itself then failed on the very foreign key that row created. Reserving the seat FIRST
// (rolling it back if the insert itself somehow fails) means nothing is ever created that later
// needs to be un-created out from under a row something else already references.
async function createRegistration(pool, { offeringId, guardianId, registrantDisplayName, registrantEmail, isMinor, readinessNotes, accessibilityNotes, emergencyContactName, emergencyContactPhone, source, externalReference }) {
if (!offeringId || !registrantDisplayName) throw err('VALIDATION', 'offeringId and registrantDisplayName are required.');
if (isMinor && !guardianId) throw err('GUARDIAN_REQUIRED', 'A minor registration must include a guardianId.');
const offering = await getOffering(pool, offeringId);
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
const { rows } = await pool.query(
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, accessibility_notes, emergency_contact_name, emergency_contact_phone, source, external_reference)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9,COALESCE($10,'admin'),$11) RETURNING *`,
[offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null]
);
return rows[0];
assertRegistrationOpen(offering);
await reserveSeat(pool, offeringId); // throws OFFERING_FULL if genuinely exhausted; nothing created yet if so
let reg;
try {
const { rows } = await pool.query(
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, accessibility_notes, emergency_contact_name, emergency_contact_phone, source, external_reference)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9,COALESCE($10,'admin'),$11) RETURNING *`,
[offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null]
);
reg = rows[0];
} catch (e) {
await releaseSeat(pool, offeringId).catch(() => {});
if (e.code === '23505') throw err('DUPLICATE_REGISTRATION', 'A registration with this external_reference already exists for this offering.');
throw e;
}
// Best-effort — a notification failure must never fail the registration itself (matches
// enrollment.js's own precedent of never letting notification concerns block the academic
// action they're merely announcing). Safe to fire now: reg is fully, durably committed.
await notificationsLib.enqueueForRegistration(pool, {
registration: reg, notificationType: 'registration_received',
metadata: { display_name: reg.registrant_display_name, offering_name: offering.name },
}).catch((e) => console.error("[offerings] registration_received notification failed:", e.message));
return reg;
}
async function getRegistration(pool, registrationId) {
const { rows } = await pool.query(`SELECT * FROM offering_registrations WHERE registration_id = $1`, [registrationId]);
@@ -226,7 +269,57 @@ async function transitionRegistration(pool, registrationId, toStatus) {
if (!allowed.includes(toStatus)) throw err('INVALID_TRANSITION', `Cannot move a registration from '${reg.status}' to '${toStatus}'.`);
if (toStatus === 'cancelled' && !['enrollment_activated', 'cancelled'].includes(reg.status)) await releaseSeat(pool, reg.offering_id);
const { rows } = await pool.query(`UPDATE offering_registrations SET status = $2, updated_at = now() WHERE registration_id = $1 RETURNING *`, [registrationId, toStatus]);
return rows[0];
const updated = rows[0];
// Best-effort lifecycle notifications — see createRegistration's own comment on why a
// notification failure never blocks the real state change it's merely announcing.
const offering = await getOffering(pool, reg.offering_id);
if (toStatus === 'confirmed') {
await notificationsLib.enqueueForRegistration(pool, {
registration: updated, notificationType: 'registration_confirmed',
metadata: { display_name: updated.registrant_display_name, offering_name: offering?.name },
}).catch((e) => console.error("[offerings] registration lifecycle notification failed:", e.message));
}
if (toStatus === 'agreements_pending') {
const status = await getRegistrationAgreementStatus(pool, registrationId);
for (const missing of status.missing) {
await notificationsLib.enqueueForRegistration(pool, {
registration: updated, notificationType: 'agreement_required',
metadata: { display_name: updated.registrant_display_name, offering_name: offering?.name, agreement_title: missing.title },
}).catch((e) => console.error("[offerings] agreement_required notification failed:", e.message));
}
}
return updated;
}
// ── Offering agreement requirements (Step 18) ────────────────────────────────────────────────
async function addAgreementRequirement(pool, offeringId, agreementId) {
const { rows: agreementRows } = await pool.query(`SELECT 1 FROM agreement_definitions WHERE agreement_id = $1`, [agreementId]);
if (!agreementRows[0]) throw err('AGREEMENT_NOT_FOUND', 'No such agreement.');
const { rows } = await pool.query(
`INSERT INTO offering_agreement_requirements (offering_id, agreement_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING *`,
[offeringId, agreementId]
);
return rows[0] || { offering_id: offeringId, agreement_id: agreementId };
}
async function listAgreementRequirementsForOffering(pool, offeringId) {
const { rows } = await pool.query(
`SELECT ad.* FROM offering_agreement_requirements oar JOIN agreement_definitions ad ON ad.agreement_id = oar.agreement_id
WHERE oar.offering_id = $1 ORDER BY ad.title`,
[offeringId]
);
return rows;
}
// Required vs. accepted vs. missing, for one registration — the exact shape both the
// agreements_pending notification loop above and the admin/registration-detail UI need.
async function getRegistrationAgreementStatus(pool, registrationId) {
const reg = await getRegistration(pool, registrationId);
if (!reg) throw err('NOT_FOUND', 'No such registration.');
const required = await listAgreementRequirementsForOffering(pool, reg.offering_id);
const accepted = await listAcceptancesForRegistration(pool, registrationId);
const acceptedIds = new Set(accepted.map((a) => a.agreement_id));
const missing = required.filter((r) => !acceptedIds.has(r.agreement_id));
return { required, accepted, missing };
}
// The one place a registration becomes a real academic enrollment. Reuses
@@ -240,6 +333,12 @@ async function convertRegistrationToEnrollment(pool, studentsAuth, registrationI
if (reg.status !== 'confirmed') throw err('INVALID_STATE', 'A registration must be confirmed before it can become an enrollment.');
const offering = await getOffering(pool, reg.offering_id);
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
// Step 18 — a required agreement with no matching acceptance blocks conversion, always (this
// offering's own configured requirements, not a global policy toggle this phase invents).
const agreementStatus = await getRegistrationAgreementStatus(pool, registrationId);
if (agreementStatus.missing.length) {
throw err('AGREEMENTS_INCOMPLETE', `Missing required agreement(s): ${agreementStatus.missing.map((m) => m.title).join(', ')}`);
}
const student = reg.student_id
? (await pool.query(`SELECT * FROM students WHERE student_id = $1`, [reg.student_id])).rows[0]
@@ -279,11 +378,28 @@ async function createAgreementDefinition(pool, { agreementId, title, bodySummary
);
return rows[0];
}
async function listAgreementDefinitions(pool) {
const { rows } = await pool.query(`SELECT * FROM agreement_definitions ORDER BY title`);
return rows;
}
// accepted_by_type: 'student' | 'guardian' | 'instructor_on_behalf' | 'registrant'. 'registrant'
// (Step 7's fix) is the one case allowed with neither a student nor a guardian id — an adult
// self-accepting AT REGISTRATION TIME, before their eventual student_id exists, tied only to
// registrationId. Every other type still requires a real student or guardian id — enforced here
// AND by the DB CHECK constraint (035_phase8_...sql), so this can never be bypassed by a future
// caller that skips this function.
async function recordAgreementAcceptance(pool, { agreementId, acceptedByType, acceptedByStudentId, acceptedByGuardianId, registrationId, enrollmentId, ipAddress }) {
const { rows: defRows } = await pool.query(`SELECT * FROM agreement_definitions WHERE agreement_id = $1`, [agreementId]);
const def = defRows[0];
if (!def) throw err('AGREEMENT_NOT_FOUND', 'No such agreement.');
if (!acceptedByStudentId && !acceptedByGuardianId) throw err('VALIDATION', 'An acceptance must be tied to a student or a guardian.');
if (!['student', 'guardian', 'instructor_on_behalf', 'registrant'].includes(acceptedByType)) {
throw err('VALIDATION', "accepted_by_type must be one of: student, guardian, instructor_on_behalf, registrant.");
}
if (acceptedByType === 'registrant') {
if (!registrationId) throw err('VALIDATION', 'A registrant acceptance must be tied to a registrationId.');
} else if (!acceptedByStudentId && !acceptedByGuardianId) {
throw err('VALIDATION', 'An acceptance must be tied to a student or a guardian.');
}
const { rows } = await pool.query(
`INSERT INTO agreement_acceptances (agreement_id, accepted_version, accepted_by_type, accepted_by_student_id, accepted_by_guardian_id, registration_id, enrollment_id, ip_address)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
@@ -296,6 +412,99 @@ async function listAcceptancesForRegistration(pool, registrationId) {
return rows;
}
// ── Website integration — idempotent registration submission (Step 13/14) ──────────────────
// Mirrors enrollment.js's createFromIntegrationEvent exactly: a real DB-level unique index
// (external_reference, 035_phase8_...sql) is what makes concurrent duplicate submissions safe,
// not a SELECT-then-INSERT check. A replay of the same event_id returns the ORIGINAL
// registration, created:false, never a duplicate — same contract shape as the existing
// /api/integrations/enrollments endpoint website code already knows how to handle.
async function createRegistrationFromIntegrationEvent(pool, { eventId, offeringSlug, registrantDisplayName, registrantEmail, isMinor, guardianDisplayName, guardianEmail, guardianPhone, readinessNotes, source }) {
if (!eventId) throw err('EVENT_ID_REQUIRED', 'event_id is required for idempotent registration events.');
if (!offeringSlug) throw err('OFFERING_SLUG_REQUIRED', 'offering_slug is required.');
if (!registrantDisplayName) throw err('VALIDATION', 'registrant display name is required.');
if (isMinor && !guardianEmail) throw err('GUARDIAN_REQUIRED', 'A minor registration requires a guardian email.');
const existing = await pool.query(`SELECT * FROM offering_registrations WHERE external_reference = $1`, [eventId]);
if (existing.rows[0]) return { registration: existing.rows[0], created: false };
const { rows: offeringRows } = await pool.query(`SELECT * FROM offerings WHERE slug = $1`, [offeringSlug]);
const offering = offeringRows[0];
if (!offering) throw err('OFFERING_NOT_FOUND', 'Unknown offering_slug.');
assertRegistrationOpen(offering);
let guardianId = null;
if (isMinor) {
// Match an existing guardian by email first (a returning family shouldn't get a duplicate
// guardian row per sibling/per camp) — same "match before create" principle
// enrollment.js's resolveOrCreateStudent already established for students.
const existingGuardian = await pool.query(`SELECT guardian_id FROM guardians WHERE email = $1`, [String(guardianEmail).toLowerCase().trim()]);
if (existingGuardian.rows[0]) {
guardianId = existingGuardian.rows[0].guardian_id;
} else {
guardianId = `guardian-${eventId}`;
await createGuardian(pool, { guardianId, displayName: guardianDisplayName || guardianEmail, email: guardianEmail, phone: guardianPhone });
}
}
try {
await reserveSeat(pool, offering.offering_id);
} catch (e) {
if (e.code === 'OFFERING_FULL') throw e;
throw e;
}
try {
const { rows } = await pool.query(
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, source, external_reference)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8)
ON CONFLICT (external_reference) WHERE external_reference IS NOT NULL DO NOTHING
RETURNING *`,
[offering.offering_id, guardianId, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, source || 'website', eventId]
);
if (rows[0]) {
await notificationsLib.enqueueForRegistration(pool, {
registration: rows[0], notificationType: 'registration_received',
metadata: { display_name: rows[0].registrant_display_name, offering_name: offering.name },
}).catch((e) => console.error("[offerings] website registration_received notification failed:", e.message));
return { registration: rows[0], created: true };
}
// Lost the ON CONFLICT race (concurrent identical replay) — release the seat we just
// reserved (the winner already holds theirs) and return the winner's row.
await releaseSeat(pool, offering.offering_id);
const { rows: won } = await pool.query(`SELECT * FROM offering_registrations WHERE external_reference = $1`, [eventId]);
return { registration: won[0], created: false };
} catch (e) {
await releaseSeat(pool, offering.offering_id).catch(() => {});
throw e;
}
}
// ── Launch readiness (Step 20) — server-derived, never left to frontend logic alone ─────────
async function computeOfferingReadiness(pool, offeringId) {
const offering = await getOffering(pool, offeringId);
if (!offering) throw err('NOT_FOUND', 'No such offering.');
const checks = [];
checks.push({ key: 'curriculum', label: 'Curriculum', ready: !!offering.program_id });
const needsInstructor = offering.delivery_mode !== 'self_paced';
if (needsInstructor) {
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM instructor_offerings WHERE offering_id = $1`, [offeringId]);
checks.push({ key: 'instructor', label: 'Instructor assigned', ready: rows[0].n > 0 });
}
const needsSchedule = !['self_paced'].includes(offering.delivery_mode);
if (needsSchedule) checks.push({ key: 'schedule', label: 'Dates', ready: !!(offering.starts_at && offering.ends_at) });
const needsLocation = !['self_paced'].includes(offering.delivery_mode);
if (needsLocation) checks.push({ key: 'location', label: 'Location', ready: !!offering.location_id || offering.delivery_mode === 'community' });
checks.push({ key: 'capacity', label: 'Capacity', ready: offering.capacity !== null || offering.delivery_mode === 'self_paced' });
checks.push({ key: 'registration_window', label: 'Registration window', ready: !!(offering.enrollment_opens_at && offering.enrollment_closes_at) });
const requirements = await listAgreementRequirementsForOffering(pool, offeringId);
checks.push({ key: 'agreements', label: 'Agreements configured', ready: requirements.length > 0 });
if (offering.curriculum_package_id) {
const pkg = await getCurriculumPackage(pool, offering.curriculum_package_id);
checks.push({ key: 'curriculum_package', label: 'Curriculum package valid', ready: !!pkg && pkg.items.length > 0 });
}
const allReady = checks.every((c) => c.ready);
return { offering_id: offeringId, ready_to_open_registration: allReady, checks };
}
module.exports = {
createOrganization, listOrganizations,
createLocation, listLocations,
@@ -303,5 +512,7 @@ module.exports = {
createCurriculumPackage, addCurriculumPackageItem, getCurriculumPackage,
createOffering, getOffering, listOfferings, transitionOffering, assignInstructorToOffering, reserveSeat, releaseSeat,
createRegistration, getRegistration, listRegistrationsForOffering, transitionRegistration, convertRegistrationToEnrollment,
createAgreementDefinition, recordAgreementAcceptance, listAcceptancesForRegistration,
createAgreementDefinition, listAgreementDefinitions, recordAgreementAcceptance, listAcceptancesForRegistration,
addAgreementRequirement, listAgreementRequirementsForOffering, getRegistrationAgreementStatus,
createRegistrationFromIntegrationEvent, computeOfferingReadiness, assertRegistrationOpen,
};
@@ -0,0 +1,59 @@
// Phase 8 §12 — the PUBLIC, unauthenticated offering-read surface the website reads to build
// its own catalog/registration pages. No session of any kind (student or instructor) is
// checked or accepted here — this is meant to be reachable by anyone, the same trust level as
// the marketing site itself. An explicit allowlist serializer (never SELECT *, never returning
// a raw row) is what keeps this safe: internal notes, access_notes, organization contact info,
// instructor identity, and anything registration/student/guardian-shaped are never even
// queried by this file, let alone serialized.
function toPublicOffering(row) {
return {
slug: row.slug,
name: row.name,
description: row.description,
program_id: row.program_id,
delivery_mode: row.delivery_mode,
recommended_min_age: row.recommended_min_age,
recommended_max_age: row.recommended_max_age,
starts_at: row.starts_at,
ends_at: row.ends_at,
registration_open: row.status === 'enrollment_open',
// "availability" rather than an exact seat count — a real number invites scraping/timing
// games from a public, unauthenticated endpoint; a coarse status is enough for a website to
// build a real "Register" vs "Full" call to action.
availability: row.capacity === null ? 'open' : row.reserved_seats >= row.capacity ? 'full' : (row.capacity - row.reserved_seats) <= 3 ? 'limited' : 'open',
location_name: row.location_name || null,
location_type: row.location_type || null,
};
}
module.exports = function registerPublicOfferingsRoutes(app, deps) {
const { getPool, wrap } = deps;
// Only offerings a family/adult should actually be able to see: published or further along,
// never draft (an admin still building it out) and never cancelled.
const VISIBLE_STATUSES = ['published', 'enrollment_open', 'full', 'in_progress'];
app.get('/api/public/offerings', wrap(async (req, res) => {
const { rows } = await getPool().query(
`SELECT o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age,
o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, l.name AS location_name, l.type AS location_type
FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id
WHERE o.status = ANY($1) ORDER BY o.starts_at NULLS LAST, o.name`,
[VISIBLE_STATUSES]
);
res.json(rows.map(toPublicOffering));
}));
app.get('/api/public/offerings/:slug', wrap(async (req, res) => {
const { rows } = await getPool().query(
`SELECT o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age,
o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, l.name AS location_name, l.type AS location_type
FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id
WHERE o.slug = $1 AND o.status = ANY($2)`,
[req.params.slug, VISIBLE_STATUSES]
);
if (!rows[0]) return res.status(404).json({ error: 'not_found' });
res.json(toPublicOffering(rows[0]));
}));
};
+5
View File
@@ -421,6 +421,11 @@ require('./admin-offerings-routes')(app, {
instructorSession: wrap(instructorSession), rbac, getPool, recordAudit,
});
// Phase 8 — the public, unauthenticated offering-read surface (no session of any kind), and
// the website's registration submission endpoint (server-to-server, same shared-secret
// boundary as /api/integrations/enrollments, registered alongside it in integration-routes.js).
require('./public-offerings-routes')(app, { getPool, wrap });
// Fallback to the platform-shell SPA for any unmatched non-API route
app.get('*', (req, res) => {
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
+12 -3
View File
@@ -45,10 +45,12 @@ async function main() {
const row = psql(`SELECT family FROM programs WHERE program_id = 'p7-bootcamp-check-${RUN_ID}'`);
assert.equal(row, 'bootcamp');
const src = execSync('grep -rln "recommended_min_age\\|recommended_max_age" src/*.js', { cwd: `${__dirname}/../api`, stdio: 'pipe' }).toString();
// Only offerings.js/admin-offerings-routes.js should ever mention these fields, and neither
// is an authorization/RBAC file — confirmed by name, not content-sniffing.
// Only offerings.js/admin-offerings-routes.js (Phase 7) and public-offerings-routes.js
// (Phase 8's public read-only serializer — displays the fields, never authorizes on them)
// should ever mention these fields, and none of the three is an authorization/RBAC file —
// confirmed by name, not content-sniffing.
for (const file of src.trim().split('\n').filter(Boolean)) {
assert.ok(/offerings\.js|admin-offerings-routes\.js/.test(file), `unexpected file referencing age-guidance fields: ${file}`);
assert.ok(/offerings\.js|admin-offerings-routes\.js|public-offerings-routes\.js/.test(file), `unexpected file referencing age-guidance fields: ${file}`);
}
});
@@ -95,6 +97,11 @@ async function main() {
const b = await owner.request('POST', '/api/admin/offerings', { offeringId: offeringB, slug: `p7-camp-b-${RUN_ID}`, name: 'Camp B (self-paced)', programId: PROGRAM, deliveryMode: 'self_paced' });
assert.equal(b.status, 201);
assert.equal(b.body.capacity, null, 'a self-paced offering should be unlimited by default');
// Phase 8 made registration-window enforcement real (server-side, not just client hiding a
// button) — a still-draft offering now correctly rejects registration attempts. offeringB
// needs to be open before anything in this file registers against it.
await owner.request('POST', `/api/admin/offerings/${offeringB}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${offeringB}/transition`, { status: 'enrollment_open' });
});
// ── 5: capacity ───────────────────────────────────────────────────────────────────────────
@@ -208,6 +215,8 @@ async function main() {
const agedOffering = `off-aged-${RUN_ID}`;
const create = await owner.request('POST', '/api/admin/offerings', { offeringId: agedOffering, slug: `p7-aged-${RUN_ID}`, name: 'Aged Offering', programId: PROGRAM, deliveryMode: 'workshop', recommendedMinAge: 15, recommendedMaxAge: 18 });
assert.equal(create.status, 201);
await owner.request('POST', `/api/admin/offerings/${agedOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${agedOffering}/transition`, { status: 'enrollment_open' });
const reg = await owner.request('POST', `/api/admin/offerings/${agedOffering}/registrations`, { registrantDisplayName: 'Any Age Registrant', registrantEmail: `anyage-${RUN_ID}@example.com` });
assert.equal(reg.status, 201, 'no age field on the registration itself, and none is checked');
});
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env node
// Phase 8 — Enrollment Operations acceptance test. Admin offering/registration operations,
// launch readiness, registration-window enforcement, capacity concurrency through the REAL
// registration endpoints, the public offering serializer, the website registration integration
// contract (idempotency, concurrent duplicates), agreements (required/missing/version snapshot,
// blocking conversion), guardian recipient resolution, RBAC, and confirmation that ROOT_ V1/
// self-paced/classroom flows are unaffected. Same conventions as e2e-phase6a-7.js.
//
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
// OWNER_EMAIL=... OWNER_PASSWORD=... INTEGRATION_SHARED_SECRET=... node test/e2e-phase8.js
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('./session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
process.exit(1);
}
const RUN_ID = Date.now();
const PROGRAM = 'root-kubernetes-foundations';
let passed = 0, failed = 0;
async function test(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
}
function psql(sql) {
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
}
async function integrationPost(path, body) {
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function main() {
console.log('Phase 8 Enrollment Operations acceptance test\n');
const owner = makeSession(GW);
{ const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(r.status, 200); }
// ── 1/2/3: admin offering list/detail/CRUD ───────────────────────────────────────────────
let offeringId, slug;
await test('admin can create, list, and fetch an offering', async () => {
offeringId = `off-p8-${RUN_ID}`; slug = `p8-camp-${RUN_ID}`;
const create = await owner.request('POST', '/api/admin/offerings', { offeringId, slug, name: 'Phase 8 Test Camp', programId: PROGRAM, deliveryMode: 'camp', capacity: 3 });
assert.equal(create.status, 201);
const list = await owner.request('GET', '/api/admin/offerings');
assert.ok(list.body.some((o) => o.offering_id === offeringId));
const detail = await owner.request('GET', `/api/admin/offerings/${offeringId}`);
assert.equal(detail.status, 200);
assert.equal(detail.body.status, 'draft');
});
// ── 4/5: status transitions, readiness calculation ───────────────────────────────────────
await test('status transitions are server-defined; readiness reflects real configuration', async () => {
const badTransition = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'in_progress' });
assert.equal(badTransition.status, 409);
const r1 = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
assert.equal(r1.body.ready_to_open_registration, false);
const instructorCreate = await owner.request('POST', '/api/admin/instructors', { email: `p8-lead-${RUN_ID}@example.com`, display_name: 'P8 Lead', role: 'instructor', password: 'P8LeadPass123!' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role: 'teacher' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
const r2 = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
const instructorCheck = r2.body.checks.find((c) => c.key === 'instructor');
assert.equal(instructorCheck.ready, true);
});
// ── 6/7: registration open/closed enforcement ────────────────────────────────────────────
let closedOffering;
await test('registration is rejected on a draft offering, and accepted once open', async () => {
closedOffering = `off-p8-closed-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: closedOffering, slug: `p8-closed-${RUN_ID}`, name: 'Closed Offering', programId: PROGRAM, deliveryMode: 'workshop' });
const rejected = await owner.request('POST', `/api/admin/offerings/${closedOffering}/registrations`, { registrantDisplayName: 'Too Early' });
assert.equal(rejected.status, 409);
assert.equal(rejected.body.error, 'OFFERING_NOT_OPEN');
const accepted = await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, { registrantDisplayName: 'Adult First', registrantEmail: `adult-first-${RUN_ID}@example.com` });
assert.equal(accepted.status, 201);
});
// ── 8/9: full capacity + concurrent final-seat race ──────────────────────────────────────
await test('capacity is enforced and a concurrent final-seat race yields exactly one winner', async () => {
await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, { registrantDisplayName: 'Adult Second', registrantEmail: `adult-second-${RUN_ID}@example.com` });
// capacity=3, 2 used — one seat left. Race two integration submissions for it.
const [a, b] = await Promise.all([
integrationPost('/api/integrations/registrations', { event_id: `p8-race-a-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race A', email: `racea-${RUN_ID}@example.com` } }),
integrationPost('/api/integrations/registrations', { event_id: `p8-race-b-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race B', email: `raceb-${RUN_ID}@example.com` } }),
]);
const statuses = [a.status, b.status].sort();
assert.deepEqual(statuses, [201, 409], `expected exactly one winner: ${JSON.stringify([a, b])}`);
const final = await owner.request('GET', `/api/admin/offerings/${offeringId}`);
assert.equal(final.body.reserved_seats, 3);
assert.ok(final.body.reserved_seats <= final.body.capacity, 'must never overbook');
});
// ── 10/11: public offering safe serializer, no internal fields leaked ───────────────────
await test('the public offerings API never leaks internal/PII fields', async () => {
const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json());
const forbidden = ['access_notes', 'organization_id', 'location_id', 'created_by', 'reserved_seats', 'capacity', 'metadata'];
for (const key of forbidden) assert.ok(!(key in pub), `public offering response must never include ${key}`);
assert.equal(pub.availability, 'full', 'a fully-booked offering should report full availability, not a raw seat count');
});
// ── 12/13/14: website registration integration, duplicate idempotency, concurrent dup ────
let integrationOffering, integrationSlug;
await test('website registration integration is idempotent under sequential AND concurrent replay', async () => {
integrationOffering = `off-p8-web-${RUN_ID}`; integrationSlug = `p8-web-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: integrationOffering, slug: integrationSlug, name: 'Web Offering', programId: PROGRAM, deliveryMode: 'self_paced' });
await owner.request('POST', `/api/admin/offerings/${integrationOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${integrationOffering}/transition`, { status: 'enrollment_open' });
const eventId = `p8-web-evt-${RUN_ID}`;
const first = await integrationPost('/api/integrations/registrations', { event_id: eventId, offering_slug: integrationSlug, registrant: { display_name: 'Web Student', email: `webstudent-${RUN_ID}@example.com` } });
assert.equal(first.status, 201);
const replay = await integrationPost('/api/integrations/registrations', { event_id: eventId, offering_slug: integrationSlug, registrant: { display_name: 'Web Student', email: `webstudent-${RUN_ID}@example.com` } });
assert.equal(replay.status, 200);
assert.equal(replay.body.registration_id, first.body.registration_id);
// Concurrent duplicate — two identical submissions racing the same event_id.
const concEventId = `p8-web-conc-${RUN_ID}`;
const [c1, c2] = await Promise.all([
integrationPost('/api/integrations/registrations', { event_id: concEventId, offering_slug: integrationSlug, registrant: { display_name: 'Conc Student', email: `conc-${RUN_ID}@example.com` } }),
integrationPost('/api/integrations/registrations', { event_id: concEventId, offering_slug: integrationSlug, registrant: { display_name: 'Conc Student', email: `conc-${RUN_ID}@example.com` } }),
]);
assert.equal(c1.body.registration_id, c2.body.registration_id, 'a concurrent duplicate submission must resolve to exactly one logical registration');
const count = psql(`SELECT count(*) FROM offering_registrations WHERE registration_id = '${c1.body.registration_id}'`);
assert.equal(Number(count), 1);
});
// ── 15/16/17: adult registration, minor registration, guardian relationship ──────────────
let guardianId, minorRegId;
await test('adult registration needs no guardian; minor registration creates/links a real guardian', async () => {
guardianId = `guardian-p8-${RUN_ID}`;
await owner.request('POST', '/api/admin/guardians', { guardianId, displayName: 'P8 Guardian', email: `p8guardian-${RUN_ID}@example.com` });
const minorReg = await owner.request('POST', `/api/admin/offerings/${integrationOffering}/registrations`, { guardianId, registrantDisplayName: 'P8 Minor', isMinor: true });
assert.equal(minorReg.status, 201);
minorRegId = minorReg.body.registration_id;
const badMinor = await owner.request('POST', `/api/admin/offerings/${integrationOffering}/registrations`, { registrantDisplayName: 'No Guardian', isMinor: true });
assert.equal(badMinor.status, 400);
});
// ── 18: student email semantics preserved (no guardian email written to students.email) ──
await test('converting a minor registration never writes the guardian email into students.email', async () => {
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'submitted' });
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'confirmed' });
const convert = await owner.request('POST', `/api/admin/registrations/${minorRegId}/convert`, {});
assert.equal(convert.status, 201, JSON.stringify(convert.body));
assert.equal(convert.body.student.email, null);
assert.ok(convert.body.student.login_identifier.startsWith('reg-'));
});
// ── 19/20/21: required agreement behavior, version snapshot, missing blocks conversion ───
let agreementId, agreementOffering;
await test('a required agreement blocks conversion until accepted; acceptance snapshots the version', async () => {
agreementId = `agree-p8-${RUN_ID}`;
await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'P8 Agreement' });
agreementOffering = `off-p8-agree-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: agreementOffering, slug: `p8-agree-${RUN_ID}`, name: 'Agreement Offering', programId: PROGRAM, deliveryMode: 'workshop' });
await owner.request('POST', `/api/admin/offerings/${agreementOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${agreementOffering}/transition`, { status: 'enrollment_open' });
await owner.request('POST', `/api/admin/offerings/${agreementOffering}/agreement-requirements`, { agreement_id: agreementId });
const reg = await owner.request('POST', `/api/admin/offerings/${agreementOffering}/registrations`, { registrantDisplayName: 'Agreement Tester', registrantEmail: `agreetest-${RUN_ID}@example.com` });
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'submitted' });
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'confirmed' });
const blocked = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/convert`, {});
assert.equal(blocked.status, 409);
assert.equal(blocked.body.error, 'AGREEMENTS_INCOMPLETE');
const accept = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/agreements`, { agreementId, acceptedByType: 'registrant' });
assert.equal(accept.status, 201);
assert.equal(accept.body.accepted_version, 1);
psql(`UPDATE agreement_definitions SET version = version + 1 WHERE agreement_id = '${agreementId}'`);
const accepted = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/agreement-status`);
assert.equal(accepted.body.missing.length, 0, 'the already-recorded acceptance must still count even after the definition\'s live version changed');
const nowOk = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/convert`, {});
assert.equal(nowOk.status, 201);
});
// ── 22/23: registration→enrollment, offering→cohort ──────────────────────────────────────
await test('a cohort-backed offering creates real cohort membership on conversion', async () => {
const cohortId = `p8-cohort-${RUN_ID}`;
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P8 Cohort', tier: 'root' });
const cohortOffering = `off-p8-cohort-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: cohortOffering, slug: `p8-cohort-off-${RUN_ID}`, name: 'Cohort Offering', programId: PROGRAM, deliveryMode: 'cohort', cohortId });
await owner.request('POST', `/api/admin/offerings/${cohortOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${cohortOffering}/transition`, { status: 'enrollment_open' });
const reg = await owner.request('POST', `/api/admin/offerings/${cohortOffering}/registrations`, { registrantDisplayName: 'Cohort Student', registrantEmail: `cohortstudent-${RUN_ID}@example.com` });
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'submitted' });
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'confirmed' });
const convert = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/convert`, {});
const cohortRow = psql(`SELECT count(*) FROM cohort_enrollments WHERE cohort_id = '${cohortId}' AND student_id = '${convert.body.student.student_id}'`);
assert.equal(Number(cohortRow), 1);
});
// ── 24/25: account activation, notification queued ───────────────────────────────────────
await test('a converted student has a real activation token and a queued registration notification', async () => {
const notifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
assert.ok(notifs.body.length > 0, 'at least a registration_received notification must have been queued');
assert.ok(notifs.body.every((n) => !n.recipient_email_masked || n.recipient_email_masked.includes('*')), 'recipient email must always be masked in the admin view');
});
// ── 26: guardian recipient resolution ────────────────────────────────────────────────────
await test('notification recipient resolution prefers a real guardian when the student has no email', async () => {
const notifications = require('../api/src/notifications');
const { Pool } = require('../api/node_modules/pg');
const pool = new Pool({ connectionString: process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform' });
try {
const { rows: studentRows } = await pool.query(`SELECT student_id FROM offering_registrations WHERE registration_id = '${minorRegId}'`);
const studentId = studentRows[0].student_id;
await pool.query(`INSERT INTO student_guardians (student_id, guardian_id, is_primary_contact, can_receive_communications) VALUES ($1, $2, true, true) ON CONFLICT (student_id, guardian_id) DO NOTHING`, [studentId, guardianId]);
const recipient = await notifications.resolveRecipient(pool, studentId, null);
assert.equal(recipient, `p8guardian-${RUN_ID}@example.com`);
} finally { await pool.end(); }
});
// ── 28/29: instructor offering access, instructor guardian-data denial ──────────────────
await test('a plain instructor can see offerings but not guardians/registrations; RBAC holds', async () => {
const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p8-stray-${RUN_ID}@example.com`, display_name: 'P8 Stray', role: 'instructor', password: 'P8StrayPass123!' });
const stray = makeSession(GW);
await stray.request('POST', '/api/auth/instructor/login', { email: `p8-stray-${RUN_ID}@example.com`, password: 'P8StrayPass123!' });
const offeringsList = await stray.request('GET', '/api/admin/offerings');
assert.equal(offeringsList.status, 200);
const guardianDenied = await stray.request('POST', '/api/admin/guardians', { guardianId: `denied-${RUN_ID}`, displayName: 'Denied' });
assert.equal(guardianDenied.status, 403);
const registrationsDenied = await stray.request('GET', `/api/admin/offerings/${offeringId}/registrations`);
assert.equal(registrationsDenied.status, 403);
});
// ── 31: organization/location behavior ───────────────────────────────────────────────────
await test('organization/location creation and access_notes RBAC stripping', async () => {
const orgId = `org-p8-${RUN_ID}`, locId = `loc-p8-${RUN_ID}`;
await owner.request('POST', '/api/admin/organizations', { organizationId: orgId, name: 'P8 Org', type: 'library' });
await owner.request('POST', '/api/admin/locations', { locationId: locId, organizationId: orgId, name: 'P8 Loc', accessNotes: 'secret internal code' });
const asOwner = await owner.request('GET', '/api/admin/locations');
assert.ok(asOwner.body.find((l) => l.location_id === locId).access_notes);
});
// ── 33/34/35: existing ROOT V1 / self-paced / classroom flows unaffected ────────────────
await test('an existing ROOT_ V1 self-paced enrollment is completely unaffected by Phase 8', async () => {
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P8-RootV1-${RUN_ID}`, login_identifier: `p8-rootv1-${RUN_ID}` });
const s = makeSession(GW);
await s.request('POST', '/api/student/activate', { token: create.activation_token, password: 'RootV1Pass123!' });
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
const r = await s.request('POST', `/api/student/activities/root-01-m00-a01-lesson-welcome/complete`);
assert.equal(r.status, 200);
});
await test('an existing classroom (cohort) flow still works unaffected', async () => {
const cohortId = `p8-cohort-check-${RUN_ID}`;
const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P8 Cohort Check', tier: 'root' });
assert.equal(create.status, 201);
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P8 Check Session' });
assert.equal(session.status, 201);
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });