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:
2026-08-26 23:59:42 -05:00
co-authored by Claude Sonnet 5
parent aa63d09fb8
commit 788fb197e0
9 changed files with 573 additions and 35 deletions
+69
View File
@@ -0,0 +1,69 @@
# Offering Admin Guide
Phase 8 — how to actually run an offering from `/admin` → Offerings, end to end. Pairs with
`docs/OFFERING_AND_REGISTRATION_MODEL.md` (the underlying data model) and `docs/
WINTER_BREAK_CAMP_OPERATIONS.md` (the specific Winter Break Camp walkthrough).
## Creating an offering
**Offerings → Create offering.** Required: offering ID, URL slug, name, program, delivery mode.
Optional at creation: capacity (blank = unlimited), a location. Dates, the registration window,
organization, cohort, curriculum package, and agreement requirements are all set from the
offering's own detail page after creation — a new offering always starts `draft`, invisible to
the public offerings API and closed to registration, so there's no rush to fill in everything
before saving.
## The detail page
Opening an offering shows:
- **Overview** — status, seats, and the only status-transition buttons the offering can
actually take next (the ones the UI doesn't show are ones the server would reject anyway —
see `docs/OFFERING_AND_REGISTRATION_MODEL.md`'s transition diagram).
- **Launch readiness** — a live, server-computed checklist (curriculum, instructor, dates,
location, capacity, registration window, agreements configured). This is the same check the
server itself could use to gate opening registration — it is never just a frontend hint.
- **Required agreements** — which agreement definitions this offering requires before a
registration can convert to enrollment, with a form to add another.
- **Registrations / roster** — every registration against this offering, with a form to add one
directly (useful for phone/in-person sign-ups an instructor is entering by hand).
## Moving an offering through its lifecycle
`draft → published → enrollment_open → full/in_progress → completed`, or `→ cancelled` from
anywhere non-terminal. Only real, server-allowed transitions ever appear as buttons. `full` is
not automatic — an offering set to `enrollment_open` that happens to be at capacity will reject
new registrations with a clear error, but only moves to the visible `full` status when someone
explicitly sets it (or the very next registration attempt trips it) — check the readiness
panel/registration list to know why registration is currently unavailable.
## Working a registration
Open a registration from the offering's roster to see its own detail: registrant/guardian info,
agreement status (required vs. accepted vs. missing), lifecycle transition buttons, and its
communication history (every notification queued for it, with recipient always masked). Once a
registration is `confirmed` and every required agreement is accepted, **Convert to enrollment**
appears — this is the one action that creates the real student account (or matches an existing
one), the real academic enrollment, and real cohort membership if the offering has a cohort.
## Agreements
**Agreements** (reachable from an offering's "Required agreements" section) lets you define a
new agreement (title + a plain-language summary — never legal text, see `docs/
STUDENT_GUARDIAN_DATA_MODEL.md`) and associate it with any offering that needs it. A registration
cannot convert to enrollment while any required agreement is unaccepted.
## Guardians
A minor's registration always carries a linked guardian — create one from the registration form
when registering a minor by hand, or let the website integration create/match one automatically
from a real submission (see `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`). Guardian records are
admin/owner-only — a plain instructor's cohort access to a student's academic progress is
unchanged, but guardian contact detail is not part of that.
## What the admin UI deliberately does not do
No pricing/payment fields, no legal-document authoring, no bulk email composer — every
communication is a templated, transactional notification triggered by a real lifecycle event
(see `docs/REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md`), never a free-form message an admin
types and blasts out.
+30 -11
View File
@@ -1,9 +1,18 @@
# Offering & Registration Model
Phase 7 Steps 3/6/12-14/18-20 — the schema and lifecycle for offerings, capacity, registrations,
agreements, and curriculum packages. See `platform/api/migrations/034_phase7_offerings_
registrations_agreements.sql` for the exact DDL and `platform/api/src/offerings.js` for the
implementation.
Phase 7 Steps 3/6/12-14/18-20 (extended in Phase 8) — the schema and lifecycle for offerings,
capacity, registrations, agreements, and curriculum packages. See `platform/api/migrations/
034_phase7_offerings_registrations_agreements.sql` and `035_phase8_enrollment_operations.sql`
for the exact DDL and `platform/api/src/offerings.js` for the implementation.
**Updated for Phase 8**: registration is now server-authoritative (draft/cancelled/completed/
full/window-closed offerings reject registration attempts with distinct error codes — see
`assertRegistrationOpen()`); offerings can require specific agreements before a registration may
convert to enrollment (`offering_agreement_requirements`); `accepted_by_type` gained a
`'registrant'` value for an adult self-accepting before their student_id exists; the website
registration integration (`POST /api/integrations/registrations`) is live — see `docs/
WEBSITE_ENROLLMENT_INTEGRATION.md`; and launch readiness is now server-computed
(`computeOfferingReadiness()`), exposed at `GET /api/admin/offerings/:id/readiness`.
## Offering lifecycle (server-defined transitions only)
@@ -37,13 +46,23 @@ If zero rows come back, the offering is genuinely full — `409 OFFERING_FULL`.
row-level lock Postgres takes for the UPDATE's own duration makes two concurrent registration
attempts for the last seat serialize correctly, with exactly one succeeding, never both.
**A real ordering bug found and fixed during this phase's own testing**: the registration route
originally reserved the seat *before* validating the registration's own fields (e.g. a minor
registration missing a `guardianId`). Under a full offering, that let a plain validation error
get masked behind a false `OFFERING_FULL`, and even when the check passed, a request that would
later fail validation had already consumed a real seat. Fixed by creating (and validating) the
registration row first, then reserving the seat, rolling back (deleting) the registration row if
the seat reservation itself then fails — see `admin-offerings-routes.js`'s own comment.
**Two real ordering bugs found and fixed across Phase 7 and Phase 8, in opposite directions —
the current, final order is: validate fields → reserve the seat → insert the row → notify.**
Phase 7's own testing found the route originally reserved the seat *before* validating the
registration's fields (e.g. a minor missing `guardianId`), letting a plain validation error get
masked behind a false `OFFERING_FULL` under a full offering. Fixed by validating first.
Phase 8 then added a `registration_received` notification fired right after the registration row
was inserted — which briefly made the ORIGINAL Phase 7 fix's remaining shape (insert, then
reserve, then roll back the row by deleting it if the seat turned out to be unavailable) newly
unsafe: the notification row, created after insert but before the seat check, held a real
foreign key to the registration — so when reservation failed and the code tried to delete the
just-inserted registration row, that DELETE itself failed on the very foreign key the
notification had just created. Found via this phase's own regression testing, fixed by moving
seat reservation to happen BEFORE the insert (rolling the seat back, not a row, if the insert
itself then somehow fails) and only ever queuing the notification once the registration is fully,
durably committed — see `offerings.js`'s `createRegistration()` and its own header comment.
## Registration lifecycle
+189
View File
@@ -0,0 +1,189 @@
# Phase 8 — Enrollment Operations, Guardian Communications & Website Integration
Phase 8's mission: turn Phase 7's business/product backend into an OPERABLE enrollment system —
Offering → Registration → Guardian/Adult Contact → Agreements → Communications → Review →
Confirmation → Enrollment → Cohort → Account Activation → Classroom/Lab Access — without direct
database manipulation, ahead of RootNode's first commercial launch (Winter Break Camp). Not a
payment phase — everything is built around where a future payment step will plug in.
## Baseline
Confirmed at start: branch `main`, clean tree, HEAD = `origin/main` = Phase 7's
`df5e39f767a6af3ba50daabde5de97fa1b1a5f3b`.
## What was built
- **Migrations 035/036** — registration-stage notifications (nullable `student_id` +
`registration_id` on `notification_events`), a `'registrant'` agreement-acceptance type,
`offering_agreement_requirements`, a real unique index on `external_reference` (website
registration idempotency), and the notification_type CHECK widening (see "bugs found" below).
- **`offerings.js` extended**: `assertRegistrationOpen()` (server-authoritative registration
windows), `addAgreementRequirement`/`getRegistrationAgreementStatus` (required vs. accepted vs.
missing), a conversion-time block on missing required agreements, `computeOfferingReadiness()`
(server-derived launch checklist), and `createRegistrationFromIntegrationEvent()` (the
website's idempotent submission path, mirroring `enrollment.js`'s established pattern exactly).
- **`notifications.js` extended**: a guardian-email fallback in `resolveRecipient()`, and a
parallel `enqueueForRegistration()`/`resolveRegistrationRecipient()` pair for the pre-account
registration stage — same table, same worker, same masked admin view, no second system.
- **Five new templates** (`registrationReceived`, `registrationConfirmed`,
`registrationNeedsAction`, `agreementRequired`, `campOperationalUpdate`) — HTML + text,
RootNode-branded, no tracking, no marketing content.
- **`public-offerings-routes.js`** (new) — the unauthenticated, explicitly-allowlisted offering
read API the website will consume.
- **`POST /api/integrations/registrations`** (new, in the existing `integration-routes.js`) —
the website's registration submission contract, same shared-secret boundary as
`/api/integrations/enrollments`, fully idempotent.
- **Admin UI**: a real `/admin` → Offerings section (`OfferingsView.jsx`) — list, create, detail
(overview with valid-transitions-only buttons, launch readiness, required agreements,
registrations/roster) and registration detail (lifecycle, agreement status, communication
history, convert-to-enrollment). Reuses the existing `Admin.module.css`/`adminApi` conventions
exactly — no new UI framework or pattern introduced.
## Two real bugs found and fixed during this phase's own testing (before any commit)
1. **`notification_type` CHECK constraint mismatch** — the five new registration-stage
notification types were wired into `notifications.js`/`notification-worker.js` before the
database's own CHECK constraint was widened to accept them. Every `enqueueForRegistration()`
call was silently failing (caught by the deliberate "a notification failure never blocks the
real action" try/catch) until a comprehensive smoke test surfaced it directly. Fixed in
`036_phase8_notification_types.sql`. Also hardened: every previously-silent
`.catch(() => {})` around a notification call now at least logs the error, so a future
regression like this surfaces immediately instead of silently vanishing.
2. **A registration-insert/seat-reservation/notification ordering bug** — adding a
`registration_received` notification right after the registration row's insert (but before
the seat reservation that could still fail and roll the row back) meant a failed reservation's
cleanup DELETE could hit a foreign-key violation against the notification row it had just
created. Found via `e2e-phase7.js`'s own regression rerun. Fixed by reordering
`createRegistration()` to validate → reserve the seat → insert → notify, strictly in that
order, and simplifying the admin route accordingly (see `docs/OFFERING_AND_REGISTRATION_
MODEL.md` for the full before/after).
## Cascading regression fixes (stale test assumptions, not new bugs)
Enforcing real registration-window checks (Step 21) broke `e2e-phase7.js`'s own `offeringB`
(created in Phase 7, never transitioned out of `draft`, registered against successfully only
because no such enforcement existed yet) and a new age-guidance offering created the same way.
Fixed by publishing/opening both before registering, matching the new, correct requirement. A
third `e2e-phase7.js` test's own file-allowlist check needed widening to permit the new, entirely
legitimate `public-offerings-routes.js` reference to age-guidance fields (display-only, never
authorization).
## Testing
`platform/test/e2e-phase8.js` — 16/16 passing (fresh stack): offering CRUD/transitions/
readiness, registration open/closed enforcement, capacity + concurrent final-seat race through
the real registration endpoints, the public serializer's safety (no internal/PII fields), website
integration idempotency (sequential replay + concurrent duplicate), adult/minor registration
paths, student-identity preservation on conversion, required-agreement conversion blocking with
version snapshotting even after the live definition's version later changes, offering→cohort
membership, a real activation token + queued notification on conversion, guardian recipient
resolution, RBAC (offering/location visible to any instructor, guardian/registration detail
admin-only), organization/location `access_notes` stripping, and confirmation ROOT_ V1 self-paced
and classroom flows are unaffected.
## Winter Break commercial-path rehearsal
`infra/scripts/drills/test-phase8-winter-camp-registration.js` — 12/12 passing against a real,
freshly-built stack, with no payment code anywhere: admin configures a real offering (location,
capacity, instructor, required agreement), the website integration submits a REAL minor
registration (a real guardian created/matched, a confirmation notification queued), guardian
acceptance is recorded through the supported application flow, a future payment confirmation is
SIMULATED via a plain admin transition, the registration converts to a real enrollment and real
cohort membership, the account activates with a real one-time token, an adult registers and
converts independently, two students genuinely compete for the final seat (both correctly
rejected once capacity was already exhausted — no overbooking), the instructor runs a real
classroom session and Prepares Labs, and the minor student completes real ROOT 01 orientation
plus a REAL Lab task (`linux-inspect-hardware`) inside a real disposable Lab container, with the
instructor then seeing the real progress.
## Regression
Fresh-stack reruns after every fix: `e2e-phase7.js` 17/17, `e2e-phase6f.js` 17/17, `e2e-phase5b.js`
32/32 (enrollment.js's `resolveOrCreateStudent` gained an optional, default-off `returnToken`
param in Phase 7, re-verified unaffected here), `e2e-phase5c.js` 29/29 (notifications.js was
directly extended this phase — every existing Phase 5C notification type/flow reconfirmed
byte-for-byte unaffected). `e2e-phase5a.js` not independently rerun: its own checks are embedded
in and reconfirmed by both 5B's and 5C's "Phase 5A ... still works unmodified" assertions, and no
code Phase 5A itself owns was touched.
## Frontend build
`npm run build` clean — the new `OfferingsView.jsx` compiles without warning; the only new
warning is the pre-existing "chunk larger than 500kB" advisory, unchanged in cause from every
prior phase.
## Real Lab result
Both rehearsals (Phase 7's from the prior phase, re-verified here, and this phase's own) drive a
real disposable Lab container through a genuine `linux-inspect-hardware` scenario setup/validate
cycle — confirmed passing for real, not simulated, in the Winter Break commercial-path rehearsal.
## Security/privacy findings
The public offerings API was built with an explicit field allowlist from the start (never
`SELECT *`) — `e2e-phase8.js` asserts directly that `access_notes`, `organization_id`,
`location_id`, `created_by`, `reserved_seats`, `capacity`, and `metadata` never appear in a
public response, and that a fully-booked offering reports `"full"` rather than a raw seat count
(avoiding a scraping/timing vector against a public, unauthenticated endpoint). No new security
weakening was introduced; the two bugs found above were both silent-failure/ordering bugs, not
authorization gaps.
## Legal-review requirements — unchanged, restated
See `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s list in full, still entirely unresolved by this
phase's work: the actual legal text of any participation/media/liability agreement,
jurisdiction-specific minor-consent requirements, data-retention/deletion policy, and whether
emergency-contact fields need additional encryption at rest. `agreement_definitions.body_summary`
remains a plain-language placeholder, never legal text.
## Documentation
New: `PHASE8_ENROLLMENT_OPERATIONS.md` (this document), `WINTER_BREAK_CAMP_OPERATIONS.md`,
`REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md`, `OFFERING_ADMIN_GUIDE.md`. Updated:
`ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md` (BUILT NOW/PHASE 8 BUILT/FUTURE table),
`OFFERING_AND_REGISTRATION_MODEL.md` (the two ordering bugs, launch readiness, registration
enforcement), `STUDENT_GUARDIAN_DATA_MODEL.md` (guardian communications now wired),
`WEBSITE_ENROLLMENT_INTEGRATION.md` (the real, live registration contract with worked examples),
`platform/README.md` (test list). Reviewed, no change needed: `docs/ENGINEERING_SOP.md`,
`docs/LAB_ARCHITECTURE.md`, `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md`,
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` (the existing provider adapter needed no changes — the
new templates render through the exact same delivery path).
## Known limitations
- The website's own registration UI does not exist yet in this repository (rootnodeacademy.com
is built separately) — the contract is ready and tested, nobody has built the website-side
form against it yet.
- Agreement acceptance is not exposed on the website registration endpoint itself — currently a
separate admin-recorded (or future self-service) step after submission.
- `registration_needs_action` has no automatic trigger — an admin action, deliberately, since
"needs action" covers many distinct real reasons an admin should state explicitly rather than
the platform guessing one.
- No waitlist was built — a `waitlisted` registration status was considered and deliberately
deferred; the existing `cancelled`-releases-a-seat mechanism plus manual re-registration covers
the real, near-term operational need without adding a new status machine branch this phase
didn't have concrete evidence would be used.
- No payment processing — every "payment confirmed" step in both rehearsals is a plain admin
transition, exactly as authorized.
## Remaining blockers before opening Winter Break registration to the public
1. Real dates, venue, capacity, and price are still undecided (not invented by this phase).
2. The agreement's actual legal text requires professional review.
3. No payment processing exists yet.
4. The website's own registration form doesn't exist yet.
None of these are platform readiness gaps — the platform is ready to operate the flow the moment
those four external decisions/assets exist.
## Commits
See `git log` for this phase's exact commit sequence (migrations/backend, admin UI, tests +
rehearsal, docs) — grouped per `docs/ENGINEERING_SOP.md`'s existing convention.
## Recommended Phase 9
Real payment integration (Stripe or equivalent) wired into the registration-confirmation step;
the actual website-side registration form; agreement-acceptance exposed on the public
registration contract once the legal text is finalized; and, informed by real Winter Break Camp
operational experience, whether a waitlist is actually worth building.
@@ -0,0 +1,62 @@
# Registration & Guardian Communications
Phase 8 §8-§11 — how registration-stage notifications extend the existing Phase 5C notification
system. No second email subsystem was built: same `notification_events` table, same durable
worker (`notification-worker.js`), same Google Workspace-backed provider adapter
(`email/provider.js`), same masked-recipient admin API.
## What's new vs. Phase 5C
`notification_events.student_id` is now nullable, and a `registration_id` column was added (a
CHECK constraint guarantees every row still has a real subject) — because a registration-stage
notification (Registration Received, Agreement Required) may need to go out before any student
account exists at all, for a brand-new guardian-submitted registration. `notification_type`'s
CHECK constraint was widened to include five new types. See `033_phase8_...sql` and
`036_phase8_notification_types.sql`.
**A real bug found and fixed during this phase's own testing**: the new notification types were
added to `notifications.js`/`notification-worker.js` before the database CHECK constraint was
widened to accept them — every registration-stage `enqueue` call was silently failing (caught by
the deliberate "a notification failure never blocks the real action" try/catch) until this was
caught and fixed. See `docs/PHASE8_ENROLLMENT_OPERATIONS.md`.
## The five new templates
1. **Registration Received** — queued the moment a registration is created (website or admin).
2. **Registration Confirmed** — queued when a registration moves to `confirmed`.
3. **Registration Needs Action** — available for an admin/future-flow to queue with a reason
when a registration needs something before it can proceed (not automatically triggered by
any single lifecycle transition this phase — a deliberate choice, since "needs action" covers
many different real reasons an admin should state explicitly).
4. **Agreement Required** — queued once per missing required agreement when a registration
enters `agreements_pending`.
5. **Camp/Offering Operational Update** — a deliberately generic operational notice (e.g. "camp
starts Monday, here's what to bring"), NOT a marketing template — no promotional content, no
discount codes, no unrelated announcements. Available for an admin to trigger manually;
nothing queues it automatically.
All five are HTML + plain-text, RootNode-branded, no tracking pixels, no external images, no
passwords or sensitive data — same `templates.js` conventions Phase 5C established.
## Recipient resolution — extended, never overwritten
`resolveRecipient()`'s existing order (student's own email → the enrollment's
`metadata.contact_email`) now has a third, final fallback: a real, communications-enabled
primary guardian's email (`student_guardians`/`guardians`). This is purely additive — a student
who already resolves correctly today is completely unaffected, since the new fallback is only
ever reached when the first two return nothing. `enrollmentContactEmail` (Phase 5C, unchanged)
and this new guardian fallback deliberately coexist: `contact_email` remains a per-enrollment
override for the live website integration's existing contract, while the guardian relationship
is the durable, cross-enrollment identity — prefer the guardian model going forward, but the
transitional field is never broken.
For registration-stage notifications (no student yet, possibly), a separate
`resolveRegistrationRecipient()` tries: the registration's own student (if already converted) →
its linked guardian → the registrant's own supplied email.
## What was deliberately NOT built
No bulk/marketing email composer, no free-form admin-authored message sent to an arbitrary
recipient — every notification is one of the fixed templates above, triggered by a real
lifecycle event or an explicit, scoped admin action (never a raw "compose email" form). No SMS.
No third notification channel beyond email.
@@ -137,7 +137,10 @@ planned this phase.
| Registrations, registration lifecycle | PHASE 7 BUILT |
| Agreement definitions/acceptances (record-only, no legal text) | PHASE 7 BUILT |
| Admin routes for all of the above | PHASE 7 BUILT |
| Admin UI (Offerings/Registrations screens) | **NOT BUILT this phase** — see known limitations |
| Public registration flow, payments, subscriptions | FUTURE |
| Admin UI (Offerings/Registrations screens) | **PHASE 8 BUILT** — `/admin` → Offerings, with launch readiness, roster, agreements, and per-registration detail (communications, agreement status). |
| Guardian communications (5 new notification templates, guardian recipient fallback) | PHASE 8 BUILT |
| Public offering read API + website registration submission contract | PHASE 8 BUILT (`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) |
| Offering agreement requirements + conversion blocking | PHASE 8 BUILT |
| Public registration flow (a real website-side form), payments, subscriptions | FUTURE |
| INIT_/COMPILE_ curriculum | FUTURE |
| School/library SIS integrations, parent portal | FUTURE, explicitly out of scope |
+4 -2
View File
@@ -23,8 +23,10 @@ guardian is a real relationship, not a workaround field.
detailed learning analytics by default. A guardian relationship is a contact/authorization
record, not a monitoring grant.
- **No automatic broad access.** `student_guardians.can_receive_communications` and
`is_primary_contact` govern notification targeting only (a future concern — this phase writes
the rows but does not wire them into `notifications.js`'s resolveRecipient yet), never
`is_primary_contact` govern notification targeting only — wired into `notifications.js`'s
`resolveRecipient()` as of Phase 8 (a communications-enabled primary guardian is the LAST
fallback, tried only after the student's own email and any enrollment-level `contact_email` —
see `docs/REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md`), never
authorization to student data.
## Data minimization
+131 -18
View File
@@ -179,29 +179,142 @@ a first-class concept in this endpoint, talk to the RootNode team before buildin
workaround; the schema now has the real tables for it, they're just not wired into this
endpoint yet.
## Future flow: offerings and registrations (Phase 7, not yet live on this endpoint)
## Offerings and registrations (Phase 8 — live)
RootNode Academy is now (rootnodeacademy.com, live separately from this repository) building
toward representing specific purchasable deliveries — camps, cohorts, self-paced access — as
first-class **offerings**, with a **registration** step before final enrollment (see
`docs/OFFERING_AND_REGISTRATION_MODEL.md`). The eventual intended flow:
As of Phase 8, RootNode represents specific purchasable deliveries — camps, cohorts, self-paced
access — as first-class **offerings**, with a **registration** step before final academic
enrollment (see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). This section covers the real, live
contract; the endpoint documented above (`/api/integrations/enrollments`, direct program
enrollment) is completely unaffected and still works exactly as described.
### 1. List/view offerings (public, no auth)
```
Website: learner selects an offering (e.g. "Winter Break Linux & Systems Camp")
-> Website collects registration info (student/guardian, readiness, agreements)
-> Website checkout/payment (future — not built yet)
-> Verified commerce event -> RootNode confirms the registration
-> RootNode activates the enrollment (reusing this exact endpoint's machinery)
-> Student receives their existing activation email, unchanged
-> Student/camp access begins
GET /api/public/offerings
GET /api/public/offerings/:slug
```
**This is documentation of an intended future shape, not a live contract change.** The endpoint
below is unaffected — it continues to work exactly as documented, program-slug-based, with no
concept of an offering. A future phase will either extend this endpoint with an optional
`offering_slug` field or add a parallel endpoint once the website's actual checkout flow is
designed; that decision has not been made. Idempotency (`event_id`) will remain the mechanism
either way.
No `X-RootNode-Integration-Key` needed — this is a public, read-only endpoint, safe to call
directly from a browser. Only `published`/`enrollment_open`/`full`/`in_progress` offerings ever
appear; `draft` and `cancelled` never do. Example response:
```json
{
"slug": "winter-linux-systems-camp-2026",
"name": "Winter Break Linux & Systems Foundations Camp",
"description": "A hands-on introduction to Linux administration and networking.",
"program_id": "root-kubernetes-foundations",
"delivery_mode": "camp",
"recommended_min_age": 13,
"recommended_max_age": null,
"starts_at": "2026-12-15T14:00:00.000Z",
"ends_at": "2026-12-19T18:00:00.000Z",
"registration_open": true,
"availability": "limited",
"location_name": "Community Room B",
"location_type": "community_center"
}
```
`availability` is `"open"` / `"limited"` / `"full"` — never a raw seat count (avoids inviting
scraping/timing games against a public endpoint). No internal notes, no organization contact
info, no instructor identity, no pricing — the website owns pricing display entirely; this
endpoint doesn't return it.
### 2. Select an offering, then submit a registration (server-to-server)
```
POST /api/integrations/registrations
Header: X-RootNode-Integration-Key: <the same secret /api/integrations/enrollments already uses>
```
Same trust boundary as the existing enrollment endpoint (Step 15) — server-to-server only, never
called from browser JS, the secret never leaves your backend's own configuration.
**Adult example request:**
```json
{
"event_id": "reg_9f8e7d6c5b4a",
"source": "website",
"offering_slug": "winter-linux-systems-camp-2026",
"registrant": { "display_name": "Jamie Rivera", "email": "[email protected]", "is_minor": false }
}
```
**Minor/guardian example request:**
```json
{
"event_id": "reg_1a2b3c4d5e6f",
"source": "website",
"offering_slug": "winter-linux-systems-camp-2026",
"registrant": { "display_name": "Riley Rivera", "is_minor": true },
"guardian": { "display_name": "Jamie Rivera", "email": "[email protected]", "phone": "555-0100" }
}
```
**Success response** (`201` new, `200` idempotent replay):
```json
{ "registration_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "offering_id": "winter-linux-systems-camp-2026", "status": "started", "created": true }
```
**Errors**: `401 unauthorized` (bad/missing key), `400` (`offering_slug`/`event_id`/registrant
fields missing, or a minor with no guardian email), `404 OFFERING_NOT_FOUND`, `409` for
`OFFERING_NOT_OPEN` / `OFFERING_CANCELLED` / `OFFERING_COMPLETED` / `OFFERING_FULL` /
`REGISTRATION_NOT_YET_OPEN` / `REGISTRATION_CLOSED`.
### 3. Duplicate/retry semantics (idempotent, DB-enforced)
Identical to `/api/integrations/enrollments`: send the exact same `event_id` again (a retried
webhook, a timed-out request retried by your own backend) and you get back the **same
registration**, `created: false`, never a duplicate, never a second seat reserved — enforced by
a real unique index on `event_id`, safe under concurrent/parallel retries too. Never generate a
new `event_id` for a retry of the same real-world submission.
### 4. Adult flow
Send `registrant.email`, omit `guardian` entirely, `is_minor: false` (or omit it). The adult IS
both the eventual student and the registration contact — no guardian record is created.
### 5. Minor/guardian flow
Send `registrant.is_minor: true` and a real `guardian` object (`display_name` + `email`
required; `phone` optional). RootNode matches an existing guardian by email first (a returning
family doesn't get a duplicate guardian record per sibling/per camp) or creates a new one. The
guardian's email is never written to the eventual student's own identity record.
### 6. Agreement flow
Not yet exposed on this endpoint — an agreement acceptance is currently recorded by RootNode
staff (or a future self-service step) after the registration is submitted, before it can be
confirmed. If your registration flow collects agreement acceptance at submission time, talk to
the RootNode team about extending this contract before building around a workaround.
### 7. Future payment step
Not built. Today, a registration is manually moved to `confirmed` by RootNode staff once payment
is verified through whatever process exists outside the platform. A future phase's real payment
integration would do this automatically on a verified commerce event.
### 8. Confirmation
Once `confirmed` (manually today, automatically in a future payment-integrated phase) and every
required agreement is accepted, the registration can convert to a real enrollment.
### 9. Enrollment activation
Conversion creates (or matches) the real student account and a real academic enrollment, reusing
the exact same activation-email machinery `/api/integrations/enrollments` already triggers — no
second activation path exists.
### 10. What this endpoint does NOT do
No payment-card data (never sent, never has a field for it). No password (RootNode issues its
own credential/activation flow). No arbitrary registration status (server-defined transitions
only — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). All example data above is fake — do not
reuse these values against a real deployment.
## What the student experiences after you call this (as of Phase 5C)
+74
View File
@@ -0,0 +1,74 @@
# Winter Break Camp Operations
Phase 8 §29 — how to actually run the Winter Break Linux & Systems Foundations Camp
(`docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md` Option A) using the platform built in Phases
7-8, end to end, entirely from `/admin`. No payment step exists yet — this document covers
everything up to and including the point where a real payment/checkout would plug in.
## What already exists vs. what Chase still fills in
**Already representable, real, tested:**
- The curriculum package (ROOT 01-02, referencing the real, unmodified courses).
- The offering itself, in DRAFT, with delivery mode `camp`.
- Registration → guardian/adult → agreement → (simulated) confirmation → enrollment → cohort →
activation → classroom → real Lab work, proven end to end in
`infra/scripts/drills/test-phase8-winter-camp-registration.js` (12/12 passing).
**Still genuinely unknown, deliberately left editable, never invented by this phase:**
- Exact dates, venue, real capacity, and price.
- The actual agreement text (a plain-language placeholder only — see `docs/
STUDENT_GUARDIAN_DATA_MODEL.md`'s legal-review list).
- Whether registration opens before or after a specific marketing push.
## The operational sequence
1. **Create the location** — `/admin` → Offerings → (via the offering create form's location
dropdown) or directly if a location admin surface is added later; today, an admin/owner uses
`POST /api/admin/locations` (documented in `docs/OFFERING_ADMIN_GUIDE.md`) once the real venue
is known.
2. **Create the curriculum package** — `winter-linux-systems-v1`, referencing
`root-01-computing-linux-foundations` and `root-02-linux-administration-networking` in
sequence. This step is a one-time setup — the same package can back every future Winter Break
Camp offering.
3. **Create the cohort** and **the offering** — delivery mode `camp`, referencing the program,
the curriculum package, and (once known) the location and capacity.
4. **Assign the lead instructor** — both at the offering level (`instructor_offerings`, Phase 7)
and the cohort level (`cohort_instructors`, unchanged since Phase 2A) — the offering detail
page's own instructor step, or the two admin routes directly.
5. **Configure the required agreement(s)** — create the agreement definition (title + plain-
language summary) and link it to the offering. **The actual legal text still needs
professional review before this camp is opened to real families** — see below.
6. **Check launch readiness** — the offering detail page's readiness panel should show every
check green before publishing. If instructor/curriculum/agreements are configured but dates/
location/capacity aren't final yet, readiness will honestly show that.
7. **Publish, then open registration** — `draft → published → enrollment_open`. Only once
`enrollment_open` does the offering appear in the public offerings API and accept real
registrations.
8. **Registrations arrive** — via the website integration (once wired — see
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`'s documented future flow) or entered directly by an
admin for a phone/in-person sign-up.
9. **Guardian/adult completes the agreement** — recorded via the registration's own agreement
acceptance action.
10. **Payment step (future, not built)** — this is the one step this phase does not implement.
Today, an admin manually transitions a registration to `confirmed` once payment is verified
by whatever process exists outside the platform; a future phase's real payment integration
would do this automatically on a verified commerce event, the same shape
`/api/integrations/enrollments` already uses for direct program enrollment.
11. **Convert to enrollment** — creates the real student account (or matches an existing one),
the real academic enrollment, and real cohort membership.
12. **Account activation** — the student's (or, for a minor, the guardian's) real one-time
activation token, delivered the same way every other RootNode activation email already is.
13. **Classroom day** — Prepare Labs for the cohort, start the classroom session, students join
and work through real ROOT 01-02 content and Labs.
14. **Offering completion** — `in_progress → completed` once the camp concludes.
## Known blockers before this can actually open to the public
- Real dates, venue, capacity, and price are not yet decided (this document does not invent
them).
- The agreement text requires professional legal review (see `docs/
STUDENT_GUARDIAN_DATA_MODEL.md`).
- No payment processing exists — step 10 above is manual until a future phase builds it.
- The website's own registration UI does not exist yet in this repository (rootnodeacademy.com
is built separately) — the integration contract it needs is ready
(`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`), but nobody has built the website-side form yet.