Compare commits
4
Commits
df5e39f767
...
788fb197e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
788fb197e0 | ||
|
|
aa63d09fb8 | ||
|
|
40362ec0c9 | ||
|
|
24eee80354 |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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}>← 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}>← 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
));
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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]));
|
||||
}));
|
||||
};
|
||||
@@ -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'));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user