docs(phase8): document enrollment operations
New: PHASE8_ENROLLMENT_OPERATIONS.md (phase summary), WINTER_BREAK_CAMP_OPERATIONS.md (the real operational walkthrough), REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md, OFFERING_ADMIN_GUIDE.md. Updated: ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md (BUILT NOW/PHASE 8 BUILT table), OFFERING_AND_REGISTRATION_MODEL.md (both ordering bugs, launch readiness, registration enforcement), STUDENT_GUARDIAN_DATA_MODEL.md (guardian communications now wired), WEBSITE_ENROLLMENT_INTEGRATION.md (the real, live registration contract with worked examples, replacing the old speculative future-flow section), platform/README.md (test list). Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -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
|
# Offering & Registration Model
|
||||||
|
|
||||||
Phase 7 Steps 3/6/12-14/18-20 — the schema and lifecycle for offerings, capacity, registrations,
|
Phase 7 Steps 3/6/12-14/18-20 (extended in Phase 8) — the schema and lifecycle for offerings,
|
||||||
agreements, and curriculum packages. See `platform/api/migrations/034_phase7_offerings_
|
capacity, registrations, agreements, and curriculum packages. See `platform/api/migrations/
|
||||||
registrations_agreements.sql` for the exact DDL and `platform/api/src/offerings.js` for the
|
034_phase7_offerings_registrations_agreements.sql` and `035_phase8_enrollment_operations.sql`
|
||||||
implementation.
|
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)
|
## 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
|
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.
|
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
|
**Two real ordering bugs found and fixed across Phase 7 and Phase 8, in opposite directions —
|
||||||
originally reserved the seat *before* validating the registration's own fields (e.g. a minor
|
the current, final order is: validate fields → reserve the seat → insert the row → notify.**
|
||||||
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
|
Phase 7's own testing found the route originally reserved the seat *before* validating the
|
||||||
later fail validation had already consumed a real seat. Fixed by creating (and validating) the
|
registration's fields (e.g. a minor missing `guardianId`), letting a plain validation error get
|
||||||
registration row first, then reserving the seat, rolling back (deleting) the registration row if
|
masked behind a false `OFFERING_FULL` under a full offering. Fixed by validating first.
|
||||||
the seat reservation itself then fails — see `admin-offerings-routes.js`'s own comment.
|
|
||||||
|
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
|
## 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 |
|
| Registrations, registration lifecycle | PHASE 7 BUILT |
|
||||||
| Agreement definitions/acceptances (record-only, no legal text) | 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 routes for all of the above | PHASE 7 BUILT |
|
||||||
| Admin UI (Offerings/Registrations screens) | **NOT BUILT this phase** — see known limitations |
|
| Admin UI (Offerings/Registrations screens) | **PHASE 8 BUILT** — `/admin` → Offerings, with launch readiness, roster, agreements, and per-registration detail (communications, agreement status). |
|
||||||
| Public registration flow, payments, subscriptions | FUTURE |
|
| 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 |
|
| INIT_/COMPILE_ curriculum | FUTURE |
|
||||||
| School/library SIS integrations, parent portal | FUTURE, explicitly out of scope |
|
| 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
|
detailed learning analytics by default. A guardian relationship is a contact/authorization
|
||||||
record, not a monitoring grant.
|
record, not a monitoring grant.
|
||||||
- **No automatic broad access.** `student_guardians.can_receive_communications` and
|
- **No automatic broad access.** `student_guardians.can_receive_communications` and
|
||||||
`is_primary_contact` govern notification targeting only (a future concern — this phase writes
|
`is_primary_contact` govern notification targeting only — wired into `notifications.js`'s
|
||||||
the rows but does not wire them into `notifications.js`'s resolveRecipient yet), never
|
`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.
|
authorization to student data.
|
||||||
|
|
||||||
## Data minimization
|
## 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
|
workaround; the schema now has the real tables for it, they're just not wired into this
|
||||||
endpoint yet.
|
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
|
As of Phase 8, RootNode represents specific purchasable deliveries — camps, cohorts, self-paced
|
||||||
toward representing specific purchasable deliveries — camps, cohorts, self-paced access — as
|
access — as first-class **offerings**, with a **registration** step before final academic
|
||||||
first-class **offerings**, with a **registration** step before final enrollment (see
|
enrollment (see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). This section covers the real, live
|
||||||
`docs/OFFERING_AND_REGISTRATION_MODEL.md`). The eventual intended flow:
|
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")
|
GET /api/public/offerings
|
||||||
-> Website collects registration info (student/guardian, readiness, agreements)
|
GET /api/public/offerings/:slug
|
||||||
-> 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**This is documentation of an intended future shape, not a live contract change.** The endpoint
|
No `X-RootNode-Integration-Key` needed — this is a public, read-only endpoint, safe to call
|
||||||
below is unaffected — it continues to work exactly as documented, program-slug-based, with no
|
directly from a browser. Only `published`/`enrollment_open`/`full`/`in_progress` offerings ever
|
||||||
concept of an offering. A future phase will either extend this endpoint with an optional
|
appear; `draft` and `cancelled` never do. Example response:
|
||||||
`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
|
```json
|
||||||
either way.
|
{
|
||||||
|
"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)
|
## 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.
|
||||||
+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-
|
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/
|
an-authorization-check verification, and confirmation that existing ROOT_ V1/self-paced/
|
||||||
classroom flows are unaffected.
|
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,
|
`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
|
Phase 6F's multi-student pilot and full end-to-end V1 journey, Phase 7/8's Winter Break Camp
|
||||||
and `docs/PHASE6F_ROOT_V1_PILOT_READINESS.md` for what they prove beyond what the e2e suites
|
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.
|
above already cover via direct API.
|
||||||
|
|
||||||
All e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
|
All e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
|
||||||
|
|||||||
Reference in New Issue
Block a user