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
|
||||
|
||||
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.
|
||||
+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
|
||||
|
||||
Reference in New Issue
Block a user