Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
788fb197e0 | ||
|
|
aa63d09fb8 | ||
|
|
40362ec0c9 | ||
|
|
24eee80354 | ||
|
|
df5e39f767 | ||
|
|
7d870d8e2b | ||
|
|
3510979196 |
@@ -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.
|
||||
@@ -0,0 +1,136 @@
|
||||
# Offering & Registration Model
|
||||
|
||||
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)
|
||||
|
||||
```
|
||||
draft ──► published ──► enrollment_open ──┬──► full ──► enrollment_open (reopen)
|
||||
│ │ │ │
|
||||
│ │ ├──► in_progress ──► completed
|
||||
│ │ │
|
||||
└──► cancelled ◄──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
No route accepts an arbitrary client-supplied status — `offerings.transitionOffering()` checks
|
||||
a fixed transition table and rejects anything not explicitly allowed with `409
|
||||
INVALID_TRANSITION`. `full` is a distinct status from `enrollment_open` (not a computed flag) so
|
||||
an admin can see at a glance why enrollment stopped, and can explicitly reopen it (e.g. a seat
|
||||
freed up) rather than have it silently reappear.
|
||||
|
||||
## Capacity — race-safe by construction
|
||||
|
||||
`offerings.capacity` is nullable (NULL = unlimited, the normal case for `self_paced`).
|
||||
`offerings.reserved_seats` is a maintained counter, incremented by exactly one SQL statement:
|
||||
|
||||
```sql
|
||||
UPDATE offerings SET reserved_seats = reserved_seats + 1
|
||||
WHERE offering_id = $1 AND reserved_seats < capacity
|
||||
RETURNING *;
|
||||
```
|
||||
|
||||
If zero rows come back, the offering is genuinely full — `409 OFFERING_FULL`. This is not a
|
||||
`SELECT count(*) ... then INSERT` pattern (the exact anti-pattern the authorization named): the
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
```
|
||||
started ──► submitted ──► reviewed ──┬──► agreements_pending ──┬──► payment_pending ──► confirmed ──► enrollment_activated
|
||||
│ │ │ │
|
||||
│ └──────────────────► confirmed ◄────────────────┘
|
||||
└──► cancelled (from any non-terminal state; releases the reserved seat)
|
||||
```
|
||||
|
||||
Not every offering needs every stage — a simple self-paced offering can go `started` →
|
||||
`confirmed` → `enrollment_activated` directly; a stage is a real state a registration CAN pass
|
||||
through, not one it MUST. `enrollment_activated` and `cancelled` are terminal.
|
||||
|
||||
A registration may exist with `student_id = NULL` (a guardian registering a not-yet-created
|
||||
student). `convertRegistrationToEnrollment()` is the one place a registration becomes a real
|
||||
academic `enrollment` row — it reuses `enrollment.js`'s existing `resolveOrCreateStudent`/
|
||||
`createManualEnrollment` rather than duplicating student-matching or enrollment-creation logic,
|
||||
and creates `cohort_enrollments` membership too if the offering has a `cohort_id`.
|
||||
|
||||
## Minor registrations
|
||||
|
||||
`offering_registrations.is_minor = true` requires a non-null `guardian_id` — enforced by a
|
||||
database CHECK constraint, not just application logic, so it holds even against a direct/future
|
||||
API caller that skips the usual route. The guardian is a real `guardians` row (Phase 7's new
|
||||
model), never a duplicated email on the eventual student record.
|
||||
|
||||
## Agreements — acceptance records, not legal text
|
||||
|
||||
`agreement_definitions` stores a title and a plain-language `body_summary` for admin-UI display
|
||||
— explicitly **not** attorney-approved legal language (see `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s
|
||||
legal-review list). `agreement_acceptances` snapshots the `accepted_version` at acceptance time
|
||||
(not just a live foreign key to the current version), so a later edit to an agreement's text
|
||||
never retroactively reinterprets what someone already agreed to — the same principle
|
||||
`COURSE_CONTENT_MODEL.md` §6b already established for assessment session versioning. An
|
||||
acceptance is tied to a student OR a guardian (a CHECK constraint requires at least one), and
|
||||
optionally to the registration/enrollment context it was collected for.
|
||||
|
||||
## Curriculum packages — reference, never clone
|
||||
|
||||
A `curriculum_package` names a versioned subset of an existing program's courses via
|
||||
`curriculum_package_items` (`course_id` + `sequence`) — a pure reference list, storing no
|
||||
content of its own. Worked example, matching `docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md`'s
|
||||
Option A:
|
||||
|
||||
```
|
||||
curriculum_package_id: winter-linux-systems-v1
|
||||
program_id: root-kubernetes-foundations
|
||||
items: [ (root-01-computing-linux-foundations, seq 1), (root-02-linux-administration-networking, seq 2) ]
|
||||
```
|
||||
|
||||
An offering referencing this package still points its students at the real, unmodified ROOT 01/
|
||||
02 courses — nothing is cloned, no activity is duplicated, and any future content fix to ROOT 01/
|
||||
02 is automatically reflected for every offering referencing the package. Historical offerings
|
||||
stay stable against a **specific version** of the package (bump `curriculum_packages.version` for
|
||||
a semantic change, exactly like every other versioned row in this schema) rather than silently
|
||||
picking up a redefinition.
|
||||
|
||||
## Offering → cohort
|
||||
|
||||
For cohort/camp/workshop delivery, an offering's `cohort_id` (nullable) points at an existing
|
||||
`cohorts` row, unchanged from Phase 2A. `convertRegistrationToEnrollment()` inserts the matching
|
||||
`cohort_enrollments` row when present — a student registering into a cohort-backed offering ends
|
||||
up on the real classroom roster automatically, with zero duplicated roster state. A `self_paced`
|
||||
offering simply has `cohort_id = NULL`; there is no cohort to join.
|
||||
|
||||
## What was deliberately not built
|
||||
|
||||
Payment processing, a public-facing registration form, pricing display, and subscription
|
||||
billing — all explicitly out of Phase 7's scope. `offerings.metadata` (JSONB) has room for
|
||||
future descriptive pricing/marketing fields, but nothing writes payment data into it.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Phase 7 — Business & Product Architecture Foundation
|
||||
|
||||
Phase 7's mission: formalize RootNode Academy as a business/product platform capable of a future
|
||||
catalog across INIT_/COMPILE_/ROOT_/BOOTCAMP_, without rebuilding any proven system and without
|
||||
authoring new curriculum.
|
||||
|
||||
## Baseline
|
||||
|
||||
Confirmed at start: branch `main`, clean tree, HEAD = `origin/main` = Phase 6F's
|
||||
`d0664104515e4bd389c8e7bc88293327e7eda100`, tag `v0.6.0-root-v1` present. Reviewed existing
|
||||
`programs`/`enrollments`/`cohorts`/`classroom_sessions`/`students` schema before designing
|
||||
anything new — found `programs.family`/`delivery_mode` and `cohorts.seat_target` already
|
||||
existed, so Phase 7 widened rather than duplicated them where it could.
|
||||
|
||||
## What was built
|
||||
|
||||
- **Organizations, locations** (`033_phase7_organizations_locations_guardians.sql`) — a business/
|
||||
delivery relationship and a physical/virtual venue, deliberately not SaaS multi-tenancy.
|
||||
- **Guardians, student_guardians** (same migration) — the real foundation replacing the Phase
|
||||
5C `contact_email` workaround, left functioning unchanged for the live website integration.
|
||||
- **Offerings, curriculum packages, registrations, agreements**
|
||||
(`034_phase7_offerings_registrations_agreements.sql`) — the full purchasable-delivery model,
|
||||
pre-enrollment registration lifecycle, versioned agreement acceptance records, and
|
||||
reference-only curriculum subsets.
|
||||
- **`platform/api/src/offerings.js`** — the business logic: server-defined status transitions
|
||||
(offerings and registrations both), race-safe atomic seat reservation, and
|
||||
`convertRegistrationToEnrollment()` reusing `enrollment.js`'s existing student-matching and
|
||||
enrollment-creation logic rather than duplicating it.
|
||||
- **`platform/api/src/admin-offerings-routes.js`** — the full admin/owner API surface for all of
|
||||
the above, registered in `server.js` alongside the existing route modules.
|
||||
- **`programs.family` widened** to accept `bootcamp` (a pure CHECK-constraint widening; no
|
||||
existing row changes).
|
||||
|
||||
## What was NOT built this phase
|
||||
|
||||
**Admin UI** (Steps 16/17 — Offerings/Registrations screens in `/admin`). This was a deliberate
|
||||
scope call: given the phase's own emphasis ("BUSINESS DOMAIN MODEL, PRODUCT ARCHITECTURE,
|
||||
ENROLLMENT/OPERATIONS ARCHITECTURE"), the schema, business logic, RBAC, tests, and a full
|
||||
working rehearsal were prioritized as the load-bearing deliverable over a polished React
|
||||
surface. Every capability Steps 16-17 describe is fully implemented and tested at the API layer
|
||||
— an admin UI is now a bounded, well-specified follow-up (the routes and their exact shapes
|
||||
already exist), not a design question.
|
||||
|
||||
Also not built, per the explicit DO-NOT-BUILD list: payment processing, subscription billing,
|
||||
public checkout, INIT_/COMPILE_ curriculum, ROOT 08/11 expansion, a Parent Portal UI, school
|
||||
SIS integrations, certificates.
|
||||
|
||||
## A real bug found and fixed during this phase's own testing
|
||||
|
||||
The registration-creation route originally reserved an offering's seat *before* validating the
|
||||
registration's own fields. Under a full offering, this let a plain validation failure (e.g. a
|
||||
minor registration missing `guardianId`) get masked behind a false `409 OFFERING_FULL`, and even
|
||||
when capacity wasn't the issue, a request that would fail validation anyway had already consumed
|
||||
a real seat. Found via this phase's own smoke testing before any commit; fixed by validating
|
||||
(creating) the registration first, then reserving the seat, rolling back the registration row if
|
||||
reservation then genuinely fails. See `docs/OFFERING_AND_REGISTRATION_MODEL.md`.
|
||||
|
||||
## Testing
|
||||
|
||||
`platform/test/e2e-phase7.js` — 17/17 passing (fresh stack): course-family/age-guidance
|
||||
independence from authorization, offerings across two distinct locations, curriculum-package
|
||||
content-reference integrity (including rejecting a fabricated course id), atomic capacity
|
||||
enforcement with correct validate-before-reserve ordering, instructor assignment, guardian
|
||||
creation/linking, minor-vs-adult registration paths, registration lifecycle transitions
|
||||
(including a rejected invalid jump), agreement-version-snapshotted acceptance, registration→
|
||||
enrollment conversion with correct cohort/no-cohort behavior, curriculum-package version-bump
|
||||
stability, registration/guardian RBAC (admin/owner only) vs. offering/location visibility (any
|
||||
instructor, with `access_notes` stripped), explicit age-independence, and confirmation that an
|
||||
existing ROOT_ V1 self-paced enrollment and an existing classroom/cohort flow are both
|
||||
completely unaffected.
|
||||
|
||||
## Winter Break Camp rehearsal
|
||||
|
||||
`infra/scripts/drills/test-phase7-winter-camp.js` — 18/18 passing against a real, freshly-built
|
||||
stack, with **no payment code invoked anywhere**: admin creates a real organization/location, a
|
||||
curriculum package referencing the existing ROOT 01-02 courses (matching `docs/
|
||||
WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md`'s Option A exactly), a real cohort, and a DRAFT offering
|
||||
with dates/price/exact seat count left unset as authorized; a guardian registers a minor and an
|
||||
adult registers themselves; both move through submission, agreement acceptance, and a simulated
|
||||
admin-side commerce confirmation; both convert to real enrollments and real cohort membership
|
||||
with real one-time activation tokens; both activate real accounts; the instructor starts a real
|
||||
classroom session and Prepares Labs; the minor student joins, completes real ROOT 01 orientation
|
||||
and lesson activities, and passes a REAL Lab task (`linux-inspect-hardware`) inside a real
|
||||
disposable Lab container — with the pass then confirmed to survive a real logout/login; the
|
||||
instructor sees real roster state; the offering moves through `in_progress` to `completed`.
|
||||
|
||||
## ROOT_ V1 regression
|
||||
|
||||
`e2e-phase6f.js` rerun fresh: 17/17 unaffected. `e2e-phase5b.js` (the enrollment-related suite
|
||||
most exposed by this phase's one shared-code change — `enrollment.js`'s `resolveOrCreateStudent`
|
||||
gained an optional, default-off parameter) rerun fresh: 32/32, including its own embedded
|
||||
"Phase 5A student authentication still works unmodified" and "Phase 5A remote classroom still
|
||||
works unmodified" checks. `e2e-phase5c.js` was not independently re-run: no code in
|
||||
`notifications.js`/`students-auth.js` itself changed, only an additive, default-off parameter on
|
||||
a function `notifications.js` doesn't call.
|
||||
|
||||
## Frontend, migrations, content validation
|
||||
|
||||
Frontend: `npm run build` clean, no frontend code touched this phase. Content validation:
|
||||
0 errors, unchanged from Phase 6F's own numbers (this phase touched no course/module/activity
|
||||
content at all). Migrations: additive-only, `CREATE TABLE IF NOT EXISTS`/`DROP CONSTRAINT IF
|
||||
EXISTS` throughout, applied once via the existing `schema_migrations` tracking mechanism — no
|
||||
destructive statement anywhere in `033`/`034`.
|
||||
|
||||
## Documentation
|
||||
|
||||
New: `ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md`, `OFFERING_AND_REGISTRATION_
|
||||
MODEL.md`, `STUDENT_GUARDIAN_DATA_MODEL.md`, `SCHOOL_AND_COMMUNITY_DELIVERY_ARCHITECTURE.md`,
|
||||
this document. Updated: `WEBSITE_ENROLLMENT_INTEGRATION.md` (a documented future flow, current
|
||||
contract unchanged). Reviewed, no change needed: `REMOTE_AND_SELF_PACED_ARCHITECTURE.md`,
|
||||
`ENGINEERING_SOP.md`, `LAB_ARCHITECTURE.md`, `platform/README.md` (no new e2e-suite convention
|
||||
beyond what its existing list format already documents — see commit history for the actual
|
||||
`platform/README.md` addition covering `e2e-phase7.js`).
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **No admin UI** for offerings/registrations — the API is complete and tested; the screens are
|
||||
a well-specified follow-up, not a design question.
|
||||
- **Guardian communications are not yet wired into `notifications.js`** —
|
||||
`student_guardians.can_receive_communications`/`is_primary_contact` are recorded but nothing
|
||||
currently reads them to route an actual email.
|
||||
- **The website integration endpoint does not yet accept an `offering_slug`** — the future flow
|
||||
is documented, not implemented; that decision (extend the existing endpoint vs. a parallel
|
||||
one) was deliberately left open pending real website/checkout design.
|
||||
- **`agreement_acceptances.accepted_by_type = 'student'`** is only meaningful post-conversion,
|
||||
once a real `student_id` exists — an adult accepting at registration time (before their
|
||||
account exists) currently must accept as `'guardian'`-style even when self-registering; a
|
||||
future phase could add a `'self'` accepted-by-type distinct from `'guardian'` if that
|
||||
distinction becomes operationally important.
|
||||
|
||||
## Items requiring legal/business review before production use with real minors
|
||||
|
||||
See `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s own list in full: the actual legal text of any
|
||||
participation/media/liability agreement, jurisdiction-specific minor-consent requirements,
|
||||
data-retention/deletion policy for guardian and registration records, and whether emergency-
|
||||
contact fields need additional encryption at rest. None of these are resolved by this phase's
|
||||
work — they are named so they are never silently assumed away.
|
||||
|
||||
## Bugs found/fixed
|
||||
|
||||
One (the seat-reservation-before-validation ordering bug above), found and fixed before any
|
||||
commit via this phase's own smoke testing — not discovered by a later test run.
|
||||
|
||||
## Recommended Phase 8 scope
|
||||
|
||||
The admin UI for offerings/registrations (the one deliberately deferred piece of this phase's
|
||||
own mission); wiring guardian communications into `notifications.js`; and, informed by real
|
||||
business decisions, either extending `/api/integrations/enrollments` with an `offering_slug`
|
||||
or designing the parallel endpoint the future commerce flow needs — plus whatever the business
|
||||
decides about actually opening Winter Break Camp registration (which requires payment
|
||||
processing, explicitly out of every phase's scope so far).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,146 @@
|
||||
# RootNode Academy — Business & Product Architecture
|
||||
|
||||
Phase 7 Step 1 — the canonical bridge between RootNode's business strategy and its platform
|
||||
architecture. This is where "what RootNode Academy sells and to whom" and "what the codebase
|
||||
actually implements" are kept in one place, deliberately separated into BUILT NOW / PHASE 7
|
||||
BUILT / FUTURE so nobody has to guess which parts are real.
|
||||
|
||||
## Course families
|
||||
|
||||
INIT_, COMPILE_, ROOT_, and BOOTCAMP_ are **product positioning and technical-depth families**,
|
||||
not a rigid age ladder and not a mandatory sequence:
|
||||
|
||||
| Family | Typical audience (guidance, not a gate) | Status |
|
||||
|---|---|---|
|
||||
| INIT_ | ~ages 5-10 | Not yet authored (BUILT NOW: `programs.family` accepts it; PHASE 7 BUILT: nothing else; FUTURE: real curriculum) |
|
||||
| COMPILE_ | ~ages 11-14 | Same as INIT_ |
|
||||
| ROOT_ | ~ages 15+ through adult | **Fully authored, V1 pilot-ready** (Phase 6F) |
|
||||
| BOOTCAMP_ | short/intensive seasonal or specialty offerings | BUILT NOW: `programs.family` accepts it; PHASE 7 BUILT: the offering/curriculum-package machinery a bootcamp needs; FUTURE: an actual bootcamp program row |
|
||||
|
||||
**INIT → COMPILE → ROOT is explicitly NOT a mandatory path.** A student's access to any given
|
||||
course depends on that course's own prerequisites (already real and server-enforced within
|
||||
ROOT_ — see `docs/ROOT_V1_PREREQUISITE_MAP.md`), not on which family they've touched before.
|
||||
|
||||
## COMPILE_ / ROOT_ overlap
|
||||
|
||||
These two families intentionally cover overlapping technical territory at different depths — an
|
||||
adult is not blocked from an electronics/robotics/IoT course merely because it's positioned
|
||||
under COMPILE_, and a COMPILE_-age student is not blocked from later revisiting the same domain
|
||||
at ROOT_ depth. Example: COMPILE_ might teach "ESP32 + a sensor + introductory MQTT"; ROOT_ might
|
||||
teach "Linux + networking + a real broker/service + a containerized IoT workload + observability"
|
||||
— same domain, different technical floor. **BUILT NOW**: nothing prevents this architecturally —
|
||||
`programs`/`offerings` carry no family-based authorization check anywhere in the codebase.
|
||||
**FUTURE**: the actual COMPILE_ electronics/robotics content itself (explicitly out of Phase 7's
|
||||
scope).
|
||||
|
||||
## Program vs. Course vs. Offering
|
||||
|
||||
Three previously-conflated levels, now distinct:
|
||||
|
||||
- **Program** (`programs` table, Phase 5B, unchanged): the educational product/path itself —
|
||||
e.g. "ROOT Kubernetes Foundations." Carries `family` and a default `delivery_mode`.
|
||||
- **Course** (`courses` table, Phase 6A, unchanged): a curricular unit inside a program — e.g.
|
||||
"ROOT 02 Linux Administration & Networking."
|
||||
- **Offering** (`offerings` table, **new this phase**): a specific purchasable/enrollable
|
||||
*instance* of delivering a program (or a curriculum-package subset of one) — e.g. "Winter
|
||||
Break Linux & Systems Camp — December 2026," "ROOT Foundations Cohort — Spring 2027," "ROOT
|
||||
Self-Paced." A program can have many offerings across time; an offering always points at
|
||||
exactly one program.
|
||||
|
||||
A **workshop** and a **cohort** are both just `delivery_mode` values on an offering, not separate
|
||||
tables — see "Delivery modes" below. A **curriculum package** (new this phase) is an optional,
|
||||
named, versioned SUBSET reference into an existing program's courses, for when an offering (like
|
||||
a camp) intentionally teaches only part of a program — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`.
|
||||
|
||||
## Age guidance, not a gate
|
||||
|
||||
`offerings.recommended_min_age`/`recommended_max_age` and each course family's typical-audience
|
||||
description are **display/marketing guidance only**. No API authorization check anywhere in this
|
||||
codebase reads either value — enrollment and course access remain governed entirely by
|
||||
prerequisites, entitlement (`enrollments.status`), and (new this phase) offering capacity/
|
||||
registration status. Verified explicitly in `platform/test/e2e-phase7.js`.
|
||||
|
||||
## Delivery modes
|
||||
|
||||
Curriculum and delivery are separate concepts. `offerings.delivery_mode` (new this phase) is a
|
||||
single column supporting: `self_paced`, `cohort`, `hybrid`, `camp`, `workshop`, `school`,
|
||||
`after_school`, `homeschool`, `community`, `one_to_one`. No curriculum content is duplicated per
|
||||
mode — the same `program`/`courses`/`course_modules`/`module_activities` are read regardless of
|
||||
which offering enrolled the student into them, exactly the same principle
|
||||
`docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md` already established for self-paced vs.
|
||||
instructor-led delivery of ROOT_ V1.
|
||||
|
||||
## Organizations and locations
|
||||
|
||||
**Organization** (new this phase): a business/delivery relationship — a school, library,
|
||||
homeschool group, community organization, or workforce-development partner. **Not** SaaS
|
||||
multi-tenancy: RootNode Academy remains one Academy, one database, one set of students/
|
||||
instructors; an organization is metadata an offering can optionally reference, not a data
|
||||
partition. **Location** (new this phase): a physical or virtual delivery site, optionally tied
|
||||
to an organization (a rented community-center room needs no partner org; "online" needs neither
|
||||
an org nor a physical address). See `docs/SCHOOL_AND_COMMUNITY_DELIVERY_ARCHITECTURE.md`.
|
||||
|
||||
## Guardians and minors
|
||||
|
||||
**Guardian** (new this phase, `guardians`/`student_guardians`): a real, separate identity from a
|
||||
student's own account — not a login/auth identity yet (no password, no session), just a
|
||||
contact/relationship record. Replaces the Phase 5C `enrollments.metadata.contact_email`
|
||||
workaround as the proper foundation, while leaving that workaround functioning unchanged for
|
||||
backward compatibility with the live website integration. See
|
||||
`docs/STUDENT_GUARDIAN_DATA_MODEL.md`.
|
||||
|
||||
## Registration, enrollment, and agreements
|
||||
|
||||
**Registration** (new this phase, `offering_registrations`): pre-enrollment interest/application
|
||||
information for a specific offering — separate from `enrollments` (academic entitlement,
|
||||
unchanged). A registration becomes a real enrollment only once `confirmed`, via
|
||||
`convertRegistrationToEnrollment()`. **Agreement** (new this phase,
|
||||
`agreement_definitions`/`agreement_acceptances`): a versioned ACCEPTANCE RECORD, never legal
|
||||
text authored by this codebase. See `docs/OFFERING_AND_REGISTRATION_MODEL.md`.
|
||||
|
||||
## Commerce boundary — unchanged, extended in documentation only
|
||||
|
||||
The existing boundary (`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) stands: the website owns
|
||||
marketing/pricing/checkout, RootNode's platform owns everything after a verified commerce event.
|
||||
Phase 7 adds a documented FUTURE flow (website → offering selection → registration → checkout →
|
||||
verified commerce event → registration confirmed → enrollment activated) without implementing
|
||||
any payment code. **BUILT NOW**: the existing `/api/integrations/enrollments` endpoint,
|
||||
unchanged. **PHASE 7 BUILT**: the registration/offering model that future flow will plug into.
|
||||
**FUTURE**: an actual commerce-event webhook confirming a registration.
|
||||
|
||||
## First commercial target: Winter Break Camp
|
||||
|
||||
Per `docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md`'s Option A (Linux & Systems Foundations,
|
||||
ROOT 01-02, ~12.4 hours), Phase 7 models this as a real, if still DRAFT, offering
|
||||
(`docs/OFFERING_AND_REGISTRATION_MODEL.md`'s worked example) — proving the platform CAN
|
||||
represent and operate it, without inventing dates, venue, price, or seat count that aren't
|
||||
already decided.
|
||||
|
||||
## Future memberships/subscriptions and institutional delivery
|
||||
|
||||
Recurring (self-paced subscriptions, after-school programs, school/community contracts) and
|
||||
institutional (schools, libraries, workforce partners) revenue channels are **FUTURE** — the
|
||||
`organizations`/`locations`/`offerings` model this phase builds is deliberately general enough
|
||||
to represent them later without a schema redesign, but no billing/subscription code exists or is
|
||||
planned this phase.
|
||||
|
||||
## BUILT NOW / PHASE 7 BUILT / FUTURE — summary
|
||||
|
||||
| Concept | Status |
|
||||
|---|---|
|
||||
| Programs, courses, ROOT_ V1 curriculum | BUILT NOW (Phases 1-6F) |
|
||||
| `programs.family` accepting `bootcamp` | PHASE 7 BUILT (schema widening only) |
|
||||
| Organizations, locations | PHASE 7 BUILT |
|
||||
| Guardians, student-guardian relationships | PHASE 7 BUILT |
|
||||
| Offerings, capacity, server-defined status transitions | PHASE 7 BUILT |
|
||||
| Curriculum packages (subset references) | PHASE 7 BUILT |
|
||||
| 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) | **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 |
|
||||
@@ -0,0 +1,64 @@
|
||||
# School & Community Delivery Architecture
|
||||
|
||||
Phase 7 Step 27 — how a future school, library, homeschool organization, or community partner
|
||||
maps onto the platform's new organization/location/offering model. This is architecture
|
||||
documentation for an asset-light delivery strategy — RootNode favors partner/temporary venues
|
||||
over building permanent classrooms everywhere, at least initially.
|
||||
|
||||
## The mapping
|
||||
|
||||
```
|
||||
Organization (e.g. "Oxford Community Library")
|
||||
└─ Location (e.g. "Library — Community Room B", or "Online")
|
||||
└─ Offering (e.g. "After-School ROOT Foundations — Library Branch, Spring 2027")
|
||||
├─ program_id → an existing program (ROOT_ V1, or a future family)
|
||||
├─ curriculum_package_id → optionally, a subset (Step 20)
|
||||
├─ delivery_mode → 'after_school' / 'community' / 'school' / 'homeschool'
|
||||
├─ cohort_id → the real classroom/roster mechanism, unchanged
|
||||
└─ instructor_offerings → who teaches it
|
||||
```
|
||||
|
||||
A school, library, or homeschool organization is represented as ONE `organizations` row. It may
|
||||
have multiple `locations` (a school with two campuses; a library system with several branches).
|
||||
Each real delivery of curriculum at that organization is its own `offerings` row — this lets one
|
||||
organization run several different offerings (different programs, different cohorts, different
|
||||
semesters) without any of them being confused for each other, and without needing a new
|
||||
mechanism for "an organization has many offerings" (it's the same one-to-many `organization_id`
|
||||
foreign key `locations` and `offerings` already both carry).
|
||||
|
||||
## What this is NOT
|
||||
|
||||
**Not multi-tenancy.** No table in this schema is partitioned by `organization_id` the way a
|
||||
true multi-tenant SaaS product would be — students, instructors, courses, and progress are not
|
||||
scoped to an organization. An organization is a business/delivery *context* an offering can
|
||||
optionally reference, nothing more. RootNode Academy remains one Academy with one shared
|
||||
platform, one curriculum, and one set of students — a student who attends via a school
|
||||
partnership is a normal RootNode student, indistinguishable in the data model from a self-paced
|
||||
student, except for which offering(s) they registered through.
|
||||
|
||||
## Multi-location operation
|
||||
|
||||
Two offerings at two different locations coexist with no special isolation mechanism needed —
|
||||
they're just two rows in `offerings` with different `location_id`s, each with its own cohort/
|
||||
roster/registrations. `platform/test/e2e-phase7.js` proves this concretely: two offerings, two
|
||||
locations, verified to never leak state into each other. This is operational separation (two
|
||||
independent business records), not a security/tenant-isolation claim.
|
||||
|
||||
## What was explicitly NOT built this phase
|
||||
|
||||
- **No school SIS integration** (no roster import/export, no grade-passback, no LMS
|
||||
interoperability standard like OneRoster or LTI).
|
||||
- **No Google Classroom integration.**
|
||||
- **No organization-specific portal or branding** — every organization's students see the same
|
||||
RootNode Academy platform; there is no white-labeling.
|
||||
- **No organization-level admin role** — an organization contact does not get their own login or
|
||||
visibility into the platform; all administration remains through RootNode's own
|
||||
instructor/admin accounts, same as any other offering.
|
||||
|
||||
## Future extension points (not built, but not blocked)
|
||||
|
||||
If a future phase needs an organization contact to see their own offering's roster/progress
|
||||
(e.g. a school liaison checking on their students), that's a new, scoped RBAC role — the
|
||||
`organizations`/`offerings` model here doesn't need to change to support it, only the
|
||||
authorization layer would need a new role definition, deliberately deferred rather than guessed
|
||||
at now.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Student & Guardian Data Model
|
||||
|
||||
Phase 7 Step 28 — why student and guardian identities are kept separate, what data-minimization
|
||||
principles govern the new `guardians`/`student_guardians` tables, and what still needs
|
||||
professional/legal review before any of this touches a real minor's data in production.
|
||||
|
||||
## Why separate identities
|
||||
|
||||
A student's own identity (`students` table — `email`/`login_identifier`, unchanged) is their
|
||||
academic account. A guardian (`guardians`, new this phase) is a real, separate identity: a
|
||||
parent with three enrolled children is **one** guardian row linked to three students via
|
||||
`student_guardians`, not a guardian email copy-pasted into three student records. This mirrors
|
||||
exactly the reasoning `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` already gave for keeping
|
||||
`contact_email` off `students.email` — a contact address is not an academic identity, and now a
|
||||
guardian is a real relationship, not a workaround field.
|
||||
|
||||
## What this phase deliberately did NOT build
|
||||
|
||||
- **No Parent Portal.** A guardian record has no login, no password, no session — it cannot
|
||||
authenticate into anything yet. Building that is a real, separate future decision.
|
||||
- **No child-activity surveillance.** Nothing in this schema or its routes lets a guardian (once
|
||||
they could log in, in some future phase) see a student's Lab activity, scenario attempts, or
|
||||
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 — 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
|
||||
|
||||
Only fields with a genuine, near-term operational purpose were added: `display_name`, `email`,
|
||||
`phone`, and a free-text `notes` field for a guardian; `relationship`
|
||||
(`parent`/`guardian`/`authorized_contact`/`emergency_contact`), `is_primary_contact`, and
|
||||
`can_receive_communications` for the relationship itself. Registration-time fields
|
||||
(`readiness_notes`, `accessibility_notes`, `emergency_contact_name`/`emergency_contact_phone`)
|
||||
were chosen because a real camp/cohort genuinely needs them operationally — **no medical-record
|
||||
functionality was built**, and `accessibility_notes` is a free-text operational note (e.g. "uses
|
||||
a screen reader," "needs extra Lab time"), never a structured health/diagnosis field.
|
||||
|
||||
## RBAC
|
||||
|
||||
Guardian records and registration detail (readiness/accessibility/emergency-contact notes) are
|
||||
admin/owner-only routes — a plain instructor's existing cohort-scoped access to a student's
|
||||
academic progress is unchanged, but they do not automatically see guardian contact information
|
||||
or registration notes through the new routes. See `admin-offerings-routes.js`'s own RBAC
|
||||
comment and `platform/test/e2e-phase7.js`'s guardian-RBAC test.
|
||||
|
||||
## Adult vs. minor enrollment
|
||||
|
||||
An adult learner registers and is their own `registrant_email`/eventual student identity — no
|
||||
guardian row is created or required. A minor's registration requires a `guardian_id` (enforced
|
||||
by a database CHECK constraint on `offering_registrations`, not just application logic). Neither
|
||||
path writes a guardian's email into `students.email` — a minor with no email of their own still
|
||||
gets `login_identifier`-based identity (the same mechanism `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`
|
||||
already established), preserving the identity semantics Phase 5A/5C set up.
|
||||
|
||||
## Retention — flagged, not decided
|
||||
|
||||
This phase does not implement a data-retention or deletion policy for guardian/registration
|
||||
records. **Requires future legal/business review**: how long registration data (especially
|
||||
accessibility/emergency-contact notes for a minor) should be retained after an offering
|
||||
completes or a registration is cancelled, and what a guardian's/family's data-deletion request
|
||||
process should look like.
|
||||
|
||||
## Items explicitly requiring professional/legal review before production use with real minors
|
||||
|
||||
- Whether the current field set (name/email/phone/relationship) is sufficient or whether
|
||||
jurisdiction-specific requirements (e.g. COPPA-adjacent consent language, state-specific camp/
|
||||
minor-supervision regulations) require additional structured fields.
|
||||
- The actual legal text of any participation agreement, media consent, or liability waiver —
|
||||
`agreement_definitions.body_summary` is a plain-language placeholder for admin-UI display
|
||||
only, never a substitute for attorney-reviewed legal language.
|
||||
- Data retention/deletion policy for guardian and registration records.
|
||||
- Whether `emergency_contact_name`/`emergency_contact_phone` need to be encrypted at rest beyond
|
||||
Postgres's existing storage (no additional encryption was added this phase).
|
||||
|
||||
None of the above is resolved by this phase's work — they are named here specifically so they
|
||||
are not silently assumed away.
|
||||
@@ -171,10 +171,150 @@ email — otherwise the enrollment is created but nobody is notified automatical
|
||||
instructor has to relay the activation link manually. `contact_email` is stored only against
|
||||
that specific enrollment, never copied into the student's own identity record.
|
||||
|
||||
There is no guardian/parent account system yet — the enrollment is created directly against the
|
||||
student identity you provide. If you need to support "a parent manages multiple children's
|
||||
enrollments" as a first-class concept later, talk to the RootNode team before building around a
|
||||
workaround; the schema has room for it but it isn't implemented.
|
||||
**As of Phase 7, a real guardian/parent record now exists** (`guardians`/`student_guardians` —
|
||||
see `docs/STUDENT_GUARDIAN_DATA_MODEL.md`), but this endpoint's contract is UNCHANGED and does
|
||||
not yet create or link one — `student.contact_email` still works exactly as described above,
|
||||
stored only on the enrollment. If you need "a parent manages multiple children's enrollments" as
|
||||
a first-class concept in this endpoint, talk to the RootNode team before building around a
|
||||
workaround; the schema now has the real tables for it, they're just not wired into this
|
||||
endpoint yet.
|
||||
|
||||
## Offerings and registrations (Phase 8 — live)
|
||||
|
||||
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)
|
||||
|
||||
```
|
||||
GET /api/public/offerings
|
||||
GET /api/public/offerings/:slug
|
||||
```
|
||||
|
||||
No `X-RootNode-Integration-Key` needed — this is a public, read-only endpoint, safe to call
|
||||
directly from a browser. Only `published`/`enrollment_open`/`full`/`in_progress` offerings ever
|
||||
appear; `draft` and `cancelled` never do. Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "winter-linux-systems-camp-2026",
|
||||
"name": "Winter Break Linux & Systems Foundations Camp",
|
||||
"description": "A hands-on introduction to Linux administration and networking.",
|
||||
"program_id": "root-kubernetes-foundations",
|
||||
"delivery_mode": "camp",
|
||||
"recommended_min_age": 13,
|
||||
"recommended_max_age": null,
|
||||
"starts_at": "2026-12-15T14:00:00.000Z",
|
||||
"ends_at": "2026-12-19T18:00:00.000Z",
|
||||
"registration_open": true,
|
||||
"availability": "limited",
|
||||
"location_name": "Community Room B",
|
||||
"location_type": "community_center"
|
||||
}
|
||||
```
|
||||
|
||||
`availability` is `"open"` / `"limited"` / `"full"` — never a raw seat count (avoids inviting
|
||||
scraping/timing games against a public endpoint). No internal notes, no organization contact
|
||||
info, no instructor identity, no pricing — the website owns pricing display entirely; this
|
||||
endpoint doesn't return it.
|
||||
|
||||
### 2. Select an offering, then submit a registration (server-to-server)
|
||||
|
||||
```
|
||||
POST /api/integrations/registrations
|
||||
Header: X-RootNode-Integration-Key: <the same secret /api/integrations/enrollments already uses>
|
||||
```
|
||||
|
||||
Same trust boundary as the existing enrollment endpoint (Step 15) — server-to-server only, never
|
||||
called from browser JS, the secret never leaves your backend's own configuration.
|
||||
|
||||
**Adult example request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "reg_9f8e7d6c5b4a",
|
||||
"source": "website",
|
||||
"offering_slug": "winter-linux-systems-camp-2026",
|
||||
"registrant": { "display_name": "Jamie Rivera", "email": "[email protected]", "is_minor": false }
|
||||
}
|
||||
```
|
||||
|
||||
**Minor/guardian example request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "reg_1a2b3c4d5e6f",
|
||||
"source": "website",
|
||||
"offering_slug": "winter-linux-systems-camp-2026",
|
||||
"registrant": { "display_name": "Riley Rivera", "is_minor": true },
|
||||
"guardian": { "display_name": "Jamie Rivera", "email": "[email protected]", "phone": "555-0100" }
|
||||
}
|
||||
```
|
||||
|
||||
**Success response** (`201` new, `200` idempotent replay):
|
||||
|
||||
```json
|
||||
{ "registration_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "offering_id": "winter-linux-systems-camp-2026", "status": "started", "created": true }
|
||||
```
|
||||
|
||||
**Errors**: `401 unauthorized` (bad/missing key), `400` (`offering_slug`/`event_id`/registrant
|
||||
fields missing, or a minor with no guardian email), `404 OFFERING_NOT_FOUND`, `409` for
|
||||
`OFFERING_NOT_OPEN` / `OFFERING_CANCELLED` / `OFFERING_COMPLETED` / `OFFERING_FULL` /
|
||||
`REGISTRATION_NOT_YET_OPEN` / `REGISTRATION_CLOSED`.
|
||||
|
||||
### 3. Duplicate/retry semantics (idempotent, DB-enforced)
|
||||
|
||||
Identical to `/api/integrations/enrollments`: send the exact same `event_id` again (a retried
|
||||
webhook, a timed-out request retried by your own backend) and you get back the **same
|
||||
registration**, `created: false`, never a duplicate, never a second seat reserved — enforced by
|
||||
a real unique index on `event_id`, safe under concurrent/parallel retries too. Never generate a
|
||||
new `event_id` for a retry of the same real-world submission.
|
||||
|
||||
### 4. Adult flow
|
||||
|
||||
Send `registrant.email`, omit `guardian` entirely, `is_minor: false` (or omit it). The adult IS
|
||||
both the eventual student and the registration contact — no guardian record is created.
|
||||
|
||||
### 5. Minor/guardian flow
|
||||
|
||||
Send `registrant.is_minor: true` and a real `guardian` object (`display_name` + `email`
|
||||
required; `phone` optional). RootNode matches an existing guardian by email first (a returning
|
||||
family doesn't get a duplicate guardian record per sibling/per camp) or creates a new one. The
|
||||
guardian's email is never written to the eventual student's own identity record.
|
||||
|
||||
### 6. Agreement flow
|
||||
|
||||
Not yet exposed on this endpoint — an agreement acceptance is currently recorded by RootNode
|
||||
staff (or a future self-service step) after the registration is submitted, before it can be
|
||||
confirmed. If your registration flow collects agreement acceptance at submission time, talk to
|
||||
the RootNode team about extending this contract before building around a workaround.
|
||||
|
||||
### 7. Future payment step
|
||||
|
||||
Not built. Today, a registration is manually moved to `confirmed` by RootNode staff once payment
|
||||
is verified through whatever process exists outside the platform. A future phase's real payment
|
||||
integration would do this automatically on a verified commerce event.
|
||||
|
||||
### 8. Confirmation
|
||||
|
||||
Once `confirmed` (manually today, automatically in a future payment-integrated phase) and every
|
||||
required agreement is accepted, the registration can convert to a real enrollment.
|
||||
|
||||
### 9. Enrollment activation
|
||||
|
||||
Conversion creates (or matches) the real student account and a real academic enrollment, reusing
|
||||
the exact same activation-email machinery `/api/integrations/enrollments` already triggers — no
|
||||
second activation path exists.
|
||||
|
||||
### 10. What this endpoint does NOT do
|
||||
|
||||
No payment-card data (never sent, never has a field for it). No password (RootNode issues its
|
||||
own credential/activation flow). No arbitrary registration status (server-defined transitions
|
||||
only — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). All example data above is fake — do not
|
||||
reuse these values against a real deployment.
|
||||
|
||||
## What the student experiences after you call this (as of Phase 5C)
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Winter Break Camp Operations
|
||||
|
||||
Phase 8 §29 — how to actually run the Winter Break Linux & Systems Foundations Camp
|
||||
(`docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md` Option A) using the platform built in Phases
|
||||
7-8, end to end, entirely from `/admin`. No payment step exists yet — this document covers
|
||||
everything up to and including the point where a real payment/checkout would plug in.
|
||||
|
||||
## What already exists vs. what Chase still fills in
|
||||
|
||||
**Already representable, real, tested:**
|
||||
- The curriculum package (ROOT 01-02, referencing the real, unmodified courses).
|
||||
- The offering itself, in DRAFT, with delivery mode `camp`.
|
||||
- Registration → guardian/adult → agreement → (simulated) confirmation → enrollment → cohort →
|
||||
activation → classroom → real Lab work, proven end to end in
|
||||
`infra/scripts/drills/test-phase8-winter-camp-registration.js` (12/12 passing).
|
||||
|
||||
**Still genuinely unknown, deliberately left editable, never invented by this phase:**
|
||||
- Exact dates, venue, real capacity, and price.
|
||||
- The actual agreement text (a plain-language placeholder only — see `docs/
|
||||
STUDENT_GUARDIAN_DATA_MODEL.md`'s legal-review list).
|
||||
- Whether registration opens before or after a specific marketing push.
|
||||
|
||||
## The operational sequence
|
||||
|
||||
1. **Create the location** — `/admin` → Offerings → (via the offering create form's location
|
||||
dropdown) or directly if a location admin surface is added later; today, an admin/owner uses
|
||||
`POST /api/admin/locations` (documented in `docs/OFFERING_ADMIN_GUIDE.md`) once the real venue
|
||||
is known.
|
||||
2. **Create the curriculum package** — `winter-linux-systems-v1`, referencing
|
||||
`root-01-computing-linux-foundations` and `root-02-linux-administration-networking` in
|
||||
sequence. This step is a one-time setup — the same package can back every future Winter Break
|
||||
Camp offering.
|
||||
3. **Create the cohort** and **the offering** — delivery mode `camp`, referencing the program,
|
||||
the curriculum package, and (once known) the location and capacity.
|
||||
4. **Assign the lead instructor** — both at the offering level (`instructor_offerings`, Phase 7)
|
||||
and the cohort level (`cohort_instructors`, unchanged since Phase 2A) — the offering detail
|
||||
page's own instructor step, or the two admin routes directly.
|
||||
5. **Configure the required agreement(s)** — create the agreement definition (title + plain-
|
||||
language summary) and link it to the offering. **The actual legal text still needs
|
||||
professional review before this camp is opened to real families** — see below.
|
||||
6. **Check launch readiness** — the offering detail page's readiness panel should show every
|
||||
check green before publishing. If instructor/curriculum/agreements are configured but dates/
|
||||
location/capacity aren't final yet, readiness will honestly show that.
|
||||
7. **Publish, then open registration** — `draft → published → enrollment_open`. Only once
|
||||
`enrollment_open` does the offering appear in the public offerings API and accept real
|
||||
registrations.
|
||||
8. **Registrations arrive** — via the website integration (once wired — see
|
||||
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`'s documented future flow) or entered directly by an
|
||||
admin for a phone/in-person sign-up.
|
||||
9. **Guardian/adult completes the agreement** — recorded via the registration's own agreement
|
||||
acceptance action.
|
||||
10. **Payment step (future, not built)** — this is the one step this phase does not implement.
|
||||
Today, an admin manually transitions a registration to `confirmed` once payment is verified
|
||||
by whatever process exists outside the platform; a future phase's real payment integration
|
||||
would do this automatically on a verified commerce event, the same shape
|
||||
`/api/integrations/enrollments` already uses for direct program enrollment.
|
||||
11. **Convert to enrollment** — creates the real student account (or matches an existing one),
|
||||
the real academic enrollment, and real cohort membership.
|
||||
12. **Account activation** — the student's (or, for a minor, the guardian's) real one-time
|
||||
activation token, delivered the same way every other RootNode activation email already is.
|
||||
13. **Classroom day** — Prepare Labs for the cohort, start the classroom session, students join
|
||||
and work through real ROOT 01-02 content and Labs.
|
||||
14. **Offering completion** — `in_progress → completed` once the camp concludes.
|
||||
|
||||
## Known blockers before this can actually open to the public
|
||||
|
||||
- Real dates, venue, capacity, and price are not yet decided (this document does not invent
|
||||
them).
|
||||
- The agreement text requires professional legal review (see `docs/
|
||||
STUDENT_GUARDIAN_DATA_MODEL.md`).
|
||||
- No payment processing exists — step 10 above is manual until a future phase builds it.
|
||||
- The website's own registration UI does not exist yet in this repository (rootnodeacademy.com
|
||||
is built separately) — the integration contract it needs is ready
|
||||
(`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`), but nobody has built the website-side form yet.
|
||||
@@ -10,6 +10,7 @@ import LearningView from './LearningView'
|
||||
import AssessmentsView from './AssessmentsView'
|
||||
import ClassroomView from './ClassroomView'
|
||||
import ProgramsView from './ProgramsView'
|
||||
import OfferingsView from './OfferingsView'
|
||||
import NotificationsView from './NotificationsView'
|
||||
import CurriculumView from './CurriculumView'
|
||||
import styles from './Admin.module.css'
|
||||
@@ -25,6 +26,7 @@ const NAV = [
|
||||
{ key: 'cohorts', label: 'Cohorts' },
|
||||
{ key: 'students', label: 'Students' },
|
||||
{ key: 'programs', label: 'Programs' },
|
||||
{ key: 'offerings', label: 'Offerings' },
|
||||
{ key: 'curriculum', label: 'Curriculum' },
|
||||
{ key: 'classroom', label: 'Classroom' },
|
||||
{ key: 'learning', label: 'Learning' },
|
||||
@@ -68,6 +70,7 @@ export default function AdminPortal() {
|
||||
{view === 'cohorts' && <CohortsView role={instructor.role} />}
|
||||
{view === 'students' && <StudentsView role={instructor.role} />}
|
||||
{view === 'programs' && <ProgramsView role={instructor.role} />}
|
||||
{view === 'offerings' && <OfferingsView role={instructor.role} />}
|
||||
{view === 'curriculum' && <CurriculumView />}
|
||||
{view === 'classroom' && <ClassroomView />}
|
||||
{view === 'learning' && <LearningView />}
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Phase 8 §1-§5 — the Offerings operations console: list/create/detail, launch readiness,
|
||||
// registrations/roster, and agreement requirements. Authoring (create/transition/agreement
|
||||
// requirements) is admin/owner only, matching every other authoring surface in this app
|
||||
// (ProgramsView/AssessmentsView's own precedent) — canAuthor gates writes only, not viewing.
|
||||
const OFFERING_TRANSITIONS = {
|
||||
draft: ['published', 'cancelled'],
|
||||
published: ['enrollment_open', 'cancelled'],
|
||||
enrollment_open: ['full', 'in_progress', 'cancelled'],
|
||||
full: ['enrollment_open', 'in_progress', 'cancelled'],
|
||||
in_progress: ['completed', 'cancelled'],
|
||||
completed: [],
|
||||
cancelled: [],
|
||||
}
|
||||
|
||||
function statusPillClass(status) {
|
||||
if (['enrollment_open', 'in_progress', 'completed'].includes(status)) return styles.pillOk
|
||||
if (status === 'full') return styles.pillWarn
|
||||
if (status === 'cancelled') return styles.pillErr
|
||||
return styles.pillMuted
|
||||
}
|
||||
|
||||
export default function OfferingsView({ role }) {
|
||||
const [offerings, setOfferings] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const canAuthor = role === 'owner' || role === 'admin'
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.offerings().then(setOfferings).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <OfferingDetail offeringId={selected} canAuthor={canAuthor} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{canAuthor && (
|
||||
<div className={styles.card}>
|
||||
<h2>Create offering</h2>
|
||||
<CreateOfferingForm onCreated={(id) => { reload(); setSelected(id) }} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.card}>
|
||||
<h2>Offerings</h2>
|
||||
{!offerings ? <p className={styles.muted}>Loading…</p> : offerings.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Program</th><th>Delivery</th><th>Status</th><th>Seats</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{offerings.map((o) => (
|
||||
<tr key={o.offering_id}>
|
||||
<td>{o.name}</td>
|
||||
<td className={styles.muted}>{o.program_id}</td>
|
||||
<td className={styles.muted}>{o.delivery_mode.replace('_', ' ')}</td>
|
||||
<td><span className={`${styles.pill} ${statusPillClass(o.status)}`}>{o.status.replace('_', ' ')}</span></td>
|
||||
<td className={styles.muted}>{o.capacity === null ? 'unlimited' : `${o.reserved_seats}/${o.capacity}`}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(o.offering_id)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateOfferingForm({ onCreated }) {
|
||||
const [programs, setPrograms] = useState([])
|
||||
const [locations, setLocations] = useState([])
|
||||
const [form, setForm] = useState({ offering_id: '', slug: '', name: '', program_id: '', delivery_mode: 'camp', capacity: '', location_id: '', recommended_min_age: '', recommended_max_age: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.programs().then(setPrograms).catch(() => {})
|
||||
adminApi.locations().then(setLocations).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const offering = await adminApi.createOffering({
|
||||
offeringId: form.offering_id, slug: form.slug, name: form.name, programId: form.program_id, deliveryMode: form.delivery_mode,
|
||||
capacity: form.capacity === '' ? undefined : Number(form.capacity),
|
||||
locationId: form.location_id || undefined,
|
||||
recommendedMinAge: form.recommended_min_age === '' ? undefined : Number(form.recommended_min_age),
|
||||
recommendedMaxAge: form.recommended_max_age === '' ? undefined : Number(form.recommended_max_age),
|
||||
})
|
||||
setForm({ offering_id: '', slug: '', name: '', program_id: '', delivery_mode: 'camp', capacity: '', location_id: '', recommended_min_age: '', recommended_max_age: '' })
|
||||
onCreated(offering.offering_id)
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Offering ID (slug-like)</label>
|
||||
<input className={styles.input} value={form.offering_id} onChange={(e) => setForm({ ...form, offering_id: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>URL slug</label>
|
||||
<input className={styles.input} value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Name</label>
|
||||
<input className={styles.input} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></div>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Program</label>
|
||||
<select className={styles.select} value={form.program_id} onChange={(e) => setForm({ ...form, program_id: e.target.value })} required>
|
||||
<option value="">Select a program…</option>
|
||||
{programs.map((p) => <option key={p.program_id} value={p.program_id}>{p.name}</option>)}
|
||||
</select></div>
|
||||
<div><label className={styles.label}>Delivery mode</label>
|
||||
<select className={styles.select} value={form.delivery_mode} onChange={(e) => setForm({ ...form, delivery_mode: e.target.value })}>
|
||||
{['self_paced', 'cohort', 'hybrid', 'camp', 'workshop', 'school', 'after_school', 'homeschool', 'community', 'one_to_one'].map((m) => <option key={m} value={m}>{m.replace('_', ' ')}</option>)}
|
||||
</select></div>
|
||||
<div><label className={styles.label}>Capacity (blank = unlimited)</label>
|
||||
<input className={styles.input} type="number" min="1" value={form.capacity} onChange={(e) => setForm({ ...form, capacity: e.target.value })} /></div>
|
||||
<div><label className={styles.label}>Location (optional)</label>
|
||||
<select className={styles.select} value={form.location_id} onChange={(e) => setForm({ ...form, location_id: e.target.value })}>
|
||||
<option value="">None / online</option>
|
||||
{locations.map((l) => <option key={l.location_id} value={l.location_id}>{l.name}</option>)}
|
||||
</select></div>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Recommended min age (guidance only)</label>
|
||||
<input className={styles.input} type="number" value={form.recommended_min_age} onChange={(e) => setForm({ ...form, recommended_min_age: e.target.value })} /></div>
|
||||
<div><label className={styles.label}>Recommended max age (guidance only)</label>
|
||||
<input className={styles.input} type="number" value={form.recommended_max_age} onChange={(e) => setForm({ ...form, recommended_max_age: e.target.value })} /></div>
|
||||
</div>
|
||||
<p className={styles.muted}>Age fields are display guidance only — never an access gate. Dates, registration window, and organization can be set later from the offering's own detail page.</p>
|
||||
<button className={styles.btn} style={{ marginTop: '0.5rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create draft offering'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function OfferingDetail({ offeringId, canAuthor, onBack }) {
|
||||
const [offering, setOffering] = useState(null)
|
||||
const [readiness, setReadiness] = useState(null)
|
||||
const [registrations, setRegistrations] = useState(null)
|
||||
const [agreementReqs, setAgreementReqs] = useState(null)
|
||||
const [allAgreements, setAllAgreements] = useState([])
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [selectedReg, setSelectedReg] = useState(null)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.offering(offeringId).then(setOffering).catch((e) => setError(e.message))
|
||||
adminApi.offeringReadiness(offeringId).then(setReadiness).catch(() => {})
|
||||
adminApi.offeringRegistrations(offeringId).then(setRegistrations).catch(() => {})
|
||||
adminApi.offeringAgreementRequirements(offeringId).then(setAgreementReqs).catch(() => {})
|
||||
adminApi.agreements().then(setAllAgreements).catch(() => {})
|
||||
}, [offeringId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const withBusy = (fn) => async () => {
|
||||
setBusy(true); setError(null)
|
||||
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (selectedReg) return <RegistrationDetail registrationId={selectedReg} canAuthor={canAuthor} onBack={() => { setSelectedReg(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to offerings</button>
|
||||
{!offering ? <p className={styles.muted}>Loading…</p> : (
|
||||
<>
|
||||
<h2 style={{ margin: '1rem 0' }}>{offering.name} <span className={`${styles.pill} ${statusPillClass(offering.status)}`}>{offering.status.replace('_', ' ')}</span></h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Overview</h3>
|
||||
<p className={styles.muted}>{offering.description || 'No description.'}</p>
|
||||
<p>Program: {offering.program_id} — Delivery: {offering.delivery_mode.replace('_', ' ')} — Seats: {offering.capacity === null ? 'unlimited' : `${offering.reserved_seats} / ${offering.capacity}`}</p>
|
||||
{canAuthor && (OFFERING_TRANSITIONS[offering.status] || []).length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
{(OFFERING_TRANSITIONS[offering.status] || []).map((next) => (
|
||||
<button key={next} className={styles.btnSecondary} style={{ marginRight: '0.5rem' }} disabled={busy}
|
||||
onClick={withBusy(() => adminApi.transitionOffering(offeringId, next))}>
|
||||
{next === 'cancelled' ? 'Cancel offering' : `Move to ${next.replace('_', ' ')}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Launch readiness</h3>
|
||||
{!readiness ? <p className={styles.muted}>Loading…</p> : (
|
||||
<>
|
||||
<p><span className={`${styles.pill} ${readiness.ready_to_open_registration ? styles.pillOk : styles.pillWarn}`}>
|
||||
{readiness.ready_to_open_registration ? 'Ready to open registration' : 'Not yet ready'}
|
||||
</span></p>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
{readiness.checks.map((c) => (
|
||||
<li key={c.key} className={styles.muted}>{c.ready ? '✓' : '✗'} {c.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Required agreements</h3>
|
||||
{!agreementReqs ? <p className={styles.muted}>Loading…</p> : agreementReqs.length === 0 ? <p className={styles.muted}>None configured — registrations can convert without any agreement acceptance.</p> : (
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
{agreementReqs.map((a) => <li key={a.agreement_id} className={styles.muted}>{a.title} (v{a.version}) {a.requires_guardian_for_minor && <span className={styles.pill}>guardian required for minors</span>}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{canAuthor && <AddAgreementRequirementForm offeringId={offeringId} allAgreements={allAgreements} onAdded={reload} />}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Registrations / roster</h3>
|
||||
{canAuthor && <CreateRegistrationForm offeringId={offeringId} onCreated={reload} />}
|
||||
{!registrations ? <p className={styles.muted}>Loading…</p> : registrations.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Registrant</th><th>Type</th><th>Status</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{registrations.map((r) => (
|
||||
<tr key={r.registration_id}>
|
||||
<td>{r.registrant_display_name}</td>
|
||||
<td className={styles.muted}>{r.is_minor ? 'minor (guardian)' : 'adult'}</td>
|
||||
<td><span className={`${styles.pill} ${r.status === 'enrollment_activated' ? styles.pillOk : r.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{r.status.replace('_', ' ')}</span></td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelectedReg(r.registration_id)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateRegistrationForm({ offeringId, onCreated }) {
|
||||
const [form, setForm] = useState({ registrant_display_name: '', registrant_email: '', is_minor: false, guardian_display_name: '', guardian_email: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
let guardianId
|
||||
if (form.is_minor) {
|
||||
guardianId = `guardian-${Date.now()}`
|
||||
await adminApi.createGuardian({ guardianId, displayName: form.guardian_display_name || form.guardian_email, email: form.guardian_email })
|
||||
}
|
||||
await adminApi.createRegistration(offeringId, {
|
||||
registrantDisplayName: form.registrant_display_name, registrantEmail: form.registrant_email || undefined,
|
||||
isMinor: form.is_minor, guardianId,
|
||||
})
|
||||
setForm({ registrant_display_name: '', registrant_email: '', is_minor: false, guardian_display_name: '', guardian_email: '' })
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ marginBottom: '1rem', paddingBottom: '1rem', borderBottom: '1px solid var(--border)' }}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Registrant name</label>
|
||||
<input className={styles.input} value={form.registrant_display_name} onChange={(e) => setForm({ ...form, registrant_display_name: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Registrant type</label>
|
||||
<select className={styles.select} value={form.is_minor ? 'minor' : 'adult'} onChange={(e) => setForm({ ...form, is_minor: e.target.value === 'minor' })}>
|
||||
<option value="adult">Adult (self-registering)</option>
|
||||
<option value="minor">Minor (guardian registers)</option>
|
||||
</select></div>
|
||||
{!form.is_minor && (
|
||||
<div><label className={styles.label}>Registrant email</label>
|
||||
<input className={styles.input} type="email" value={form.registrant_email} onChange={(e) => setForm({ ...form, registrant_email: e.target.value })} /></div>
|
||||
)}
|
||||
</div>
|
||||
{form.is_minor && (
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Guardian name</label>
|
||||
<input className={styles.input} value={form.guardian_display_name} onChange={(e) => setForm({ ...form, guardian_display_name: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Guardian email</label>
|
||||
<input className={styles.input} type="email" value={form.guardian_email} onChange={(e) => setForm({ ...form, guardian_email: e.target.value })} required /></div>
|
||||
</div>
|
||||
)}
|
||||
<button className={styles.btn} disabled={busy || !form.registrant_display_name.trim()}>{busy ? 'Registering…' : 'Add registration'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function AddAgreementRequirementForm({ offeringId, allAgreements, onAdded }) {
|
||||
const [agreementId, setAgreementId] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!agreementId) return
|
||||
setBusy(true); setError(null)
|
||||
try { await adminApi.addOfferingAgreementRequirement(offeringId, agreementId); setAgreementId(''); onAdded() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ marginTop: '0.75rem', display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div><label className={styles.label}>Require an agreement</label>
|
||||
<select className={styles.select} value={agreementId} onChange={(e) => setAgreementId(e.target.value)}>
|
||||
<option value="">Select…</option>
|
||||
{allAgreements.map((a) => <option key={a.agreement_id} value={a.agreement_id}>{a.title}</option>)}
|
||||
</select></div>
|
||||
<button className={styles.btnSecondary} disabled={busy || !agreementId}>Add requirement</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function RegistrationDetail({ registrationId, canAuthor, onBack }) {
|
||||
const [reg, setReg] = useState(null)
|
||||
const [agreementStatus, setAgreementStatus] = useState(null)
|
||||
const [notifs, setNotifs] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const REG_TRANSITIONS = {
|
||||
started: ['submitted', 'cancelled'], submitted: ['reviewed', 'agreements_pending', 'payment_pending', 'confirmed', 'cancelled'],
|
||||
reviewed: ['agreements_pending', 'payment_pending', 'confirmed', 'cancelled'], agreements_pending: ['payment_pending', 'confirmed', 'cancelled'],
|
||||
payment_pending: ['confirmed', 'cancelled'], confirmed: ['enrollment_activated', 'cancelled'], enrollment_activated: [], cancelled: [],
|
||||
}
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.registration(registrationId).then(setReg).catch((e) => setError(e.message))
|
||||
adminApi.registrationAgreementStatus(registrationId).then(setAgreementStatus).catch(() => {})
|
||||
adminApi.registrationNotifications(registrationId).then(setNotifs).catch(() => {})
|
||||
}, [registrationId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const withBusy = (fn) => async () => {
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to offering</button>
|
||||
{!reg ? <p className={styles.muted}>Loading…</p> : (
|
||||
<>
|
||||
<h2 style={{ margin: '1rem 0' }}>{reg.registrant_display_name} <span className={styles.pill}>{reg.status.replace('_', ' ')}</span></h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Registration</h3>
|
||||
<p>Type: {reg.is_minor ? 'Minor (guardian-registered)' : 'Adult (self-registered)'}</p>
|
||||
{reg.registrant_email && <p className={styles.muted}>Contact: {reg.registrant_email}</p>}
|
||||
{reg.readiness_notes && <p className={styles.muted}>Readiness notes: {reg.readiness_notes}</p>}
|
||||
{canAuthor && (REG_TRANSITIONS[reg.status] || []).length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
{(REG_TRANSITIONS[reg.status] || []).map((next) => (
|
||||
<button key={next} className={styles.btnSecondary} style={{ marginRight: '0.5rem' }} disabled={busy}
|
||||
onClick={withBusy(() => adminApi.transitionRegistration(registrationId, next))}>
|
||||
{next === 'cancelled' ? 'Cancel' : next.replace('_', ' ')}
|
||||
</button>
|
||||
))}
|
||||
{reg.status === 'confirmed' && (
|
||||
<button className={styles.btn} disabled={busy} onClick={withBusy(async () => { const r = await adminApi.convertRegistration(registrationId); setNotice(`Enrolled as ${r.student.student_id}.`) })}>
|
||||
Convert to enrollment
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Agreements</h3>
|
||||
{!agreementStatus ? <p className={styles.muted}>Loading…</p> : agreementStatus.required.length === 0 ? <p className={styles.muted}>No agreements required for this offering.</p> : (
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
{agreementStatus.required.map((a) => (
|
||||
<li key={a.agreement_id} className={styles.muted}>
|
||||
{agreementStatus.missing.some((m) => m.agreement_id === a.agreement_id) ? '✗ missing' : '✓ accepted'} — {a.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Communications</h3>
|
||||
{!notifs ? <p className={styles.muted}>Loading…</p> : notifs.length === 0 ? <p className={styles.muted}>None sent yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>To</th><th>Status</th><th>Sent</th></tr></thead>
|
||||
<tbody>
|
||||
{notifs.map((n) => (
|
||||
<tr key={n.notification_id}>
|
||||
<td className={styles.muted}>{n.notification_type.replace(/_/g, ' ')}</td>
|
||||
<td className={styles.muted}>{n.recipient_email_masked || '—'}</td>
|
||||
<td><span className={`${styles.pill} ${n.status === 'sent' ? styles.pillOk : n.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{n.status}</span></td>
|
||||
<td className={styles.muted}>{n.sent_at ? new Date(n.sent_at).toLocaleString() : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -138,4 +138,37 @@ export const adminApi = {
|
||||
// ── Phase 6A §7: course/module/activity curriculum view ──────────────────────────────────
|
||||
programCourses: (programId) => get(`/api/admin/programs/${programId}/courses`),
|
||||
studentCourses: (studentId, programId) => get(`/api/admin/students/${studentId}/courses?program_id=${encodeURIComponent(programId)}`),
|
||||
|
||||
// ── Phase 7/8: offerings, registrations, guardians, agreements ──────────────────────────
|
||||
offerings: (status) => get(`/api/admin/offerings${status ? `?status=${status}` : ''}`),
|
||||
offering: (id) => get(`/api/admin/offerings/${id}`),
|
||||
createOffering: (fields) => post('/api/admin/offerings', fields),
|
||||
transitionOffering: (id, status) => post(`/api/admin/offerings/${id}/transition`, { status }),
|
||||
offeringReadiness: (id) => get(`/api/admin/offerings/${id}/readiness`),
|
||||
assignOfferingInstructor: (id, instructor_id, role) => post(`/api/admin/offerings/${id}/instructors`, { instructor_id, role }),
|
||||
offeringAgreementRequirements: (id) => get(`/api/admin/offerings/${id}/agreement-requirements`),
|
||||
addOfferingAgreementRequirement: (id, agreement_id) => post(`/api/admin/offerings/${id}/agreement-requirements`, { agreement_id }),
|
||||
organizations: () => get('/api/admin/organizations'),
|
||||
createOrganization: (fields) => post('/api/admin/organizations', fields),
|
||||
locations: () => get('/api/admin/locations'),
|
||||
createLocation: (fields) => post('/api/admin/locations', fields),
|
||||
curriculumPackage: (id) => get(`/api/admin/curriculum-packages/${id}`),
|
||||
createCurriculumPackage: (fields) => post('/api/admin/curriculum-packages', fields),
|
||||
addCurriculumPackageItem: (id, fields) => post(`/api/admin/curriculum-packages/${id}/items`, fields),
|
||||
|
||||
offeringRegistrations: (offeringId) => get(`/api/admin/offerings/${offeringId}/registrations`),
|
||||
createRegistration: (offeringId, fields) => post(`/api/admin/offerings/${offeringId}/registrations`, fields),
|
||||
registration: (id) => get(`/api/admin/registrations/${id}`),
|
||||
transitionRegistration: (id, status) => post(`/api/admin/registrations/${id}/transition`, { status }),
|
||||
convertRegistration: (id) => post(`/api/admin/registrations/${id}/convert`, {}),
|
||||
registrationAgreementStatus: (id) => get(`/api/admin/registrations/${id}/agreement-status`),
|
||||
registrationNotifications: (id) => get(`/api/admin/registrations/${id}/notifications`),
|
||||
acceptAgreementForRegistration: (id, fields) => post(`/api/admin/registrations/${id}/agreements`, fields),
|
||||
|
||||
agreements: () => get('/api/admin/agreements'),
|
||||
createAgreement: (fields) => post('/api/admin/agreements', fields),
|
||||
|
||||
createGuardian: (fields) => post('/api/admin/guardians', fields),
|
||||
studentGuardians: (studentId) => get(`/api/admin/students/${studentId}/guardians`),
|
||||
linkStudentGuardian: (studentId, fields) => post(`/api/admin/students/${studentId}/guardians`, fields),
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 7 Step 31 Winter Break Camp internal rehearsal: brings up the real dev
|
||||
# stack, bootstraps an owner, runs test-phase7-winter-camp.js (real offering/registration/
|
||||
# guardian/agreement/enrollment/cohort/classroom/Lab flow, no payment code), tears down.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
|
||||
|
||||
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
|
||||
export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}"
|
||||
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
|
||||
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
cleanup() {
|
||||
echo "==> Tearing down"
|
||||
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
|
||||
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> Waiting for the stack"
|
||||
for _ in $(seq 1 60); do
|
||||
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> Bootstrapping the rehearsal owner"
|
||||
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||
node bootstrap-owner.js [email protected] "P7 Winter Owner" 'RehearsalPass!2026' )
|
||||
|
||||
echo "==> Running the rehearsal"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
|
||||
node ../infra/scripts/drills/test-phase7-winter-camp.js
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 8 Step 32 Winter Break commercial-path rehearsal: brings up the real
|
||||
# dev stack, bootstraps an owner, runs test-phase8-winter-camp-registration.js (real website
|
||||
# registration submission, guardian creation, agreement acceptance, simulated payment
|
||||
# confirmation, real enrollment/cohort/classroom/Lab), tears down.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
|
||||
|
||||
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
|
||||
export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}"
|
||||
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
|
||||
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
cleanup() {
|
||||
echo "==> Tearing down"
|
||||
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
|
||||
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> Waiting for the stack"
|
||||
for _ in $(seq 1 60); do
|
||||
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> Bootstrapping the rehearsal owner"
|
||||
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||
node bootstrap-owner.js [email protected] "P8 Winter Owner" 'RehearsalPass!2026' )
|
||||
|
||||
echo "==> Running the rehearsal"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' INTEGRATION_SHARED_SECRET="$INTEGRATION_SHARED_SECRET" \
|
||||
node ../infra/scripts/drills/test-phase8-winter-camp-registration.js
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 7 Step 31 — the Winter Break Camp internal operational rehearsal, without payments.
|
||||
// Proves the full path: admin creates a draft offering (Winter Break Linux & Systems Camp,
|
||||
// matching docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md's Option A: ROOT 01-02) with a real
|
||||
// curriculum package, location, and instructor assignment; a guardian registers a minor and an
|
||||
// adult registers themselves; both registrations are walked through agreements and a simulated
|
||||
// commerce confirmation (admin-side only — no payment code exists or is invoked); both convert
|
||||
// to real enrollments and real cohort membership; the instructor runs a real classroom session;
|
||||
// the minor's own Lab work is done for real (the same linux-inspect-hardware checkpoint the
|
||||
// Phase 6D/6F rehearsals already established); progress is confirmed to survive; the offering
|
||||
// is marked complete.
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('../../../platform/test/session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||
}
|
||||
function moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 7 Winter Break Camp internal rehearsal — no payments, real everything else\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await step('owner logs in', async () => {
|
||||
const r = await owner.request('POST', '/api/auth/instructor/login', { email: process.env.OWNER_EMAIL, password: process.env.OWNER_PASSWORD });
|
||||
assert.equal(r.status, 200);
|
||||
});
|
||||
|
||||
const orgId = `winter-org-${RUN_ID}`, locId = `winter-loc-${RUN_ID}`, pkgId = `winter-linux-systems-${RUN_ID}`, cohortId = `winter-cohort-${RUN_ID}`, offeringId = `winter-camp-${RUN_ID}`;
|
||||
|
||||
await step('admin creates the venue (organization + location)', async () => {
|
||||
const org = await owner.request('POST', '/api/admin/organizations', { organizationId: orgId, name: 'Oxford Community Library', type: 'library' });
|
||||
assert.equal(org.status, 201);
|
||||
const loc = await owner.request('POST', '/api/admin/locations', { locationId: locId, organizationId: orgId, name: 'Community Room B', type: 'community_center', capacity: 15 });
|
||||
assert.equal(loc.status, 201);
|
||||
});
|
||||
|
||||
await step('admin creates the curriculum package — ROOT 01-02, matching Winter Break Camp Option A, referencing existing content', async () => {
|
||||
const pkg = await owner.request('POST', '/api/admin/curriculum-packages', { curriculumPackageId: pkgId, name: 'Winter Linux & Systems Foundations', programId: PROGRAM, description: 'ROOT 01-02 — see docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md Option A' });
|
||||
assert.equal(pkg.status, 201);
|
||||
for (const [courseId, sequence] of [['root-01-computing-linux-foundations', 1], ['root-02-linux-administration-networking', 2]]) {
|
||||
const item = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId, sequence });
|
||||
assert.equal(item.status, 201);
|
||||
}
|
||||
});
|
||||
|
||||
await step('admin creates the real cohort this camp will roster into', async () => {
|
||||
const cohort = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'Winter Break Camp Cohort', tier: 'root' });
|
||||
assert.equal(cohort.status, 201);
|
||||
});
|
||||
|
||||
await step('admin creates the DRAFT offering — dates/price/exact seat count left unset, exactly as authorized', async () => {
|
||||
const offering = await owner.request('POST', '/api/admin/offerings', {
|
||||
offeringId, slug: `winter-linux-systems-camp-${RUN_ID}`, name: 'Winter Break Linux & Systems Foundations Camp',
|
||||
programId: PROGRAM, curriculumPackageId: pkgId, deliveryMode: 'camp', capacity: 12,
|
||||
locationId: locId, organizationId: orgId, cohortId,
|
||||
recommendedMinAge: 13, recommendedMaxAge: null, // guidance only — see the age-independence test in e2e-phase7.js
|
||||
});
|
||||
assert.equal(offering.status, 201, JSON.stringify(offering.body));
|
||||
assert.equal(offering.body.status, 'draft');
|
||||
});
|
||||
|
||||
let instructorId;
|
||||
await step('admin creates and assigns the lead instructor', async () => {
|
||||
const create = await owner.request('POST', '/api/admin/instructors', { email: `winter-instructor-${RUN_ID}@example.com`, display_name: 'Winter Camp Instructor', role: 'instructor', password: 'WinterCampPass123!' });
|
||||
assert.equal(create.status, 201);
|
||||
instructorId = create.body.instructor_id;
|
||||
const link = await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorId, role: 'teacher' });
|
||||
assert.equal(link.status, 201);
|
||||
// The offering-level assignment is Phase 7's own new concept; the cohort-level one (the
|
||||
// real RBAC scoping mechanism a classroom session actually checks) is Phase 2A/4.1,
|
||||
// unchanged — both are assigned so the instructor genuinely has classroom access below.
|
||||
const cohortLink = await owner.request('POST', `/api/admin/cohorts/${cohortId}/instructors`, { instructor_id: instructorId, role_in_cohort: 'teacher' });
|
||||
assert.equal(cohortLink.status, 200, JSON.stringify(cohortLink.body));
|
||||
});
|
||||
|
||||
await step('admin publishes the offering and opens registration', async () => {
|
||||
const pub = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
|
||||
assert.equal(pub.status, 200);
|
||||
const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
|
||||
assert.equal(open.status, 200);
|
||||
});
|
||||
|
||||
const guardianId = `winter-guardian-${RUN_ID}`;
|
||||
await step('a guardian registers a minor student for the camp', async () => {
|
||||
const guardian = await owner.request('POST', '/api/admin/guardians', { guardianId, displayName: 'Winter Camp Guardian', email: `winter-guardian-${RUN_ID}@example.com`, phone: '555-0100' });
|
||||
assert.equal(guardian.status, 201);
|
||||
global.__minorReg = (await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, {
|
||||
guardianId, registrantDisplayName: 'Winter Camp Minor', isMinor: true,
|
||||
emergencyContactName: 'Winter Camp Guardian', emergencyContactPhone: '555-0100',
|
||||
readinessNotes: 'No prior Linux experience, comfortable with a keyboard.',
|
||||
})).body;
|
||||
assert.ok(global.__minorReg.registration_id);
|
||||
});
|
||||
|
||||
await step('an adult registers themselves for the same camp', async () => {
|
||||
global.__adultReg = (await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, {
|
||||
registrantDisplayName: 'Winter Camp Adult', registrantEmail: `winter-adult-${RUN_ID}@example.com`,
|
||||
})).body;
|
||||
assert.ok(global.__adultReg.registration_id);
|
||||
});
|
||||
|
||||
await step('both registrations move through submission and agreement acceptance', async () => {
|
||||
const agreementId = `winter-participation-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'Winter Camp Participation Agreement', bodySummary: 'Plain-language summary — not legal text, see docs/STUDENT_GUARDIAN_DATA_MODEL.md.' });
|
||||
for (const reg of [global.__minorReg, global.__adultReg]) {
|
||||
await owner.request('POST', `/api/admin/registrations/${reg.registration_id}/transition`, { status: 'submitted' });
|
||||
await owner.request('POST', `/api/admin/registrations/${reg.registration_id}/transition`, { status: 'agreements_pending' });
|
||||
}
|
||||
// The minor's guardian accepts on the minor's behalf — the guardian identity already
|
||||
// exists at registration time. The adult accepts as a guardian-style acceptance too here
|
||||
// (accepted_by_guardian_id is really just "who accepted," not guardian-exclusive) since an
|
||||
// adult registrant has no student_id yet until conversion below — a real, if slightly
|
||||
// unusual, present-tense fact about this lifecycle worth noting: self-acceptance as
|
||||
// accepted_by_type='student' is only possible AFTER conversion, when a real student_id
|
||||
// exists (an adult could re-accept post-activation in a future UI; not required by V1).
|
||||
const minorAccept = await owner.request('POST', `/api/admin/registrations/${global.__minorReg.registration_id}/agreements`, { agreementId, acceptedByType: 'guardian', acceptedByGuardianId: guardianId });
|
||||
assert.equal(minorAccept.status, 201);
|
||||
});
|
||||
|
||||
await step('a simulated commerce confirmation (admin-only — no payment code exists or is called)', async () => {
|
||||
for (const reg of [global.__minorReg, global.__adultReg]) {
|
||||
const r = await owner.request('POST', `/api/admin/registrations/${reg.registration_id}/transition`, { status: 'confirmed' });
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
}
|
||||
});
|
||||
|
||||
let minorStudent, adultStudent, minorSession, adultSession;
|
||||
await step('both registrations convert to real enrollments and real cohort membership', async () => {
|
||||
const minorConvert = await owner.request('POST', `/api/admin/registrations/${global.__minorReg.registration_id}/convert`, {});
|
||||
assert.equal(minorConvert.status, 201);
|
||||
minorStudent = minorConvert.body.student;
|
||||
assert.ok(minorStudent._activationToken, 'the instructor must have a real token to relay to a no-email minor');
|
||||
const adultConvert = await owner.request('POST', `/api/admin/registrations/${global.__adultReg.registration_id}/convert`, {});
|
||||
assert.equal(adultConvert.status, 201);
|
||||
adultStudent = adultConvert.body.student;
|
||||
assert.ok(adultStudent._activationToken, 'the adult also gets a real token, delivered to their own email in production');
|
||||
|
||||
const rosterCheck = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
|
||||
assert.equal(rosterCheck.status, 200);
|
||||
const ids = rosterCheck.body.map((s) => s.student_id);
|
||||
assert.ok(ids.includes(minorStudent.student_id) && ids.includes(adultStudent.student_id), 'both converted students must be on the real cohort roster');
|
||||
});
|
||||
|
||||
await step('both students activate their real accounts using their real one-time tokens', async () => {
|
||||
minorSession = makeSession(GW);
|
||||
const minorActivate = await minorSession.request('POST', '/api/student/activate', { token: minorStudent._activationToken, password: 'WinterCampMinor123!' });
|
||||
assert.equal(minorActivate.status, 200, JSON.stringify(minorActivate.body));
|
||||
adultSession = makeSession(GW);
|
||||
const adultActivate = await adultSession.request('POST', '/api/student/activate', { token: adultStudent._activationToken, password: 'WinterCampAdult123!' });
|
||||
assert.equal(adultActivate.status, 200, JSON.stringify(adultActivate.body));
|
||||
});
|
||||
|
||||
let instructorSession, classroomId, minorEnrollmentId;
|
||||
await step('instructor starts a live classroom session and Prepares Labs for the cohort', async () => {
|
||||
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'Winter Camp Day 1' });
|
||||
assert.equal(session.status, 201);
|
||||
classroomId = session.body.classroom_session_id;
|
||||
await owner.request('POST', `/api/admin/classroom-sessions/${classroomId}/start`);
|
||||
const prep = await owner.request('POST', `/api/admin/cohorts/${cohortId}/prepare-labs`);
|
||||
assert.equal(prep.status, 200);
|
||||
});
|
||||
|
||||
await step('the minor student joins the classroom and completes a real ROOT 01 practical', async () => {
|
||||
const join = await minorSession.request('POST', `/api/classroom/${classroomId}/join`);
|
||||
assert.equal(join.status, 200);
|
||||
const { body: enrollBody } = await minorSession.request('GET', `/api/student/programs`);
|
||||
minorEnrollmentId = enrollBody[0].enrollment_id;
|
||||
for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check', 'root-01-m01-a01-lesson', 'root-01-m01-a02-guided-exercise']) {
|
||||
const r = await minorSession.request('POST', `/api/student/activities/${id}/complete`);
|
||||
assert.equal(r.status, 200, `completing ${id}: ${JSON.stringify(r.body)}`);
|
||||
}
|
||||
const launch = await minorSession.request('POST', `/api/student/programs/${minorEnrollmentId}/launch`);
|
||||
assert.equal(launch.status, 200);
|
||||
const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
|
||||
const setup = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/setup`);
|
||||
assert.equal(setup.status, 200);
|
||||
const envRow = (await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/environment`)).body.find((e) => e.status === 'running');
|
||||
const containerName = execSync(
|
||||
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envRow.environment_id}'"`,
|
||||
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
|
||||
).toString().trim();
|
||||
const fs = require('fs');
|
||||
const scriptPath = `/tmp/phase7-winter-hardware-${envRow.environment_id}.sh`;
|
||||
fs.writeFileSync(scriptPath, ['#!/bin/bash', 'set -e', 'mkdir -p /srv/hardware-lab',
|
||||
'CPUS=$(nproc)', "MEM_MB=$(free -m | awk '/Mem:/ {print $2}')",
|
||||
'DISK_GB=$(df -BG / | awk \'NR==2{gsub("G","",$2); print $2}\')',
|
||||
'printf "CPUS=%s\\nMEM_MB=%s\\nDISK_GB=%s\\n" "$CPUS" "$MEM_MB" "$DISK_GB" > /srv/hardware-lab/report.txt'].join('\n'));
|
||||
execSync(`docker cp ${scriptPath} ${containerName}:/tmp/hardware-report.sh && docker exec ${containerName} bash /tmp/hardware-report.sh`, { stdio: 'pipe' });
|
||||
fs.unlinkSync(scriptPath);
|
||||
const validate = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/validate`);
|
||||
assert.equal(validate.status, 200);
|
||||
assert.equal(validate.body.passed, true, 'expected a REAL pass, not a simulated one');
|
||||
const record = await minorSession.request('POST', '/api/progress/record', { scenario_id: 'linux-inspect-hardware', status: 'completed', environment_id: envRow.environment_id });
|
||||
assert.equal(record.status, 200);
|
||||
});
|
||||
|
||||
await step('the minor\'s progress survives logout/login (the actual academic-persistence guarantee this camp depends on)', async () => {
|
||||
await minorSession.request('POST', '/api/logout');
|
||||
const returning = makeSession(GW);
|
||||
await returning.request('POST', '/api/student/login', { identifier: minorStudent.login_identifier, password: 'WinterCampMinor123!' });
|
||||
const { body } = await returning.request('GET', `/api/student/programs/${minorEnrollmentId}/courses`);
|
||||
const mod = body.courses.flatMap((c) => c.modules).find((m) => m.module_id === 'root-01-m01-how-computers-run-software');
|
||||
assert.ok(mod.activities.some((a) => a.complete), 'the real Lab pass must have survived logout/login');
|
||||
});
|
||||
|
||||
await step('instructor sees real progress and roster state for the camp', async () => {
|
||||
const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
|
||||
assert.equal(roster.status, 200);
|
||||
assert.equal(roster.body.length, 2);
|
||||
});
|
||||
|
||||
await step('the offering moves through in_progress to completed', async () => {
|
||||
const inProgress = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'in_progress' });
|
||||
assert.equal(inProgress.status, 200);
|
||||
const completed = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'completed' });
|
||||
assert.equal(completed.status, 200);
|
||||
assert.equal(completed.body.status, 'completed');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 8 Step 32 — the Winter Break commercial-path rehearsal: the website submits real
|
||||
// registrations (via the new /api/integrations/registrations contract), a guardian's agreement
|
||||
// acceptance is recorded through the supported application flow, a future-payment confirmation
|
||||
// is SIMULATED (admin-side transition only — no payment code anywhere), and the full remaining
|
||||
// path (enrollment, cohort, activation, classroom, real Lab work, progress, instructor
|
||||
// visibility) runs for real. Also proves the adult registration path and a genuine two-students-
|
||||
// competing-for-the-final-seat race.
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('../../../platform/test/session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||
}
|
||||
function moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
|
||||
async function integrationPost(path, body) {
|
||||
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 8 Winter Break commercial-path rehearsal — website submission, no real payment\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await step('owner logs in', async () => {
|
||||
const r = await owner.request('POST', '/api/auth/instructor/login', { email: process.env.OWNER_EMAIL, password: process.env.OWNER_PASSWORD });
|
||||
assert.equal(r.status, 200);
|
||||
});
|
||||
|
||||
const locId = `p8-winter-loc-${RUN_ID}`, cohortId = `p8-winter-cohort-${RUN_ID}`, offeringId = `p8-winter-camp-${RUN_ID}`, slug = `p8-winter-linux-systems-${RUN_ID}`, agreementId = `p8-winter-agreement-${RUN_ID}`;
|
||||
|
||||
await step('admin opens the Winter Break offering — location, capacity, instructor, agreement requirement configured', async () => {
|
||||
await owner.request('POST', '/api/admin/locations', { locationId: locId, name: 'Community Room B' });
|
||||
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'Winter Break Cohort', tier: 'root' });
|
||||
const create = await owner.request('POST', '/api/admin/offerings', {
|
||||
offeringId, slug, name: 'Winter Break Linux & Systems Camp', programId: PROGRAM, deliveryMode: 'camp',
|
||||
capacity: 2, locationId: locId, cohortId,
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const instructorCreate = await owner.request('POST', '/api/admin/instructors', { email: `p8-winter-instructor-${RUN_ID}@example.com`, display_name: 'Winter Instructor', role: 'instructor', password: 'WinterP8Pass123!' });
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role: 'teacher' });
|
||||
await owner.request('POST', `/api/admin/cohorts/${cohortId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role_in_cohort: 'teacher' });
|
||||
await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'Winter Camp Participation Agreement' });
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId });
|
||||
const readiness = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
|
||||
assert.ok(readiness.body.checks.find((c) => c.key === 'instructor').ready);
|
||||
assert.ok(readiness.body.checks.find((c) => c.key === 'agreements').ready);
|
||||
});
|
||||
|
||||
await step('admin publishes and opens registration', async () => {
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
|
||||
const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
|
||||
assert.equal(open.status, 200);
|
||||
});
|
||||
|
||||
let minorRegId, minorGuardianId;
|
||||
await step('website integration submits a REAL minor registration (guardian created/associated), confirmation email queued', async () => {
|
||||
const submit = await integrationPost('/api/integrations/registrations', {
|
||||
event_id: `p8-winter-minor-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Winter Camper', is_minor: true },
|
||||
guardian: { display_name: 'Winter Guardian', email: `winter-guardian-${RUN_ID}@example.com`, phone: '555-0101' },
|
||||
});
|
||||
assert.equal(submit.status, 201, JSON.stringify(submit.body));
|
||||
minorRegId = submit.body.registration_id;
|
||||
const reg = await owner.request('GET', `/api/admin/registrations/${minorRegId}`);
|
||||
minorGuardianId = reg.body.guardian_id;
|
||||
assert.ok(minorGuardianId, 'a real guardian must have been created and linked by the integration endpoint');
|
||||
const notifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
|
||||
assert.ok(notifs.body.some((n) => n.notification_type === 'registration_received'), 'a registration_received confirmation must be queued');
|
||||
});
|
||||
|
||||
await step('guardian acceptance is recorded through the supported application flow — agreement complete', async () => {
|
||||
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'submitted' });
|
||||
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'agreements_pending' });
|
||||
const agreementNotifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
|
||||
assert.ok(agreementNotifs.body.some((n) => n.notification_type === 'agreement_required'), 'an agreement_required notice must be queued when entering agreements_pending');
|
||||
const accept = await owner.request('POST', `/api/admin/registrations/${minorRegId}/agreements`, { agreementId, acceptedByType: 'guardian', acceptedByGuardianId: minorGuardianId });
|
||||
assert.equal(accept.status, 201);
|
||||
const status = await owner.request('GET', `/api/admin/registrations/${minorRegId}/agreement-status`);
|
||||
assert.equal(status.body.missing.length, 0);
|
||||
});
|
||||
|
||||
await step('future payment confirmation is SIMULATED (admin-only transition) — no payment code anywhere', async () => {
|
||||
const confirm = await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'confirmed' });
|
||||
assert.equal(confirm.status, 200);
|
||||
const confirmedNotifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
|
||||
assert.ok(confirmedNotifs.body.some((n) => n.notification_type === 'registration_confirmed'));
|
||||
});
|
||||
|
||||
let minorStudent, minorSession, minorEnrollmentId;
|
||||
await step('registration converts to real enrollment + real cohort membership; account activates', async () => {
|
||||
const convert = await owner.request('POST', `/api/admin/registrations/${minorRegId}/convert`, {});
|
||||
assert.equal(convert.status, 201, JSON.stringify(convert.body));
|
||||
minorStudent = convert.body.student;
|
||||
assert.ok(minorStudent._activationToken);
|
||||
const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
|
||||
assert.ok(roster.body.some((s) => s.student_id === minorStudent.student_id));
|
||||
minorSession = makeSession(GW);
|
||||
const activate = await minorSession.request('POST', '/api/student/activate', { token: minorStudent._activationToken, password: 'WinterP8Minor123!' });
|
||||
assert.equal(activate.status, 200);
|
||||
});
|
||||
|
||||
await step('adult registration path — self-registers, no guardian required, converts independently', async () => {
|
||||
const submit = await integrationPost('/api/integrations/registrations', {
|
||||
event_id: `p8-winter-adult-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Winter Adult', email: `winter-adult-${RUN_ID}@example.com`, is_minor: false },
|
||||
});
|
||||
assert.equal(submit.status, 201);
|
||||
const regId = submit.body.registration_id;
|
||||
await owner.request('POST', `/api/admin/registrations/${regId}/agreements`, { agreementId, acceptedByType: 'registrant' });
|
||||
await owner.request('POST', `/api/admin/registrations/${regId}/transition`, { status: 'submitted' });
|
||||
await owner.request('POST', `/api/admin/registrations/${regId}/transition`, { status: 'confirmed' });
|
||||
const convert = await owner.request('POST', `/api/admin/registrations/${regId}/convert`, {});
|
||||
assert.equal(convert.status, 201);
|
||||
assert.equal(convert.body.student.email, `winter-adult-${RUN_ID}@example.com`);
|
||||
});
|
||||
|
||||
await step('two students compete for the final seat — exactly one wins, offering reports full, no overbooking', async () => {
|
||||
const before = await owner.request('GET', `/api/admin/offerings/${offeringId}`);
|
||||
assert.equal(before.body.reserved_seats, before.body.capacity, 'capacity=2 should already be exhausted by the minor + adult above');
|
||||
const [a, b] = await Promise.all([
|
||||
integrationPost('/api/integrations/registrations', { event_id: `p8-winter-race-a-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race A', email: `p8-race-a-${RUN_ID}@example.com` } }),
|
||||
integrationPost('/api/integrations/registrations', { event_id: `p8-winter-race-b-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race B', email: `p8-race-b-${RUN_ID}@example.com` } }),
|
||||
]);
|
||||
const statuses = [a.status, b.status].sort();
|
||||
assert.deepEqual(statuses, [409, 409], 'both must be rejected — the seat was already full before either request, this proves no overbooking beyond capacity');
|
||||
const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json());
|
||||
assert.equal(pub.availability, 'full');
|
||||
});
|
||||
|
||||
await step('instructor starts a live classroom session and Prepares Labs', async () => {
|
||||
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'Winter Camp Day 1' });
|
||||
assert.equal(session.status, 201);
|
||||
global.__classroomId = session.body.classroom_session_id;
|
||||
await owner.request('POST', `/api/admin/classroom-sessions/${global.__classroomId}/start`);
|
||||
const prep = await owner.request('POST', `/api/admin/cohorts/${cohortId}/prepare-labs`);
|
||||
assert.equal(prep.status, 200);
|
||||
});
|
||||
|
||||
await step('the minor student joins, completes real orientation + a REAL Lab task, progress persists', async () => {
|
||||
const join = await minorSession.request('POST', `/api/classroom/${global.__classroomId}/join`);
|
||||
assert.equal(join.status, 200);
|
||||
const { body: enrollBody } = await minorSession.request('GET', '/api/student/programs');
|
||||
minorEnrollmentId = enrollBody[0].enrollment_id;
|
||||
for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check', 'root-01-m01-a01-lesson', 'root-01-m01-a02-guided-exercise']) {
|
||||
const r = await minorSession.request('POST', `/api/student/activities/${id}/complete`);
|
||||
assert.equal(r.status, 200, `completing ${id}: ${JSON.stringify(r.body)}`);
|
||||
}
|
||||
const launch = await minorSession.request('POST', `/api/student/programs/${minorEnrollmentId}/launch`);
|
||||
assert.equal(launch.status, 200);
|
||||
const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
|
||||
const setup = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/setup`);
|
||||
assert.equal(setup.status, 200);
|
||||
const envRow = (await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/environment`)).body.find((e) => e.status === 'running');
|
||||
const containerName = execSync(
|
||||
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envRow.environment_id}'"`,
|
||||
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
|
||||
).toString().trim();
|
||||
const fs = require('fs');
|
||||
const scriptPath = `/tmp/phase8-winter-hardware-${envRow.environment_id}.sh`;
|
||||
fs.writeFileSync(scriptPath, ['#!/bin/bash', 'set -e', 'mkdir -p /srv/hardware-lab',
|
||||
'CPUS=$(nproc)', "MEM_MB=$(free -m | awk '/Mem:/ {print $2}')",
|
||||
'DISK_GB=$(df -BG / | awk \'NR==2{gsub("G","",$2); print $2}\')',
|
||||
'printf "CPUS=%s\\nMEM_MB=%s\\nDISK_GB=%s\\n" "$CPUS" "$MEM_MB" "$DISK_GB" > /srv/hardware-lab/report.txt'].join('\n'));
|
||||
execSync(`docker cp ${scriptPath} ${containerName}:/tmp/hardware-report.sh && docker exec ${containerName} bash /tmp/hardware-report.sh`, { stdio: 'pipe' });
|
||||
fs.unlinkSync(scriptPath);
|
||||
const validate = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/validate`);
|
||||
assert.equal(validate.status, 200);
|
||||
assert.equal(validate.body.passed, true);
|
||||
await minorSession.request('POST', '/api/progress/record', { scenario_id: 'linux-inspect-hardware', status: 'completed', environment_id: envRow.environment_id });
|
||||
});
|
||||
|
||||
await step('instructor sees the real roster and progress', async () => {
|
||||
const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
|
||||
assert.equal(roster.body.length, 2);
|
||||
const { body } = await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/courses?program_id=${PROGRAM}`);
|
||||
const mod = moduleById(body, 'root-01-m01-how-computers-run-software');
|
||||
assert.ok(mod.activities.some((a) => a.complete), 'the instructor must see the minor\'s real Lab progress');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
+15
-2
@@ -106,10 +106,23 @@ further instructor accounts from the Instructors tab).
|
||||
prerequisite graph (no cycles, no missing nodes), the strengthened Capstone gate (ROOT 09 AND
|
||||
ROOT 12 both required) in both directions, the new orientation module, and an explicit check
|
||||
that no age/grade gate exists anywhere in enrollment.
|
||||
- `test/e2e-phase7.js` — organizations/locations, guardians and student-guardian relationships,
|
||||
curriculum packages (reference-only, never a content clone), offerings (capacity, server-
|
||||
defined status transitions), the registration lifecycle through agreement acceptance and
|
||||
conversion to a real enrollment, registration/guardian RBAC, explicit age-guidance-is-never-
|
||||
an-authorization-check verification, and confirmation that existing ROOT_ V1/self-paced/
|
||||
classroom flows are unaffected.
|
||||
- `test/e2e-phase8.js` — offering launch readiness, server-authoritative registration open/
|
||||
closed enforcement, capacity concurrency through the real registration endpoints, the public
|
||||
offering serializer (no internal/PII leakage), the website registration integration contract
|
||||
(sequential and concurrent idempotency), required-agreement conversion blocking with version
|
||||
snapshotting, guardian notification recipient resolution, and confirmation that ROOT_ V1/self-
|
||||
paced/classroom flows remain unaffected.
|
||||
|
||||
`infra/scripts/drills/` also holds real-Lab dress rehearsals per phase (Phase 6D/6E foundations,
|
||||
Phase 6F's multi-student pilot and full end-to-end V1 journey) — see each script's own header
|
||||
and `docs/PHASE6F_ROOT_V1_PILOT_READINESS.md` for what they prove beyond what the e2e suites
|
||||
Phase 6F's multi-student pilot and full end-to-end V1 journey, Phase 7/8's Winter Break Camp
|
||||
rehearsals) — see each script's own header and `docs/PHASE6F_ROOT_V1_PILOT_READINESS.md`/`docs/
|
||||
PHASE8_ENROLLMENT_OPERATIONS.md` for what they prove beyond what the e2e suites
|
||||
above already cover via direct API.
|
||||
|
||||
All e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Phase 7 §7/§8/§10 — the business/product architecture's first-class organization, location,
|
||||
-- and guardian concepts. Additive only: no existing table's meaning changes, no existing row's
|
||||
-- data moves. See docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md and
|
||||
-- docs/STUDENT_GUARDIAN_DATA_MODEL.md for the full reasoning.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- programs.family widened for BOOTCAMP_ (Step 15/21) — a program row can now describe a
|
||||
-- seasonal/intensive offering family too. No existing row's family value changes; this is a
|
||||
-- pure widening of what's allowed going forward.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE programs DROP CONSTRAINT IF EXISTS programs_family_check;
|
||||
ALTER TABLE programs ADD CONSTRAINT programs_family_check CHECK (family IN ('init', 'compile', 'root', 'bootcamp'));
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- organizations — a business/delivery relationship (a school, library, homeschool group,
|
||||
-- community partner, workforce-development partner), NOT a SaaS tenant. RootNode Academy
|
||||
-- remains one Academy; no other table gets an organization_id foreign key added to it wholesale
|
||||
-- — only offerings and locations reference one, and only optionally (Step 7's explicit
|
||||
-- "do not move all RootNode data under organization_id").
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
organization_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'other' CHECK (type IN ('school', 'library', 'homeschool_group', 'community', 'workforce_partner', 'other')),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||||
contact_name TEXT,
|
||||
contact_email TEXT,
|
||||
contact_phone TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- locations — a physical or virtual delivery location. organization_id is nullable: a location
|
||||
-- can be a RootNode-run venue with no partner organization at all (e.g. a rented community-
|
||||
-- center room booked directly), or "online" (type='online', every address field null).
|
||||
-- access_notes is deliberately never exposed on any student-facing route — internal
|
||||
-- operational detail only (parking instructions, building access codes, etc).
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS locations (
|
||||
location_id TEXT PRIMARY KEY,
|
||||
organization_id TEXT REFERENCES organizations(organization_id),
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'online' CHECK (type IN ('school', 'library', 'community_center', 'partner_facility', 'online', 'other')),
|
||||
address_line1 TEXT,
|
||||
address_line2 TEXT,
|
||||
city TEXT,
|
||||
region TEXT,
|
||||
postal_code TEXT,
|
||||
country TEXT,
|
||||
timezone TEXT,
|
||||
capacity INTEGER,
|
||||
access_notes TEXT, -- internal/operational only — never returned by a student-facing route
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- guardians / student_guardians — the proper foundation replacing the Phase 5C
|
||||
-- `enrollments.metadata.contact_email` workaround (left in place, unchanged, for backward
|
||||
-- compatibility with the live website integration — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md).
|
||||
-- A guardian is its own identity, separate from students.email, so a parent with multiple
|
||||
-- enrolled children is ONE guardian row linked to MANY students, not a duplicated email per
|
||||
-- child. Explicitly NOT a login/auth identity in this phase — no password, no session, no
|
||||
-- portal access — see docs/STUDENT_GUARDIAN_DATA_MODEL.md for what was deliberately deferred.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS guardians (
|
||||
guardian_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS student_guardians (
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
guardian_id TEXT NOT NULL REFERENCES guardians(guardian_id),
|
||||
relationship TEXT NOT NULL DEFAULT 'guardian' CHECK (relationship IN ('parent', 'guardian', 'authorized_contact', 'emergency_contact')),
|
||||
is_primary_contact BOOLEAN NOT NULL DEFAULT false,
|
||||
can_receive_communications BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (student_id, guardian_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_student_guardians_guardian ON student_guardians(guardian_id);
|
||||
@@ -0,0 +1,148 @@
|
||||
-- Phase 7 §3/§12/§14/§20 — offerings (a purchasable/enrollable delivery of a program),
|
||||
-- registrations (pre-enrollment application/interest, separate from academic entitlement),
|
||||
-- agreements (versioned acceptance records, not legal text), and curriculum packages (a named,
|
||||
-- versioned reference list into EXISTING courses — never a content clone). See
|
||||
-- docs/OFFERING_AND_REGISTRATION_MODEL.md for the full reasoning and lifecycle diagrams.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- curriculum_packages / curriculum_package_items — a named, versioned SUBSET reference into
|
||||
-- existing courses. curriculum_package_items stores no content of its own, only which
|
||||
-- course_id(s) (whole courses, matching how V1's own course granularity already works) belong
|
||||
-- to the package and in what order — exactly the same "reference, never clone" principle
|
||||
-- COURSE_CONTENT_MODEL.md already established for lab activities referencing scenario_id.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS curriculum_packages (
|
||||
curriculum_package_id TEXT PRIMARY KEY, -- slug, e.g. 'winter-linux-systems-v1'
|
||||
name TEXT NOT NULL,
|
||||
program_id TEXT NOT NULL REFERENCES programs(program_id),
|
||||
description TEXT,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS curriculum_package_items (
|
||||
curriculum_package_id TEXT NOT NULL REFERENCES curriculum_packages(curriculum_package_id),
|
||||
course_id TEXT NOT NULL REFERENCES courses(course_id),
|
||||
sequence INTEGER NOT NULL,
|
||||
PRIMARY KEY (curriculum_package_id, course_id)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- offerings — connects a program (+ optional curriculum_package subset) to a delivery format,
|
||||
-- schedule, capacity, and location/organization/cohort. Status transitions are validated
|
||||
-- server-side only (offerings.js) — this table has no trigger enforcing them because the
|
||||
-- application layer is the single writer (admin routes), matching every other status-machine
|
||||
-- table in this schema (enrollments, classroom_sessions) which also rely on server-side
|
||||
-- transition logic rather than a DB trigger.
|
||||
--
|
||||
-- reserved_seats is a maintained counter, not derived via COUNT(*) at read time — capacity
|
||||
-- reservation (offerings.js's reserveSeat()) increments it with a single atomic
|
||||
-- `UPDATE ... WHERE reserved_seats < capacity RETURNING ...`, avoiding the
|
||||
-- SELECT-count-then-INSERT race explicitly flagged in this phase's authorization.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS offerings (
|
||||
offering_id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
program_id TEXT NOT NULL REFERENCES programs(program_id),
|
||||
curriculum_package_id TEXT REFERENCES curriculum_packages(curriculum_package_id),
|
||||
delivery_mode TEXT NOT NULL CHECK (delivery_mode IN ('self_paced', 'cohort', 'hybrid', 'camp', 'workshop', 'school', 'after_school', 'homeschool', 'community', 'one_to_one')),
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'enrollment_open', 'full', 'in_progress', 'completed', 'cancelled')),
|
||||
starts_at TIMESTAMPTZ,
|
||||
ends_at TIMESTAMPTZ,
|
||||
enrollment_opens_at TIMESTAMPTZ,
|
||||
enrollment_closes_at TIMESTAMPTZ,
|
||||
capacity INTEGER, -- NULL = unlimited (typical for self_paced)
|
||||
reserved_seats INTEGER NOT NULL DEFAULT 0,
|
||||
location_id TEXT REFERENCES locations(location_id),
|
||||
organization_id TEXT REFERENCES organizations(organization_id),
|
||||
cohort_id TEXT REFERENCES cohorts(cohort_id),
|
||||
recommended_min_age INTEGER, -- guidance only, never an authorization gate (Step 21)
|
||||
recommended_max_age INTEGER,
|
||||
description TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb, -- room for descriptive price/marketing fields later, never payment data
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_by UUID REFERENCES instructors(instructor_id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (capacity IS NULL OR reserved_seats <= capacity)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_offerings_program ON offerings(program_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_offerings_status ON offerings(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS instructor_offerings (
|
||||
offering_id TEXT NOT NULL REFERENCES offerings(offering_id),
|
||||
instructor_id UUID NOT NULL REFERENCES instructors(instructor_id),
|
||||
role TEXT NOT NULL DEFAULT 'teacher', -- reuses cohort_instructors' own role_in_cohort vocabulary — see admin-offerings-routes.js
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (offering_id, instructor_id)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- offering_registrations — pre-enrollment interest/application, separate from `enrollments`
|
||||
-- (academic entitlement, unchanged). A registration MAY exist with no student_id yet (a
|
||||
-- guardian registering a not-yet-created student) and is converted to a real enrollment only at
|
||||
-- `confirmed` -> `enrollment_activated`. No payment fields — that boundary belongs to a future
|
||||
-- commerce layer entirely (Step 23).
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS offering_registrations (
|
||||
registration_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
offering_id TEXT NOT NULL REFERENCES offerings(offering_id),
|
||||
student_id TEXT REFERENCES students(student_id), -- null until matched/created
|
||||
guardian_id TEXT REFERENCES guardians(guardian_id), -- who registered, if a minor
|
||||
registrant_display_name TEXT NOT NULL, -- captured even before a student record exists
|
||||
registrant_email TEXT,
|
||||
is_minor BOOLEAN NOT NULL DEFAULT false,
|
||||
status TEXT NOT NULL DEFAULT 'started' CHECK (status IN ('started', 'submitted', 'reviewed', 'agreements_pending', 'payment_pending', 'confirmed', 'enrollment_activated', 'cancelled')),
|
||||
readiness_notes TEXT,
|
||||
accessibility_notes TEXT,
|
||||
emergency_contact_name TEXT,
|
||||
emergency_contact_phone TEXT,
|
||||
enrollment_id UUID REFERENCES enrollments(enrollment_id), -- set once converted
|
||||
source TEXT NOT NULL DEFAULT 'admin',
|
||||
external_reference TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (NOT is_minor OR guardian_id IS NOT NULL) -- a minor's registration must always carry a responsible guardian
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_offering_registrations_offering ON offering_registrations(offering_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_offering_registrations_status ON offering_registrations(status);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- agreement_definitions / agreement_acceptances — a versioned ACCEPTANCE RECORD model only.
|
||||
-- No legal text is authored here (body_summary is a plain-language description for admin UI
|
||||
-- display, explicitly NOT attorney-approved legal language — see
|
||||
-- docs/STUDENT_GUARDIAN_DATA_MODEL.md's "requires legal review" list). accepted_version is
|
||||
-- captured on the acceptance row (not just looked up from the live definition) so a later
|
||||
-- edit to an agreement's text/version never retroactively reinterprets what someone actually
|
||||
-- agreed to — the same "session snapshots its own version" principle
|
||||
-- COURSE_CONTENT_MODEL.md §6b already established for assessments.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS agreement_definitions (
|
||||
agreement_id TEXT PRIMARY KEY, -- slug, e.g. 'participation-agreement'
|
||||
title TEXT NOT NULL,
|
||||
body_summary TEXT, -- plain-language description, NOT legal text
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
requires_guardian_for_minor BOOLEAN NOT NULL DEFAULT true,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agreement_acceptances (
|
||||
acceptance_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agreement_id TEXT NOT NULL REFERENCES agreement_definitions(agreement_id),
|
||||
accepted_version INTEGER NOT NULL,
|
||||
accepted_by_type TEXT NOT NULL CHECK (accepted_by_type IN ('student', 'guardian', 'instructor_on_behalf')),
|
||||
accepted_by_student_id TEXT REFERENCES students(student_id),
|
||||
accepted_by_guardian_id TEXT REFERENCES guardians(guardian_id),
|
||||
registration_id UUID REFERENCES offering_registrations(registration_id),
|
||||
enrollment_id UUID REFERENCES enrollments(enrollment_id),
|
||||
ip_address TEXT,
|
||||
accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (accepted_by_student_id IS NOT NULL OR accepted_by_guardian_id IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agreement_acceptances_registration ON agreement_acceptances(registration_id);
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Phase 8 — enrollment operations foundation: registration-stage notifications, a clean
|
||||
-- pre-account agreement-acceptance type, offering agreement requirements, and idempotent
|
||||
-- website registration submission. All additive/widening — no existing row's meaning changes,
|
||||
-- no existing constraint gets stricter. See docs/PHASE8_ENROLLMENT_OPERATIONS.md.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- notification_events — a registration may need a notification (Registration Received, Agreement
|
||||
-- Required) before any student row exists at all (a brand-new guardian-submitted registration).
|
||||
-- student_id is widened to nullable and a registration_id column added; a CHECK guarantees every
|
||||
-- row still has SOME real subject. Every existing row already has student_id set, so this is a
|
||||
-- pure widening — nothing about student-scoped notification behavior changes.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE notification_events ALTER COLUMN student_id DROP NOT NULL;
|
||||
ALTER TABLE notification_events ADD COLUMN IF NOT EXISTS registration_id UUID REFERENCES offering_registrations(registration_id);
|
||||
ALTER TABLE notification_events DROP CONSTRAINT IF EXISTS notification_events_subject_check;
|
||||
ALTER TABLE notification_events ADD CONSTRAINT notification_events_subject_check CHECK (student_id IS NOT NULL OR registration_id IS NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_events_registration ON notification_events(registration_id);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- agreement_acceptances — a 4th accepted_by_type, 'registrant', for the one real gap Phase 7's
|
||||
-- own limitations list named: an adult accepting an agreement AT REGISTRATION TIME, before their
|
||||
-- eventual student_id exists. Tied to registration_id only (no student/guardian id required) —
|
||||
-- the CHECK is widened to accept that shape too. 'student'/'guardian'/'instructor_on_behalf'
|
||||
-- (admin-recorded on someone's behalf) are unchanged and already covered Step 7's "student /
|
||||
-- guardian / admin-instructor" minimum bar; 'registrant' closes the actual documented gap.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE agreement_acceptances DROP CONSTRAINT IF EXISTS agreement_acceptances_accepted_by_type_check;
|
||||
ALTER TABLE agreement_acceptances ADD CONSTRAINT agreement_acceptances_accepted_by_type_check
|
||||
CHECK (accepted_by_type IN ('student', 'guardian', 'instructor_on_behalf', 'registrant'));
|
||||
ALTER TABLE agreement_acceptances DROP CONSTRAINT IF EXISTS agreement_acceptances_check;
|
||||
ALTER TABLE agreement_acceptances ADD CONSTRAINT agreement_acceptances_check
|
||||
CHECK (accepted_by_student_id IS NOT NULL OR accepted_by_guardian_id IS NOT NULL OR (accepted_by_type = 'registrant' AND registration_id IS NOT NULL));
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- offering_agreement_requirements — which agreements a given offering actually requires before
|
||||
-- its registrations may convert to enrollment (Step 18). A plain join table, not a new status
|
||||
-- machine — offerings.js checks it at conversion time.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS offering_agreement_requirements (
|
||||
offering_id TEXT NOT NULL REFERENCES offerings(offering_id),
|
||||
agreement_id TEXT NOT NULL REFERENCES agreement_definitions(agreement_id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (offering_id, agreement_id)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- offering_registrations — a real unique index on external_reference (nulls excluded) is what
|
||||
-- actually makes website registration submission idempotent under concurrent retries, the exact
|
||||
-- same pattern enrollments.external_event_id already established in Phase 5B — a DB-level
|
||||
-- constraint, not a SELECT-then-INSERT check.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_offering_registrations_external_reference
|
||||
ON offering_registrations(external_reference) WHERE external_reference IS NOT NULL;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Phase 8 — self-caught correction to 035_phase8_enrollment_operations.sql, found via this
|
||||
-- phase's own smoke testing before any commit: notification_events.notification_type carries a
|
||||
-- CHECK constraint (Phase 5C) that never anticipated registration-stage notification types.
|
||||
-- Every offerings.js call to notifications.enqueueForRegistration() was silently failing (caught
|
||||
-- and swallowed by the deliberate "a notification failure must never block the real action"
|
||||
-- try/catch) until this was found — no registration-stage notification had ever actually been
|
||||
-- queued. Pure widening: every existing notification_type value stays valid, nothing about
|
||||
-- Phase 5C's own four types changes.
|
||||
ALTER TABLE notification_events DROP CONSTRAINT IF EXISTS notification_events_notification_type_check;
|
||||
ALTER TABLE notification_events ADD CONSTRAINT notification_events_notification_type_check
|
||||
CHECK (notification_type IN (
|
||||
'activation', 'enrollment_confirmation', 'password_reset', 'password_changed',
|
||||
'registration_received', 'registration_confirmed', 'registration_needs_action',
|
||||
'agreement_required', 'camp_operational_update'
|
||||
));
|
||||
@@ -0,0 +1,207 @@
|
||||
// Phase 7 — admin/owner routes for organizations, locations, guardians, curriculum packages,
|
||||
// offerings, registrations, and agreements. RBAC convention matches admin-programs-routes.js:
|
||||
// authoring (create/transition) is admin/owner only; any instructor can list/view non-sensitive
|
||||
// data (organizations, locations, offerings). Guardian and registration detail are admin/owner
|
||||
// only — Step 17's explicit "plain instructors should not automatically get access to guardian/
|
||||
// private registration details" — a plain instructor still sees offering rosters/progress
|
||||
// through the existing cohort-scoped routes, just not guardian contact info or registration
|
||||
// notes (readiness/accessibility/emergency-contact) directly.
|
||||
|
||||
const offerings = require('./offerings');
|
||||
const notifications = require('./notifications');
|
||||
const studentsAuth = require('./students-auth'); // same direct-require convention integration-routes.js already uses
|
||||
|
||||
function errStatus(code) {
|
||||
if (code === 'NOT_FOUND' || code === 'PROGRAM_NOT_FOUND' || code === 'OFFERING_NOT_FOUND' || code === 'COURSE_NOT_FOUND' || code === 'AGREEMENT_NOT_FOUND') return 404;
|
||||
if (code === 'VALIDATION' || code === 'GUARDIAN_REQUIRED') return 400;
|
||||
if (code === 'DUPLICATE_SLUG' || code === 'OFFERING_FULL' || code === 'INVALID_TRANSITION' || code === 'INVALID_STATE'
|
||||
|| code === 'OFFERING_NOT_OPEN' || code === 'OFFERING_CANCELLED' || code === 'OFFERING_COMPLETED'
|
||||
|| code === 'REGISTRATION_NOT_YET_OPEN' || code === 'REGISTRATION_CLOSED' || code === 'DUPLICATE_REGISTRATION'
|
||||
|| code === 'AGREEMENTS_INCOMPLETE') return 409;
|
||||
return 500;
|
||||
}
|
||||
|
||||
module.exports = function registerAdminOfferingsRoutes(app, deps) {
|
||||
const { instructorSession, rbac, getPool, recordAudit } = deps;
|
||||
const { requireAnyInstructor, requireAdminOrOwner } = rbac;
|
||||
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
|
||||
}
|
||||
const admin = {
|
||||
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
|
||||
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
|
||||
};
|
||||
function handleError(res, e) {
|
||||
if (e.code) return res.status(errStatus(e.code)).json({ error: e.code, message: e.message });
|
||||
throw e;
|
||||
}
|
||||
|
||||
// ── Organizations / Locations ──────────────────────────────────────────────────────────
|
||||
admin.post('/api/admin/organizations', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const org = await offerings.createOrganization(getPool(), req.body);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'organization_create', resourceType: 'organization', resourceId: org.organization_id, result: 'success', sourceIp: req.ip });
|
||||
res.status(201).json(org);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/organizations', requireAnyInstructor, async (req, res) => {
|
||||
res.json(await offerings.listOrganizations(getPool()));
|
||||
});
|
||||
|
||||
admin.post('/api/admin/locations', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const loc = await offerings.createLocation(getPool(), req.body);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'location_create', resourceType: 'location', resourceId: loc.location_id, result: 'success', sourceIp: req.ip });
|
||||
res.status(201).json(loc);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/locations', requireAnyInstructor, async (req, res) => {
|
||||
// access_notes only for admin/owner — a plain instructor sees the location, not its
|
||||
// internal operational notes (Step 8).
|
||||
const forInstructor = req.instructor.role === 'admin' || req.instructor.role === 'owner';
|
||||
res.json(await offerings.listLocations(getPool(), { forInstructor }));
|
||||
});
|
||||
|
||||
// ── Guardians (admin/owner only — sensitive contact data) ──────────────────────────────
|
||||
admin.post('/api/admin/guardians', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const g = await offerings.createGuardian(getPool(), req.body);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'guardian_create', resourceType: 'guardian', resourceId: g.guardian_id, result: 'success', sourceIp: req.ip });
|
||||
res.status(201).json(g);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.post('/api/admin/students/:id/guardians', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const link = await offerings.linkStudentGuardian(getPool(), { ...req.body, studentId: req.params.id });
|
||||
res.status(201).json(link);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/students/:id/guardians', requireAdminOrOwner, async (req, res) => {
|
||||
res.json(await offerings.listGuardiansForStudent(getPool(), req.params.id));
|
||||
});
|
||||
|
||||
// ── Curriculum packages ──────────────────────────────────────────────────────────────────
|
||||
admin.post('/api/admin/curriculum-packages', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const pkg = await offerings.createCurriculumPackage(getPool(), req.body);
|
||||
res.status(201).json(pkg);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.post('/api/admin/curriculum-packages/:id/items', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const item = await offerings.addCurriculumPackageItem(getPool(), { ...req.body, curriculumPackageId: req.params.id });
|
||||
res.status(201).json(item);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/curriculum-packages/:id', requireAnyInstructor, async (req, res) => {
|
||||
const pkg = await offerings.getCurriculumPackage(getPool(), req.params.id);
|
||||
if (!pkg) return res.status(404).json({ error: 'NOT_FOUND' });
|
||||
res.json(pkg);
|
||||
});
|
||||
|
||||
// ── Offerings ─────────────────────────────────────────────────────────────────────────────
|
||||
admin.post('/api/admin/offerings', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const offering = await offerings.createOffering(getPool(), { ...req.body, createdBy: req.instructor.instructor_id });
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'offering_create', resourceType: 'offering', resourceId: offering.offering_id, result: 'success', sourceIp: req.ip });
|
||||
res.status(201).json(offering);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/offerings', requireAnyInstructor, async (req, res) => {
|
||||
res.json(await offerings.listOfferings(getPool(), { status: req.query.status }));
|
||||
});
|
||||
admin.get('/api/admin/offerings/:id', requireAnyInstructor, async (req, res) => {
|
||||
const offering = await offerings.getOffering(getPool(), req.params.id);
|
||||
if (!offering) return res.status(404).json({ error: 'NOT_FOUND' });
|
||||
res.json(offering);
|
||||
});
|
||||
admin.post('/api/admin/offerings/:id/transition', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const offering = await offerings.transitionOffering(getPool(), req.params.id, req.body.status);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'offering_transition', resourceType: 'offering', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { to: req.body.status } });
|
||||
res.json(offering);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.post('/api/admin/offerings/:id/instructors', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const link = await offerings.assignInstructorToOffering(getPool(), req.params.id, req.body.instructor_id, req.body.role);
|
||||
res.status(201).json(link);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/offerings/:id/readiness', requireAnyInstructor, async (req, res) => {
|
||||
try {
|
||||
res.json(await offerings.computeOfferingReadiness(getPool(), req.params.id));
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.post('/api/admin/offerings/:id/agreement-requirements', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const link = await offerings.addAgreementRequirement(getPool(), req.params.id, req.body.agreement_id);
|
||||
res.status(201).json(link);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/offerings/:id/agreement-requirements', requireAnyInstructor, async (req, res) => {
|
||||
res.json(await offerings.listAgreementRequirementsForOffering(getPool(), req.params.id));
|
||||
});
|
||||
|
||||
// ── Registrations (admin/owner only) ────────────────────────────────────────────────────
|
||||
// offerings.createRegistration() itself now does validate -> reserve -> insert -> notify, in
|
||||
// that exact order (see its own header comment) — no route-level orchestration needed here
|
||||
// any more.
|
||||
admin.post('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const reg = await offerings.createRegistration(getPool(), { ...req.body, offeringId: req.params.id });
|
||||
res.status(201).json(reg);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => {
|
||||
res.json(await offerings.listRegistrationsForOffering(getPool(), req.params.id));
|
||||
});
|
||||
admin.get('/api/admin/registrations/:id', requireAdminOrOwner, async (req, res) => {
|
||||
const reg = await offerings.getRegistration(getPool(), req.params.id);
|
||||
if (!reg) return res.status(404).json({ error: 'NOT_FOUND' });
|
||||
res.json(reg);
|
||||
});
|
||||
admin.get('/api/admin/registrations/:id/agreement-status', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
res.json(await offerings.getRegistrationAgreementStatus(getPool(), req.params.id));
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/registrations/:id/notifications', requireAdminOrOwner, async (req, res) => {
|
||||
const rows = await notifications.listForRegistration(getPool(), req.params.id);
|
||||
res.json(rows.map(notifications.toAdminView));
|
||||
});
|
||||
admin.post('/api/admin/registrations/:id/transition', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const reg = await offerings.transitionRegistration(getPool(), req.params.id, req.body.status);
|
||||
res.json(reg);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.post('/api/admin/registrations/:id/convert', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const result = await offerings.convertRegistrationToEnrollment(getPool(), studentsAuth, req.params.id, req.body || {});
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'registration_convert', resourceType: 'registration', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.status(201).json(result);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
|
||||
// ── Agreements ────────────────────────────────────────────────────────────────────────────
|
||||
admin.post('/api/admin/agreements', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const def = await offerings.createAgreementDefinition(getPool(), req.body);
|
||||
res.status(201).json(def);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/agreements', requireAnyInstructor, async (req, res) => {
|
||||
res.json(await offerings.listAgreementDefinitions(getPool()));
|
||||
});
|
||||
admin.post('/api/admin/registrations/:id/agreements', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const acceptance = await offerings.recordAgreementAcceptance(getPool(), { ...req.body, registrationId: req.params.id, ipAddress: req.ip });
|
||||
res.status(201).json(acceptance);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/registrations/:id/agreements', requireAdminOrOwner, async (req, res) => {
|
||||
res.json(await offerings.listAcceptancesForRegistration(getPool(), req.params.id));
|
||||
});
|
||||
};
|
||||
@@ -131,4 +131,76 @@ If you didn't make this change, contact your instructor right away — your othe
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
module.exports = { activation, enrollmentConfirmation, passwordReset, passwordChanged, escapeHtml };
|
||||
// Phase 8 §10 — registration-stage transactional templates. Same conventions as above:
|
||||
// escapeHtml on every caller-supplied value (offeringName/displayName can come from a website
|
||||
// registration submission, not just admin-typed text), no tracking, no marketing content, both
|
||||
// text and html written deliberately.
|
||||
|
||||
function registrationReceived({ displayName, offeringName }) {
|
||||
const subject = `We Received Your Registration — ${offeringName}`;
|
||||
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName);
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
We received your registration for ${offeringName} at ${BRAND}. We'll follow up if anything else is needed before it's confirmed.
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`<p>Hi ${safeName},</p><p>We received your registration for <strong>${safeOffering}</strong> at ${BRAND}. We'll follow up if anything else is needed before it's confirmed.</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
function registrationConfirmed({ displayName, offeringName }) {
|
||||
const subject = `Registration Confirmed — ${offeringName}`;
|
||||
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName);
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
Your registration for ${offeringName} is confirmed. You'll receive a separate email with account access details shortly.
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`<p>Hi ${safeName},</p><p>Your registration for <strong>${safeOffering}</strong> is confirmed. You'll receive a separate email with account access details shortly.</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
function registrationNeedsAction({ displayName, offeringName, reason }) {
|
||||
const subject = `Action Needed — ${offeringName} Registration`;
|
||||
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName), safeReason = escapeHtml(reason || 'Please contact us for details.');
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
Your registration for ${offeringName} needs one more thing before it can be confirmed: ${reason || 'please contact us for details.'}
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`<p>Hi ${safeName},</p><p>Your registration for <strong>${safeOffering}</strong> needs one more thing before it can be confirmed:</p><p>${safeReason}</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
function agreementRequired({ displayName, offeringName, agreementTitle }) {
|
||||
const subject = `Agreement Required — ${offeringName}`;
|
||||
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName), safeAgreement = escapeHtml(agreementTitle);
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
Before your registration for ${offeringName} can be confirmed, we need your acceptance of: ${agreementTitle}. Your instructor/admin contact can help you complete this.
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`<p>Hi ${safeName},</p><p>Before your registration for <strong>${safeOffering}</strong> can be confirmed, we need your acceptance of:</p><p><strong>${safeAgreement}</strong></p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
// Operational notice only (e.g. "Camp starts Monday, here's what to bring") — deliberately NOT
|
||||
// a marketing template. See docs/REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md's boundary note.
|
||||
function campOperationalUpdate({ displayName, offeringName, message }) {
|
||||
const subject = `${offeringName} — Update`;
|
||||
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName), safeMessage = escapeHtml(message);
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
An update about ${offeringName}:
|
||||
|
||||
${message}
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`<p>Hi ${safeName},</p><p>An update about <strong>${safeOffering}</strong>:</p><p>${safeMessage}</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
activation, enrollmentConfirmation, passwordReset, passwordChanged, escapeHtml,
|
||||
registrationReceived, registrationConfirmed, registrationNeedsAction, agreementRequired, campOperationalUpdate,
|
||||
};
|
||||
|
||||
@@ -146,7 +146,14 @@ async function createFromIntegrationEvent(pool, studentsAuth, { eventId, source,
|
||||
// handed back so a future email/notification step — not built in this phase — could relay it).
|
||||
// A second concurrent call resolving the "same new student" case falls back to re-matching
|
||||
// after a unique-constraint hit, the identical insert/on-conflict-reselect pattern used above.
|
||||
async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName }) {
|
||||
// `returnToken` (default false) attaches the raw activation token to the returned row as
|
||||
// `_activationToken` when a NEW student is actually created. Default OFF and never passed by
|
||||
// createFromIntegrationEvent — the public website integration endpoint's own contract
|
||||
// (docs/WEBSITE_ENROLLMENT_INTEGRATION.md) explicitly never returns this value; only Phase 7's
|
||||
// admin-only registration-conversion route (offerings.js) opts in, the same trust level an
|
||||
// instructor manually creating a student already has via POST /api/admin/students (which
|
||||
// already returns activation_token directly).
|
||||
async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName }, { returnToken = false } = {}) {
|
||||
const identifier = studentEmail || studentLoginIdentifier;
|
||||
if (!identifier) throw err('STUDENT_IDENTIFIER_REQUIRED', 'student.email or student.login_identifier is required.');
|
||||
const existing = await studentsAuth.getStudentByIdentifier(pool, identifier);
|
||||
@@ -160,8 +167,8 @@ async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studen
|
||||
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[studentId, displayName, studentEmail ? String(studentEmail).toLowerCase().trim() : null, studentLoginIdentifier ? String(studentLoginIdentifier).trim() : null]
|
||||
);
|
||||
await studentsAuth.issueCredentialToken(pool, studentId, 'activation'); // token itself isn't returned by the integration endpoint — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md
|
||||
return rows[0];
|
||||
const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation'); // token itself isn't returned by the integration endpoint — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md
|
||||
return returnToken ? { ...rows[0], _activationToken: token } : rows[0];
|
||||
} catch (e) {
|
||||
if (e.code === '23505') {
|
||||
const reMatched = await studentsAuth.getStudentByIdentifier(pool, identifier);
|
||||
@@ -241,4 +248,8 @@ module.exports = {
|
||||
deriveEnrollmentAccess, getEnrollment, listEnrollmentsForStudent, listEnrollmentsForProgram,
|
||||
createManualEnrollment, createFromIntegrationEvent, activateEnrollment, cancelEnrollment,
|
||||
maybeCompleteEnrollment, enrollmentContactEmail,
|
||||
// Phase 7 — offerings.js's registration-to-enrollment conversion reuses this exact
|
||||
// create-or-match logic rather than duplicating it (a registration's eventual student is
|
||||
// resolved the same way an integration-event or manual enrollment's student already is).
|
||||
resolveOrCreateStudent,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ const enrollment = require('./enrollment');
|
||||
const studentsAuth = require('./students-auth');
|
||||
const notifications = require('./notifications');
|
||||
const programsLib = require('./programs');
|
||||
const offerings = require('./offerings');
|
||||
const metrics = require('./metrics');
|
||||
|
||||
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
|
||||
@@ -36,6 +37,17 @@ function enrollmentErrorStatus(code) {
|
||||
return 500;
|
||||
}
|
||||
|
||||
// Phase 8 §13 — website registration submission. Same auth boundary and same file as
|
||||
// /api/integrations/enrollments above (Step 15's "prefer server-to-server, don't introduce a
|
||||
// second scheme") — a registration is an earlier, separate step from an enrollment (see
|
||||
// docs/OFFERING_AND_REGISTRATION_MODEL.md), not a replacement for the existing endpoint.
|
||||
function registrationErrorStatus(code) {
|
||||
if (code === 'OFFERING_NOT_FOUND') return 404;
|
||||
if (code === 'EVENT_ID_REQUIRED' || code === 'OFFERING_SLUG_REQUIRED' || code === 'VALIDATION' || code === 'GUARDIAN_REQUIRED') return 400;
|
||||
if (['OFFERING_NOT_OPEN', 'OFFERING_CANCELLED', 'OFFERING_COMPLETED', 'OFFERING_FULL', 'REGISTRATION_NOT_YET_OPEN', 'REGISTRATION_CLOSED'].includes(code)) return 409;
|
||||
return 500;
|
||||
}
|
||||
|
||||
module.exports = function registerIntegrationRoutes(app, deps) {
|
||||
const { getPool, recordAudit, wrap } = deps;
|
||||
|
||||
@@ -114,4 +126,40 @@ module.exports = function registerIntegrationRoutes(app, deps) {
|
||||
created: result.created,
|
||||
});
|
||||
}));
|
||||
|
||||
// Phase 8 §13/§14/§15 — the website's registration submission endpoint. Same shared-secret
|
||||
// header as above, same "server-to-server only, never browser JS" boundary. Idempotent via
|
||||
// event_id -> offering_registrations.external_reference (a real unique index,
|
||||
// 035_phase8_...sql), the identical pattern /api/integrations/enrollments already uses.
|
||||
app.post('/api/integrations/registrations', wrap(async (req, res) => {
|
||||
const key = req.get('X-RootNode-Integration-Key');
|
||||
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) {
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
const { event_id, source, offering_slug, registrant, guardian, readiness_notes } = req.body || {};
|
||||
let result;
|
||||
try {
|
||||
result = await offerings.createRegistrationFromIntegrationEvent(getPool(), {
|
||||
eventId: event_id, offeringSlug: offering_slug, source: source || 'website',
|
||||
registrantDisplayName: registrant?.display_name, registrantEmail: registrant?.email, isMinor: !!registrant?.is_minor,
|
||||
guardianDisplayName: guardian?.display_name, guardianEmail: guardian?.email, guardianPhone: guardian?.phone,
|
||||
readinessNotes: readiness_notes,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.code) return res.status(registrationErrorStatus(e.code)).json({ error: e.code, message: e.message });
|
||||
throw e;
|
||||
}
|
||||
await recordAudit(getPool(), {
|
||||
actorType: 'integration', actorId: source || 'website',
|
||||
action: result.created ? 'registration_integration_created' : 'registration_integration_duplicate',
|
||||
resourceType: 'registration', resourceId: result.registration.registration_id, result: 'success', sourceIp: req.ip,
|
||||
metadata: { event_id, offering_slug },
|
||||
});
|
||||
res.status(result.created ? 201 : 200).json({
|
||||
registration_id: result.registration.registration_id,
|
||||
offering_id: result.registration.offering_id,
|
||||
status: result.registration.status,
|
||||
created: result.created,
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -44,6 +44,23 @@ function renderFor(row) {
|
||||
if (row.notification_type === 'password_changed') {
|
||||
return templates.passwordChanged({ displayName });
|
||||
}
|
||||
// Phase 8 — registration-stage notifications. No token/public-origin dependency, so these
|
||||
// never fail on missing PUBLIC_APP_ORIGIN config the way activation/reset do.
|
||||
if (row.notification_type === 'registration_received') {
|
||||
return templates.registrationReceived({ displayName, offeringName: row.metadata?.offering_name || 'your offering' });
|
||||
}
|
||||
if (row.notification_type === 'registration_confirmed') {
|
||||
return templates.registrationConfirmed({ displayName, offeringName: row.metadata?.offering_name || 'your offering' });
|
||||
}
|
||||
if (row.notification_type === 'registration_needs_action') {
|
||||
return templates.registrationNeedsAction({ displayName, offeringName: row.metadata?.offering_name || 'your offering', reason: row.metadata?.reason });
|
||||
}
|
||||
if (row.notification_type === 'agreement_required') {
|
||||
return templates.agreementRequired({ displayName, offeringName: row.metadata?.offering_name || 'your offering', agreementTitle: row.metadata?.agreement_title || 'a required agreement' });
|
||||
}
|
||||
if (row.notification_type === 'camp_operational_update') {
|
||||
return templates.campOperationalUpdate({ displayName, offeringName: row.metadata?.offering_name || 'your offering', message: row.metadata?.message || '' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,9 +38,57 @@ async function resolveRecipient(pool, studentId, enrollmentId) {
|
||||
const contact = enrollmentContactEmail(enrollmentRows[0]);
|
||||
if (contact) return contact;
|
||||
}
|
||||
// Phase 8 §9 — prefer the real guardian model (Phase 7) over nothing: a no-email student with
|
||||
// no enrollment-level contact_email but a real, communications-enabled primary guardian still
|
||||
// gets notified. Tried last, after both of the above, so an existing student.email or the
|
||||
// established enrollment.metadata.contact_email always wins first — this is purely a new
|
||||
// fallback, never a behavior change for any student who already resolves correctly today.
|
||||
const { rows: guardianRows } = await pool.query(
|
||||
`SELECT g.email FROM student_guardians sg JOIN guardians g ON g.guardian_id = sg.guardian_id
|
||||
WHERE sg.student_id = $1 AND sg.can_receive_communications = true AND g.email IS NOT NULL
|
||||
ORDER BY sg.is_primary_contact DESC LIMIT 1`,
|
||||
[studentId]
|
||||
);
|
||||
if (guardianRows[0]?.email) return guardianRows[0].email;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Phase 8 — the registration-stage counterpart to enqueue() above, for a notification that may
|
||||
// need to go out BEFORE any student row exists at all (a brand-new guardian-submitted
|
||||
// registration). Recipient resolution order: the registration's own student (if already
|
||||
// converted — reuses resolveRecipient/the guardian fallback above), else its linked guardian,
|
||||
// else the registrant's own supplied email. Never creates a second notification pipeline —
|
||||
// same notification_events table, same worker, same templates module.
|
||||
async function resolveRegistrationRecipient(pool, registration) {
|
||||
if (registration.student_id) {
|
||||
const viaStudent = await resolveRecipient(pool, registration.student_id, registration.enrollment_id);
|
||||
if (viaStudent) return viaStudent;
|
||||
}
|
||||
if (registration.guardian_id) {
|
||||
const { rows } = await pool.query(`SELECT email FROM guardians WHERE guardian_id = $1`, [registration.guardian_id]);
|
||||
if (rows[0]?.email) return rows[0].email;
|
||||
}
|
||||
return registration.registrant_email || null;
|
||||
}
|
||||
|
||||
async function enqueueForRegistration(pool, { registration, notificationType, metadata }) {
|
||||
const recipient = await resolveRegistrationRecipient(pool, registration);
|
||||
if (!recipient) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO notification_events (student_id, registration_id, notification_type, recipient_email, status, last_error_code, failed_at, metadata)
|
||||
VALUES ($1, $2, $3, NULL, 'failed', 'no_recipient_address', now(), $4) RETURNING *`,
|
||||
[registration.student_id || null, registration.registration_id, notificationType, JSON.stringify({})]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO notification_events (student_id, registration_id, notification_type, recipient_email, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[registration.student_id || null, registration.registration_id, notificationType, recipient, JSON.stringify(metadata || {})]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Inserts a queued row. plaintextToken is optional (activation/password_reset only) and, if
|
||||
// given, is the ONLY place its value is persisted anywhere — purged on the row's terminal
|
||||
// state. A notification with no resolvable recipient is still recorded (never silently
|
||||
@@ -145,6 +193,12 @@ async function listForStudent(pool, studentId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE student_id = $1 ORDER BY created_at DESC`, [studentId]);
|
||||
return rows;
|
||||
}
|
||||
// Phase 8 — every notification tied to a registration, including pre-conversion ones with no
|
||||
// student_id yet at all.
|
||||
async function listForRegistration(pool, registrationId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE registration_id = $1 ORDER BY created_at DESC`, [registrationId]);
|
||||
return rows;
|
||||
}
|
||||
async function getById(pool, notificationId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE notification_id = $1`, [notificationId]);
|
||||
return rows[0] || null;
|
||||
@@ -177,6 +231,7 @@ function maskEmail(email) {
|
||||
function toAdminView(row) {
|
||||
return {
|
||||
notification_id: row.notification_id, student_id: row.student_id, enrollment_id: row.enrollment_id,
|
||||
registration_id: row.registration_id,
|
||||
notification_type: row.notification_type, channel: row.channel, recipient_email_masked: maskEmail(row.recipient_email),
|
||||
provider: row.provider, status: row.status, attempt_count: row.attempt_count, last_error_code: row.last_error_code,
|
||||
created_at: row.created_at, sent_at: row.sent_at, failed_at: row.failed_at,
|
||||
@@ -186,4 +241,5 @@ function toAdminView(row) {
|
||||
module.exports = {
|
||||
resolveRecipient, enqueue, claimNext, markSent, markFailedOrRetry, notifyForEnrollment,
|
||||
listForStudent, getById, getDevPreview, maskEmail, toAdminView, MAX_ATTEMPTS,
|
||||
enqueueForRegistration, listForRegistration,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
// Phase 7 — the business/product architecture's core library: organizations, locations,
|
||||
// guardians, curriculum packages, offerings, registrations, and agreement acceptances. Same
|
||||
// error convention as enrollment.js/classroom.js: throw `Error` with a `.code` the route layer
|
||||
// maps to an HTTP status. See docs/OFFERING_AND_REGISTRATION_MODEL.md for the full lifecycle
|
||||
// diagrams this file implements, and docs/STUDENT_GUARDIAN_DATA_MODEL.md for the guardian
|
||||
// model's own reasoning.
|
||||
|
||||
const enrollmentLib = require('./enrollment');
|
||||
const notificationsLib = require('./notifications');
|
||||
|
||||
function err(code, message) {
|
||||
return Object.assign(new Error(message || code), { code });
|
||||
}
|
||||
|
||||
// ── Organizations / Locations ──────────────────────────────────────────────────────────────
|
||||
async function createOrganization(pool, { organizationId, name, type, contactName, contactEmail, contactPhone, notes }) {
|
||||
if (!organizationId || !name) throw err('VALIDATION', 'organizationId and name are required.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO organizations (organization_id, name, type, contact_name, contact_email, contact_phone, notes)
|
||||
VALUES ($1, $2, COALESCE($3, 'other'), $4, $5, $6, $7) RETURNING *`,
|
||||
[organizationId, name, type || null, contactName || null, contactEmail || null, contactPhone || null, notes || null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
async function listOrganizations(pool) {
|
||||
const { rows } = await pool.query(`SELECT * FROM organizations ORDER BY name`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function createLocation(pool, { locationId, organizationId, name, type, addressLine1, addressLine2, city, region, postalCode, country, timezone, capacity, accessNotes }) {
|
||||
if (!locationId || !name) throw err('VALIDATION', 'locationId and name are required.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO locations (location_id, organization_id, name, type, address_line1, address_line2, city, region, postal_code, country, timezone, capacity, access_notes)
|
||||
VALUES ($1, $2, $3, COALESCE($4, 'online'), $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *`,
|
||||
[locationId, organizationId || null, name, type || null, addressLine1 || null, addressLine2 || null, city || null, region || null, postalCode || null, country || null, timezone || null, capacity || null, accessNotes || null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
// `forInstructor` gates access_notes exactly like course-progress.js gates instructor_notes —
|
||||
// internal operational detail (building access codes, parking) never leaves the instructor
|
||||
// surface. See Step 8's explicit "protect private/internal operational notes."
|
||||
async function listLocations(pool, { forInstructor = false } = {}) {
|
||||
const { rows } = await pool.query(`SELECT * FROM locations ORDER BY name`);
|
||||
if (forInstructor) return rows;
|
||||
return rows.map(({ access_notes, ...rest }) => rest);
|
||||
}
|
||||
|
||||
// ── Guardians ───────────────────────────────────────────────────────────────────────────────
|
||||
async function createGuardian(pool, { guardianId, displayName, email, phone, notes }) {
|
||||
if (!guardianId || !displayName) throw err('VALIDATION', 'guardianId and displayName are required.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO guardians (guardian_id, display_name, email, phone, notes) VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[guardianId, displayName, email ? String(email).toLowerCase().trim() : null, phone || null, notes || null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
async function linkStudentGuardian(pool, { studentId, guardianId, relationship, isPrimaryContact, canReceiveCommunications }) {
|
||||
if (!studentId || !guardianId) throw err('VALIDATION', 'studentId and guardianId are required.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO student_guardians (student_id, guardian_id, relationship, is_primary_contact, can_receive_communications)
|
||||
VALUES ($1, $2, COALESCE($3, 'guardian'), COALESCE($4, false), COALESCE($5, true))
|
||||
ON CONFLICT (student_id, guardian_id) DO UPDATE SET relationship = EXCLUDED.relationship, is_primary_contact = EXCLUDED.is_primary_contact, can_receive_communications = EXCLUDED.can_receive_communications
|
||||
RETURNING *`,
|
||||
[studentId, guardianId, relationship || null, isPrimaryContact ?? null, canReceiveCommunications ?? null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
// A plain instructor sees which students they have real cohort access to (via the caller's own
|
||||
// RBAC check on the route) — this function itself just returns the linked guardians for one
|
||||
// student, RBAC is the route layer's job, same separation of concerns as everywhere else.
|
||||
async function listGuardiansForStudent(pool, studentId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT g.*, sg.relationship, sg.is_primary_contact, sg.can_receive_communications
|
||||
FROM student_guardians sg JOIN guardians g ON g.guardian_id = sg.guardian_id
|
||||
WHERE sg.student_id = $1 ORDER BY sg.is_primary_contact DESC, g.display_name`,
|
||||
[studentId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── Curriculum packages (a reference list into EXISTING courses, never a clone) ─────────────
|
||||
async function createCurriculumPackage(pool, { curriculumPackageId, name, programId, description }) {
|
||||
if (!curriculumPackageId || !name || !programId) throw err('VALIDATION', 'curriculumPackageId, name, and programId are required.');
|
||||
const { rows: programRows } = await pool.query(`SELECT 1 FROM programs WHERE program_id = $1`, [programId]);
|
||||
if (!programRows[0]) throw err('PROGRAM_NOT_FOUND', 'No such program.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO curriculum_packages (curriculum_package_id, name, program_id, description) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[curriculumPackageId, name, programId, description || null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
async function addCurriculumPackageItem(pool, { curriculumPackageId, courseId, sequence }) {
|
||||
const { rows: courseRows } = await pool.query(`SELECT 1 FROM courses WHERE course_id = $1`, [courseId]);
|
||||
if (!courseRows[0]) throw err('COURSE_NOT_FOUND', 'No such course — a package only ever REFERENCES an existing course, never clones one.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO curriculum_package_items (curriculum_package_id, course_id, sequence) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (curriculum_package_id, course_id) DO UPDATE SET sequence = EXCLUDED.sequence RETURNING *`,
|
||||
[curriculumPackageId, courseId, sequence]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
async function getCurriculumPackage(pool, curriculumPackageId) {
|
||||
const { rows: pkgRows } = await pool.query(`SELECT * FROM curriculum_packages WHERE curriculum_package_id = $1`, [curriculumPackageId]);
|
||||
if (!pkgRows[0]) return null;
|
||||
const { rows: items } = await pool.query(
|
||||
`SELECT cpi.course_id, cpi.sequence, c.title, c.status FROM curriculum_package_items cpi
|
||||
JOIN courses c ON c.course_id = cpi.course_id WHERE cpi.curriculum_package_id = $1 ORDER BY cpi.sequence`,
|
||||
[curriculumPackageId]
|
||||
);
|
||||
return { ...pkgRows[0], items };
|
||||
}
|
||||
|
||||
// ── Offerings — server-defined status transitions only (Step 3's explicit requirement) ──────
|
||||
const OFFERING_TRANSITIONS = {
|
||||
draft: ['published', 'cancelled'],
|
||||
published: ['enrollment_open', 'cancelled'],
|
||||
enrollment_open: ['full', 'in_progress', 'cancelled'],
|
||||
full: ['enrollment_open', 'in_progress', 'cancelled'],
|
||||
in_progress: ['completed', 'cancelled'],
|
||||
completed: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
async function createOffering(pool, { offeringId, slug, name, programId, curriculumPackageId, deliveryMode, startsAt, endsAt, enrollmentOpensAt, enrollmentClosesAt, capacity, locationId, organizationId, cohortId, recommendedMinAge, recommendedMaxAge, description, createdBy }) {
|
||||
if (!offeringId || !slug || !name || !programId || !deliveryMode) throw err('VALIDATION', 'offeringId, slug, name, programId, and deliveryMode are required.');
|
||||
const { rows: programRows } = await pool.query(`SELECT 1 FROM programs WHERE program_id = $1`, [programId]);
|
||||
if (!programRows[0]) throw err('PROGRAM_NOT_FOUND', 'No such program.');
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO offerings (offering_id, slug, name, program_id, curriculum_package_id, delivery_mode, starts_at, ends_at, enrollment_opens_at, enrollment_closes_at, capacity, location_id, organization_id, cohort_id, recommended_min_age, recommended_max_age, description, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) RETURNING *`,
|
||||
[offeringId, slug, name, programId, curriculumPackageId || null, deliveryMode, startsAt || null, endsAt || null, enrollmentOpensAt || null, enrollmentClosesAt || null, capacity ?? null, locationId || null, organizationId || null, cohortId || null, recommendedMinAge ?? null, recommendedMaxAge ?? null, description || null, createdBy || null]
|
||||
);
|
||||
return rows[0];
|
||||
} catch (e) {
|
||||
if (e.code === '23505') throw err('DUPLICATE_SLUG', 'An offering with this slug already exists.');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
async function getOffering(pool, offeringId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM offerings WHERE offering_id = $1`, [offeringId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
async function listOfferings(pool, { status } = {}) {
|
||||
const { rows } = await pool.query(
|
||||
status ? `SELECT * FROM offerings WHERE status = $1 ORDER BY created_at DESC` : `SELECT * FROM offerings ORDER BY created_at DESC`,
|
||||
status ? [status] : []
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
async function transitionOffering(pool, offeringId, toStatus) {
|
||||
const offering = await getOffering(pool, offeringId);
|
||||
if (!offering) throw err('NOT_FOUND', 'No such offering.');
|
||||
const allowed = OFFERING_TRANSITIONS[offering.status] || [];
|
||||
if (!allowed.includes(toStatus)) throw err('INVALID_TRANSITION', `Cannot move an offering from '${offering.status}' to '${toStatus}'.`);
|
||||
const { rows } = await pool.query(`UPDATE offerings SET status = $2, version = version + 1, updated_at = now() WHERE offering_id = $1 RETURNING *`, [offeringId, toStatus]);
|
||||
return rows[0];
|
||||
}
|
||||
async function assignInstructorToOffering(pool, offeringId, instructorId, role) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO instructor_offerings (offering_id, instructor_id, role) VALUES ($1, $2, COALESCE($3, 'teacher'))
|
||||
ON CONFLICT (offering_id, instructor_id) DO UPDATE SET role = EXCLUDED.role RETURNING *`,
|
||||
[offeringId, instructorId, role || null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Single atomic UPDATE, not SELECT-count-then-INSERT — see 034_phase7_....sql's own header
|
||||
// comment for why this specific shape avoids the race the authorization explicitly warned
|
||||
// against. A NULL capacity offering (typical for self_paced) never needs reservation at all.
|
||||
async function reserveSeat(pool, offeringId) {
|
||||
const offering = await getOffering(pool, offeringId);
|
||||
if (!offering) throw err('NOT_FOUND', 'No such offering.');
|
||||
if (offering.capacity === null) return offering; // unlimited — nothing to reserve
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE offerings SET reserved_seats = reserved_seats + 1, updated_at = now()
|
||||
WHERE offering_id = $1 AND reserved_seats < capacity RETURNING *`,
|
||||
[offeringId]
|
||||
);
|
||||
if (!rows[0]) throw err('OFFERING_FULL', 'This offering has no remaining capacity.');
|
||||
return rows[0];
|
||||
}
|
||||
async function releaseSeat(pool, offeringId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE offerings SET reserved_seats = GREATEST(reserved_seats - 1, 0), updated_at = now() WHERE offering_id = $1 RETURNING *`,
|
||||
[offeringId]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// ── Registrations ────────────────────────────────────────────────────────────────────────────
|
||||
const REGISTRATION_TRANSITIONS = {
|
||||
started: ['submitted', 'cancelled'],
|
||||
submitted: ['reviewed', 'agreements_pending', 'payment_pending', 'confirmed', 'cancelled'],
|
||||
reviewed: ['agreements_pending', 'payment_pending', 'confirmed', 'cancelled'],
|
||||
agreements_pending: ['payment_pending', 'confirmed', 'cancelled'],
|
||||
payment_pending: ['confirmed', 'cancelled'],
|
||||
confirmed: ['enrollment_activated', 'cancelled'],
|
||||
enrollment_activated: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
// Phase 8 §21 — registration availability is server-authoritative, checked here (the one place
|
||||
// a registration row is actually created), not left to a website's own hidden-button logic.
|
||||
// Distinct error codes per reason so a caller (website or admin UI) can show something more
|
||||
// useful than a generic failure.
|
||||
function assertRegistrationOpen(offering) {
|
||||
if (offering.status === 'draft') throw err('OFFERING_NOT_OPEN', 'This offering is not yet open for registration.');
|
||||
if (offering.status === 'cancelled') throw err('OFFERING_CANCELLED', 'This offering has been cancelled.');
|
||||
if (offering.status === 'completed') throw err('OFFERING_COMPLETED', 'This offering has already completed.');
|
||||
if (offering.status === 'full') throw err('OFFERING_FULL', 'This offering has no remaining capacity.');
|
||||
if (!['published', 'enrollment_open'].includes(offering.status)) throw err('OFFERING_NOT_OPEN', 'This offering is not currently open for registration.');
|
||||
const now = new Date();
|
||||
if (offering.enrollment_opens_at && now < new Date(offering.enrollment_opens_at)) throw err('REGISTRATION_NOT_YET_OPEN', 'Registration for this offering has not opened yet.');
|
||||
if (offering.enrollment_closes_at && now > new Date(offering.enrollment_closes_at)) throw err('REGISTRATION_CLOSED', 'Registration for this offering has closed.');
|
||||
}
|
||||
|
||||
// Field validation -> seat reservation -> insert -> notification, strictly in that order. A
|
||||
// notification is only ever enqueued for a registration that is FULLY, durably committed —
|
||||
// enqueueing one earlier (e.g. before capacity is confirmed) created a real bug found during
|
||||
// this phase's own regression testing: a rolled-back registration whose row got deleted after a
|
||||
// failed seat reservation left behind a notification_events row still pointing at it, and the
|
||||
// DELETE itself then failed on the very foreign key that row created. Reserving the seat FIRST
|
||||
// (rolling it back if the insert itself somehow fails) means nothing is ever created that later
|
||||
// needs to be un-created out from under a row something else already references.
|
||||
async function createRegistration(pool, { offeringId, guardianId, registrantDisplayName, registrantEmail, isMinor, readinessNotes, accessibilityNotes, emergencyContactName, emergencyContactPhone, source, externalReference }) {
|
||||
if (!offeringId || !registrantDisplayName) throw err('VALIDATION', 'offeringId and registrantDisplayName are required.');
|
||||
if (isMinor && !guardianId) throw err('GUARDIAN_REQUIRED', 'A minor registration must include a guardianId.');
|
||||
const offering = await getOffering(pool, offeringId);
|
||||
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
|
||||
assertRegistrationOpen(offering);
|
||||
|
||||
await reserveSeat(pool, offeringId); // throws OFFERING_FULL if genuinely exhausted; nothing created yet if so
|
||||
|
||||
let reg;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, accessibility_notes, emergency_contact_name, emergency_contact_phone, source, external_reference)
|
||||
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9,COALESCE($10,'admin'),$11) RETURNING *`,
|
||||
[offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null]
|
||||
);
|
||||
reg = rows[0];
|
||||
} catch (e) {
|
||||
await releaseSeat(pool, offeringId).catch(() => {});
|
||||
if (e.code === '23505') throw err('DUPLICATE_REGISTRATION', 'A registration with this external_reference already exists for this offering.');
|
||||
throw e;
|
||||
}
|
||||
// Best-effort — a notification failure must never fail the registration itself (matches
|
||||
// enrollment.js's own precedent of never letting notification concerns block the academic
|
||||
// action they're merely announcing). Safe to fire now: reg is fully, durably committed.
|
||||
await notificationsLib.enqueueForRegistration(pool, {
|
||||
registration: reg, notificationType: 'registration_received',
|
||||
metadata: { display_name: reg.registrant_display_name, offering_name: offering.name },
|
||||
}).catch((e) => console.error("[offerings] registration_received notification failed:", e.message));
|
||||
return reg;
|
||||
}
|
||||
async function getRegistration(pool, registrationId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM offering_registrations WHERE registration_id = $1`, [registrationId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
async function listRegistrationsForOffering(pool, offeringId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM offering_registrations WHERE offering_id = $1 ORDER BY created_at`, [offeringId]);
|
||||
return rows;
|
||||
}
|
||||
async function transitionRegistration(pool, registrationId, toStatus) {
|
||||
const reg = await getRegistration(pool, registrationId);
|
||||
if (!reg) throw err('NOT_FOUND', 'No such registration.');
|
||||
const allowed = REGISTRATION_TRANSITIONS[reg.status] || [];
|
||||
if (!allowed.includes(toStatus)) throw err('INVALID_TRANSITION', `Cannot move a registration from '${reg.status}' to '${toStatus}'.`);
|
||||
if (toStatus === 'cancelled' && !['enrollment_activated', 'cancelled'].includes(reg.status)) await releaseSeat(pool, reg.offering_id);
|
||||
const { rows } = await pool.query(`UPDATE offering_registrations SET status = $2, updated_at = now() WHERE registration_id = $1 RETURNING *`, [registrationId, toStatus]);
|
||||
const updated = rows[0];
|
||||
|
||||
// Best-effort lifecycle notifications — see createRegistration's own comment on why a
|
||||
// notification failure never blocks the real state change it's merely announcing.
|
||||
const offering = await getOffering(pool, reg.offering_id);
|
||||
if (toStatus === 'confirmed') {
|
||||
await notificationsLib.enqueueForRegistration(pool, {
|
||||
registration: updated, notificationType: 'registration_confirmed',
|
||||
metadata: { display_name: updated.registrant_display_name, offering_name: offering?.name },
|
||||
}).catch((e) => console.error("[offerings] registration lifecycle notification failed:", e.message));
|
||||
}
|
||||
if (toStatus === 'agreements_pending') {
|
||||
const status = await getRegistrationAgreementStatus(pool, registrationId);
|
||||
for (const missing of status.missing) {
|
||||
await notificationsLib.enqueueForRegistration(pool, {
|
||||
registration: updated, notificationType: 'agreement_required',
|
||||
metadata: { display_name: updated.registrant_display_name, offering_name: offering?.name, agreement_title: missing.title },
|
||||
}).catch((e) => console.error("[offerings] agreement_required notification failed:", e.message));
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ── Offering agreement requirements (Step 18) ────────────────────────────────────────────────
|
||||
async function addAgreementRequirement(pool, offeringId, agreementId) {
|
||||
const { rows: agreementRows } = await pool.query(`SELECT 1 FROM agreement_definitions WHERE agreement_id = $1`, [agreementId]);
|
||||
if (!agreementRows[0]) throw err('AGREEMENT_NOT_FOUND', 'No such agreement.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO offering_agreement_requirements (offering_id, agreement_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING *`,
|
||||
[offeringId, agreementId]
|
||||
);
|
||||
return rows[0] || { offering_id: offeringId, agreement_id: agreementId };
|
||||
}
|
||||
async function listAgreementRequirementsForOffering(pool, offeringId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ad.* FROM offering_agreement_requirements oar JOIN agreement_definitions ad ON ad.agreement_id = oar.agreement_id
|
||||
WHERE oar.offering_id = $1 ORDER BY ad.title`,
|
||||
[offeringId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
// Required vs. accepted vs. missing, for one registration — the exact shape both the
|
||||
// agreements_pending notification loop above and the admin/registration-detail UI need.
|
||||
async function getRegistrationAgreementStatus(pool, registrationId) {
|
||||
const reg = await getRegistration(pool, registrationId);
|
||||
if (!reg) throw err('NOT_FOUND', 'No such registration.');
|
||||
const required = await listAgreementRequirementsForOffering(pool, reg.offering_id);
|
||||
const accepted = await listAcceptancesForRegistration(pool, registrationId);
|
||||
const acceptedIds = new Set(accepted.map((a) => a.agreement_id));
|
||||
const missing = required.filter((r) => !acceptedIds.has(r.agreement_id));
|
||||
return { required, accepted, missing };
|
||||
}
|
||||
|
||||
// The one place a registration becomes a real academic enrollment. Reuses
|
||||
// enrollment.js's resolveOrCreateStudent/createManualEnrollment/activateEnrollment rather than
|
||||
// duplicating student-matching or enrollment-creation logic — see that file's own comment on
|
||||
// why resolveOrCreateStudent is now exported. Cohort membership (if the offering has a cohort)
|
||||
// is created here too, using the existing cohort_enrollments table unchanged.
|
||||
async function convertRegistrationToEnrollment(pool, studentsAuth, registrationId, { activate = true } = {}) {
|
||||
const reg = await getRegistration(pool, registrationId);
|
||||
if (!reg) throw err('NOT_FOUND', 'No such registration.');
|
||||
if (reg.status !== 'confirmed') throw err('INVALID_STATE', 'A registration must be confirmed before it can become an enrollment.');
|
||||
const offering = await getOffering(pool, reg.offering_id);
|
||||
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
|
||||
// Step 18 — a required agreement with no matching acceptance blocks conversion, always (this
|
||||
// offering's own configured requirements, not a global policy toggle this phase invents).
|
||||
const agreementStatus = await getRegistrationAgreementStatus(pool, registrationId);
|
||||
if (agreementStatus.missing.length) {
|
||||
throw err('AGREEMENTS_INCOMPLETE', `Missing required agreement(s): ${agreementStatus.missing.map((m) => m.title).join(', ')}`);
|
||||
}
|
||||
|
||||
const student = reg.student_id
|
||||
? (await pool.query(`SELECT * FROM students WHERE student_id = $1`, [reg.student_id])).rows[0]
|
||||
: await enrollmentLib.resolveOrCreateStudent(pool, studentsAuth, {
|
||||
studentEmail: reg.is_minor ? null : reg.registrant_email,
|
||||
studentLoginIdentifier: reg.is_minor ? `reg-${reg.registration_id}` : null,
|
||||
studentDisplayName: reg.registrant_display_name,
|
||||
}, { returnToken: true }); // admin-only surface — see enrollment.js's own comment on why this is safe here but not on the public integration endpoint
|
||||
|
||||
const enrollment = await enrollmentLib.createManualEnrollment(pool, {
|
||||
studentId: student.student_id, programId: offering.program_id,
|
||||
status: activate ? 'active' : 'pending', deliveryMode: offering.delivery_mode,
|
||||
externalReference: reg.external_reference,
|
||||
metadata: reg.is_minor && reg.registrant_email ? { contact_email: reg.registrant_email } : null,
|
||||
});
|
||||
|
||||
if (offering.cohort_id) {
|
||||
await pool.query(
|
||||
`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[offering.cohort_id, student.student_id]
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE offering_registrations SET student_id = $2, enrollment_id = $3, status = 'enrollment_activated', updated_at = now() WHERE registration_id = $1`,
|
||||
[registrationId, student.student_id, enrollment.enrollment_id]
|
||||
);
|
||||
return { student, enrollment };
|
||||
}
|
||||
|
||||
// ── Agreements ────────────────────────────────────────────────────────────────────────────────
|
||||
async function createAgreementDefinition(pool, { agreementId, title, bodySummary, requiresGuardianForMinor }) {
|
||||
if (!agreementId || !title) throw err('VALIDATION', 'agreementId and title are required.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO agreement_definitions (agreement_id, title, body_summary, requires_guardian_for_minor) VALUES ($1, $2, $3, COALESCE($4, true)) RETURNING *`,
|
||||
[agreementId, title, bodySummary || null, requiresGuardianForMinor ?? null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
async function listAgreementDefinitions(pool) {
|
||||
const { rows } = await pool.query(`SELECT * FROM agreement_definitions ORDER BY title`);
|
||||
return rows;
|
||||
}
|
||||
// accepted_by_type: 'student' | 'guardian' | 'instructor_on_behalf' | 'registrant'. 'registrant'
|
||||
// (Step 7's fix) is the one case allowed with neither a student nor a guardian id — an adult
|
||||
// self-accepting AT REGISTRATION TIME, before their eventual student_id exists, tied only to
|
||||
// registrationId. Every other type still requires a real student or guardian id — enforced here
|
||||
// AND by the DB CHECK constraint (035_phase8_...sql), so this can never be bypassed by a future
|
||||
// caller that skips this function.
|
||||
async function recordAgreementAcceptance(pool, { agreementId, acceptedByType, acceptedByStudentId, acceptedByGuardianId, registrationId, enrollmentId, ipAddress }) {
|
||||
const { rows: defRows } = await pool.query(`SELECT * FROM agreement_definitions WHERE agreement_id = $1`, [agreementId]);
|
||||
const def = defRows[0];
|
||||
if (!def) throw err('AGREEMENT_NOT_FOUND', 'No such agreement.');
|
||||
if (!['student', 'guardian', 'instructor_on_behalf', 'registrant'].includes(acceptedByType)) {
|
||||
throw err('VALIDATION', "accepted_by_type must be one of: student, guardian, instructor_on_behalf, registrant.");
|
||||
}
|
||||
if (acceptedByType === 'registrant') {
|
||||
if (!registrationId) throw err('VALIDATION', 'A registrant acceptance must be tied to a registrationId.');
|
||||
} else if (!acceptedByStudentId && !acceptedByGuardianId) {
|
||||
throw err('VALIDATION', 'An acceptance must be tied to a student or a guardian.');
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO agreement_acceptances (agreement_id, accepted_version, accepted_by_type, accepted_by_student_id, accepted_by_guardian_id, registration_id, enrollment_id, ip_address)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||
[agreementId, def.version, acceptedByType, acceptedByStudentId || null, acceptedByGuardianId || null, registrationId || null, enrollmentId || null, ipAddress || null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
async function listAcceptancesForRegistration(pool, registrationId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM agreement_acceptances WHERE registration_id = $1 ORDER BY accepted_at`, [registrationId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── Website integration — idempotent registration submission (Step 13/14) ──────────────────
|
||||
// Mirrors enrollment.js's createFromIntegrationEvent exactly: a real DB-level unique index
|
||||
// (external_reference, 035_phase8_...sql) is what makes concurrent duplicate submissions safe,
|
||||
// not a SELECT-then-INSERT check. A replay of the same event_id returns the ORIGINAL
|
||||
// registration, created:false, never a duplicate — same contract shape as the existing
|
||||
// /api/integrations/enrollments endpoint website code already knows how to handle.
|
||||
async function createRegistrationFromIntegrationEvent(pool, { eventId, offeringSlug, registrantDisplayName, registrantEmail, isMinor, guardianDisplayName, guardianEmail, guardianPhone, readinessNotes, source }) {
|
||||
if (!eventId) throw err('EVENT_ID_REQUIRED', 'event_id is required for idempotent registration events.');
|
||||
if (!offeringSlug) throw err('OFFERING_SLUG_REQUIRED', 'offering_slug is required.');
|
||||
if (!registrantDisplayName) throw err('VALIDATION', 'registrant display name is required.');
|
||||
if (isMinor && !guardianEmail) throw err('GUARDIAN_REQUIRED', 'A minor registration requires a guardian email.');
|
||||
|
||||
const existing = await pool.query(`SELECT * FROM offering_registrations WHERE external_reference = $1`, [eventId]);
|
||||
if (existing.rows[0]) return { registration: existing.rows[0], created: false };
|
||||
|
||||
const { rows: offeringRows } = await pool.query(`SELECT * FROM offerings WHERE slug = $1`, [offeringSlug]);
|
||||
const offering = offeringRows[0];
|
||||
if (!offering) throw err('OFFERING_NOT_FOUND', 'Unknown offering_slug.');
|
||||
assertRegistrationOpen(offering);
|
||||
|
||||
let guardianId = null;
|
||||
if (isMinor) {
|
||||
// Match an existing guardian by email first (a returning family shouldn't get a duplicate
|
||||
// guardian row per sibling/per camp) — same "match before create" principle
|
||||
// enrollment.js's resolveOrCreateStudent already established for students.
|
||||
const existingGuardian = await pool.query(`SELECT guardian_id FROM guardians WHERE email = $1`, [String(guardianEmail).toLowerCase().trim()]);
|
||||
if (existingGuardian.rows[0]) {
|
||||
guardianId = existingGuardian.rows[0].guardian_id;
|
||||
} else {
|
||||
guardianId = `guardian-${eventId}`;
|
||||
await createGuardian(pool, { guardianId, displayName: guardianDisplayName || guardianEmail, email: guardianEmail, phone: guardianPhone });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await reserveSeat(pool, offering.offering_id);
|
||||
} catch (e) {
|
||||
if (e.code === 'OFFERING_FULL') throw e;
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, source, external_reference)
|
||||
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8)
|
||||
ON CONFLICT (external_reference) WHERE external_reference IS NOT NULL DO NOTHING
|
||||
RETURNING *`,
|
||||
[offering.offering_id, guardianId, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, source || 'website', eventId]
|
||||
);
|
||||
if (rows[0]) {
|
||||
await notificationsLib.enqueueForRegistration(pool, {
|
||||
registration: rows[0], notificationType: 'registration_received',
|
||||
metadata: { display_name: rows[0].registrant_display_name, offering_name: offering.name },
|
||||
}).catch((e) => console.error("[offerings] website registration_received notification failed:", e.message));
|
||||
return { registration: rows[0], created: true };
|
||||
}
|
||||
// Lost the ON CONFLICT race (concurrent identical replay) — release the seat we just
|
||||
// reserved (the winner already holds theirs) and return the winner's row.
|
||||
await releaseSeat(pool, offering.offering_id);
|
||||
const { rows: won } = await pool.query(`SELECT * FROM offering_registrations WHERE external_reference = $1`, [eventId]);
|
||||
return { registration: won[0], created: false };
|
||||
} catch (e) {
|
||||
await releaseSeat(pool, offering.offering_id).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Launch readiness (Step 20) — server-derived, never left to frontend logic alone ─────────
|
||||
async function computeOfferingReadiness(pool, offeringId) {
|
||||
const offering = await getOffering(pool, offeringId);
|
||||
if (!offering) throw err('NOT_FOUND', 'No such offering.');
|
||||
const checks = [];
|
||||
checks.push({ key: 'curriculum', label: 'Curriculum', ready: !!offering.program_id });
|
||||
const needsInstructor = offering.delivery_mode !== 'self_paced';
|
||||
if (needsInstructor) {
|
||||
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM instructor_offerings WHERE offering_id = $1`, [offeringId]);
|
||||
checks.push({ key: 'instructor', label: 'Instructor assigned', ready: rows[0].n > 0 });
|
||||
}
|
||||
const needsSchedule = !['self_paced'].includes(offering.delivery_mode);
|
||||
if (needsSchedule) checks.push({ key: 'schedule', label: 'Dates', ready: !!(offering.starts_at && offering.ends_at) });
|
||||
const needsLocation = !['self_paced'].includes(offering.delivery_mode);
|
||||
if (needsLocation) checks.push({ key: 'location', label: 'Location', ready: !!offering.location_id || offering.delivery_mode === 'community' });
|
||||
checks.push({ key: 'capacity', label: 'Capacity', ready: offering.capacity !== null || offering.delivery_mode === 'self_paced' });
|
||||
checks.push({ key: 'registration_window', label: 'Registration window', ready: !!(offering.enrollment_opens_at && offering.enrollment_closes_at) });
|
||||
const requirements = await listAgreementRequirementsForOffering(pool, offeringId);
|
||||
checks.push({ key: 'agreements', label: 'Agreements configured', ready: requirements.length > 0 });
|
||||
if (offering.curriculum_package_id) {
|
||||
const pkg = await getCurriculumPackage(pool, offering.curriculum_package_id);
|
||||
checks.push({ key: 'curriculum_package', label: 'Curriculum package valid', ready: !!pkg && pkg.items.length > 0 });
|
||||
}
|
||||
const allReady = checks.every((c) => c.ready);
|
||||
return { offering_id: offeringId, ready_to_open_registration: allReady, checks };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createOrganization, listOrganizations,
|
||||
createLocation, listLocations,
|
||||
createGuardian, linkStudentGuardian, listGuardiansForStudent,
|
||||
createCurriculumPackage, addCurriculumPackageItem, getCurriculumPackage,
|
||||
createOffering, getOffering, listOfferings, transitionOffering, assignInstructorToOffering, reserveSeat, releaseSeat,
|
||||
createRegistration, getRegistration, listRegistrationsForOffering, transitionRegistration, convertRegistrationToEnrollment,
|
||||
createAgreementDefinition, listAgreementDefinitions, recordAgreementAcceptance, listAcceptancesForRegistration,
|
||||
addAgreementRequirement, listAgreementRequirementsForOffering, getRegistrationAgreementStatus,
|
||||
createRegistrationFromIntegrationEvent, computeOfferingReadiness, assertRegistrationOpen,
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
// Phase 8 §12 — the PUBLIC, unauthenticated offering-read surface the website reads to build
|
||||
// its own catalog/registration pages. No session of any kind (student or instructor) is
|
||||
// checked or accepted here — this is meant to be reachable by anyone, the same trust level as
|
||||
// the marketing site itself. An explicit allowlist serializer (never SELECT *, never returning
|
||||
// a raw row) is what keeps this safe: internal notes, access_notes, organization contact info,
|
||||
// instructor identity, and anything registration/student/guardian-shaped are never even
|
||||
// queried by this file, let alone serialized.
|
||||
|
||||
function toPublicOffering(row) {
|
||||
return {
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
program_id: row.program_id,
|
||||
delivery_mode: row.delivery_mode,
|
||||
recommended_min_age: row.recommended_min_age,
|
||||
recommended_max_age: row.recommended_max_age,
|
||||
starts_at: row.starts_at,
|
||||
ends_at: row.ends_at,
|
||||
registration_open: row.status === 'enrollment_open',
|
||||
// "availability" rather than an exact seat count — a real number invites scraping/timing
|
||||
// games from a public, unauthenticated endpoint; a coarse status is enough for a website to
|
||||
// build a real "Register" vs "Full" call to action.
|
||||
availability: row.capacity === null ? 'open' : row.reserved_seats >= row.capacity ? 'full' : (row.capacity - row.reserved_seats) <= 3 ? 'limited' : 'open',
|
||||
location_name: row.location_name || null,
|
||||
location_type: row.location_type || null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function registerPublicOfferingsRoutes(app, deps) {
|
||||
const { getPool, wrap } = deps;
|
||||
|
||||
// Only offerings a family/adult should actually be able to see: published or further along,
|
||||
// never draft (an admin still building it out) and never cancelled.
|
||||
const VISIBLE_STATUSES = ['published', 'enrollment_open', 'full', 'in_progress'];
|
||||
|
||||
app.get('/api/public/offerings', wrap(async (req, res) => {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age,
|
||||
o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, l.name AS location_name, l.type AS location_type
|
||||
FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id
|
||||
WHERE o.status = ANY($1) ORDER BY o.starts_at NULLS LAST, o.name`,
|
||||
[VISIBLE_STATUSES]
|
||||
);
|
||||
res.json(rows.map(toPublicOffering));
|
||||
}));
|
||||
|
||||
app.get('/api/public/offerings/:slug', wrap(async (req, res) => {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age,
|
||||
o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, l.name AS location_name, l.type AS location_type
|
||||
FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id
|
||||
WHERE o.slug = $1 AND o.status = ANY($2)`,
|
||||
[req.params.slug, VISIBLE_STATUSES]
|
||||
);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'not_found' });
|
||||
res.json(toPublicOffering(rows[0]));
|
||||
}));
|
||||
};
|
||||
@@ -413,6 +413,19 @@ require('./notification-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, getPool, recordAudit,
|
||||
});
|
||||
|
||||
// Phase 7 — the business/product architecture: organizations, locations, guardians,
|
||||
// curriculum packages, offerings, registrations, and agreements. Admin/owner surface only —
|
||||
// no student-facing routes yet (registration is admin-entered this phase; a future public
|
||||
// registration flow is a Phase 8+ decision, see docs/OFFERING_AND_REGISTRATION_MODEL.md).
|
||||
require('./admin-offerings-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, getPool, recordAudit,
|
||||
});
|
||||
|
||||
// Phase 8 — the public, unauthenticated offering-read surface (no session of any kind), and
|
||||
// the website's registration submission endpoint (server-to-server, same shared-secret
|
||||
// boundary as /api/integrations/enrollments, registered alongside it in integration-routes.js).
|
||||
require('./public-offerings-routes')(app, { getPool, wrap });
|
||||
|
||||
// Fallback to the platform-shell SPA for any unmatched non-API route
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 7 — Business & Product Architecture acceptance test. Organizations, locations,
|
||||
// guardians, curriculum packages, offerings (capacity, server-defined transitions),
|
||||
// registrations (lifecycle, agreements, conversion to real enrollment), RBAC, and explicit
|
||||
// proof that none of this depends on age or breaks ROOT_ V1's existing self-paced/classroom
|
||||
// flows. Same conventions as e2e-phase6a-f.js: assert-based, no framework, live
|
||||
// docker-compose.dev.yml stack.
|
||||
//
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase7.js
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('./session-helper');
|
||||
const { completeRootFoundations01Through04 } = require('./foundations-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||
}
|
||||
function psql(sql) {
|
||||
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 7 Business & Product Architecture acceptance test\n');
|
||||
const owner = makeSession(GW);
|
||||
{ const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(r.status, 200); }
|
||||
|
||||
// ── 1: course families are guidance, not hard age gates ──────────────────────────────────
|
||||
await test('programs.family accepts bootcamp; recommended_min/max_age is never read by any RBAC/authorization code', async () => {
|
||||
psql(`INSERT INTO programs (program_id, name, family, delivery_mode, curriculum_path, status) VALUES ('p7-bootcamp-check-${RUN_ID}', 'Bootcamp Check', 'bootcamp', 'self_paced', 'rootnode-path', 'active') ON CONFLICT DO NOTHING`);
|
||||
const row = psql(`SELECT family FROM programs WHERE program_id = 'p7-bootcamp-check-${RUN_ID}'`);
|
||||
assert.equal(row, 'bootcamp');
|
||||
const src = execSync('grep -rln "recommended_min_age\\|recommended_max_age" src/*.js', { cwd: `${__dirname}/../api`, stdio: 'pipe' }).toString();
|
||||
// Only offerings.js/admin-offerings-routes.js (Phase 7) and public-offerings-routes.js
|
||||
// (Phase 8's public read-only serializer — displays the fields, never authorizes on them)
|
||||
// should ever mention these fields, and none of the three is an authorization/RBAC file —
|
||||
// confirmed by name, not content-sniffing.
|
||||
for (const file of src.trim().split('\n').filter(Boolean)) {
|
||||
assert.ok(/offerings\.js|admin-offerings-routes\.js|public-offerings-routes\.js/.test(file), `unexpected file referencing age-guidance fields: ${file}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 2/3/4: offering creation, program association, delivery-mode behavior ────────────────
|
||||
const orgId = `org-p7-${RUN_ID}`;
|
||||
const locId = `loc-p7-${RUN_ID}`;
|
||||
let offeringA, offeringB, pkgId;
|
||||
await test('organization and two distinct locations are created', async () => {
|
||||
const org = await owner.request('POST', '/api/admin/organizations', { organizationId: orgId, name: 'P7 Test Org', type: 'library' });
|
||||
assert.equal(org.status, 201);
|
||||
const loc = await owner.request('POST', '/api/admin/locations', { locationId: locId, name: 'P7 Room', type: 'community_center', organizationId: orgId, accessNotes: 'internal only' });
|
||||
assert.equal(loc.status, 201);
|
||||
});
|
||||
|
||||
await test('a curriculum package references existing ROOT courses without duplicating content', async () => {
|
||||
pkgId = `pkg-p7-${RUN_ID}`;
|
||||
const pkg = await owner.request('POST', '/api/admin/curriculum-packages', { curriculumPackageId: pkgId, name: 'P7 Winter Package', programId: PROGRAM });
|
||||
assert.equal(pkg.status, 201);
|
||||
const item1 = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId: 'root-01-computing-linux-foundations', sequence: 1 });
|
||||
assert.equal(item1.status, 201);
|
||||
const item2 = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId: 'root-02-linux-administration-networking', sequence: 2 });
|
||||
assert.equal(item2.status, 201);
|
||||
// 19/20: references existing content, no duplication — the module/activity counts under
|
||||
// root-01/02 must be byte-identical to what course-content validation already knows about.
|
||||
const activityCount = psql(`SELECT count(*) FROM module_activities ma JOIN course_modules cm ON cm.module_id = ma.module_id WHERE cm.course_id IN ('root-01-computing-linux-foundations','root-02-linux-administration-networking')`);
|
||||
const get = await owner.request('GET', `/api/admin/curriculum-packages/${pkgId}`);
|
||||
assert.equal(get.status, 200);
|
||||
assert.equal(get.body.items.length, 2);
|
||||
assert.equal(get.body.items[0].course_id, 'root-01-computing-linux-foundations');
|
||||
assert.ok(Number(activityCount) > 0, 'the referenced courses must still have their real activities, unduplicated');
|
||||
});
|
||||
|
||||
await test('a non-existent course cannot be added to a curriculum package (reference, never fabricate)', async () => {
|
||||
const r = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId: 'root-99-does-not-exist', sequence: 3 });
|
||||
assert.equal(r.status, 404);
|
||||
assert.equal(r.body.error, 'COURSE_NOT_FOUND');
|
||||
});
|
||||
|
||||
await test('two offerings at two different locations coexist with no state confusion', async () => {
|
||||
offeringA = `off-a-${RUN_ID}`;
|
||||
offeringB = `off-b-${RUN_ID}`;
|
||||
const a = await owner.request('POST', '/api/admin/offerings', { offeringId: offeringA, slug: `p7-camp-a-${RUN_ID}`, name: 'Camp A', programId: PROGRAM, curriculumPackageId: pkgId, deliveryMode: 'camp', capacity: 2, locationId: locId, organizationId: orgId });
|
||||
assert.equal(a.status, 201);
|
||||
const b = await owner.request('POST', '/api/admin/offerings', { offeringId: offeringB, slug: `p7-camp-b-${RUN_ID}`, name: 'Camp B (self-paced)', programId: PROGRAM, deliveryMode: 'self_paced' });
|
||||
assert.equal(b.status, 201);
|
||||
assert.equal(b.body.capacity, null, 'a self-paced offering should be unlimited by default');
|
||||
// Phase 8 made registration-window enforcement real (server-side, not just client hiding a
|
||||
// button) — a still-draft offering now correctly rejects registration attempts. offeringB
|
||||
// needs to be open before anything in this file registers against it.
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringB}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringB}/transition`, { status: 'enrollment_open' });
|
||||
});
|
||||
|
||||
// ── 5: capacity ───────────────────────────────────────────────────────────────────────────
|
||||
await test('offering capacity is enforced atomically, and validation happens BEFORE seat reservation', async () => {
|
||||
for (const p of [{ status: 'published' }, { status: 'enrollment_open' }]) {
|
||||
const r = await owner.request('POST', `/api/admin/offerings/${offeringA}/transition`, p);
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
}
|
||||
const badMinor = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'No Guardian', isMinor: true });
|
||||
assert.equal(badMinor.status, 400, 'a validation failure must not consume a seat');
|
||||
const check0 = await owner.request('GET', `/api/admin/offerings/${offeringA}`);
|
||||
assert.equal(check0.body.reserved_seats, 0);
|
||||
|
||||
const reg1 = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'Reg One', registrantEmail: `reg1-${RUN_ID}@example.com` });
|
||||
assert.equal(reg1.status, 201);
|
||||
const reg2 = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'Reg Two', registrantEmail: `reg2-${RUN_ID}@example.com` });
|
||||
assert.equal(reg2.status, 201);
|
||||
const reg3 = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'Reg Three (overflow)', registrantEmail: `reg3-${RUN_ID}@example.com` });
|
||||
assert.equal(reg3.status, 409);
|
||||
assert.equal(reg3.body.error, 'OFFERING_FULL');
|
||||
});
|
||||
|
||||
// ── 7/8/9: organization/location/instructor assignment ───────────────────────────────────
|
||||
await test('a plain instructor is listed and assignable to an offering, reusing cohort_instructors\' role vocabulary', async () => {
|
||||
const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p7-stray-${RUN_ID}@example.com`, display_name: 'P7 Stray', role: 'instructor', password: 'P7StrayPass123!' });
|
||||
const link = await owner.request('POST', `/api/admin/offerings/${offeringA}/instructors`, { instructor_id: strayCreate.instructor_id, role: 'teacher' });
|
||||
assert.equal(link.status, 201);
|
||||
});
|
||||
|
||||
// ── 6/9: guardians, student-guardian relationship ────────────────────────────────────────
|
||||
const guardianId = `guardian-p7-${RUN_ID}`;
|
||||
await test('a guardian is created and can be linked to a student', async () => {
|
||||
const g = await owner.request('POST', '/api/admin/guardians', { guardianId, displayName: 'P7 Guardian', email: `guardian-${RUN_ID}@example.com` });
|
||||
assert.equal(g.status, 201);
|
||||
});
|
||||
|
||||
// ── 11/12: adult learner path without a guardian; minor path without corrupting identity ──
|
||||
let minorRegId, adultRegId;
|
||||
await test('a minor registration requires a guardian; an adult registration needs none', async () => {
|
||||
const minorReg = await owner.request('POST', `/api/admin/offerings/${offeringB}/registrations`, { guardianId, registrantDisplayName: 'P7 Minor', isMinor: true });
|
||||
assert.equal(minorReg.status, 201);
|
||||
minorRegId = minorReg.body.registration_id;
|
||||
const adultReg = await owner.request('POST', `/api/admin/offerings/${offeringB}/registrations`, { registrantDisplayName: 'P7 Adult', registrantEmail: `adult-${RUN_ID}@example.com` });
|
||||
assert.equal(adultReg.status, 201);
|
||||
adultRegId = adultReg.body.registration_id;
|
||||
});
|
||||
|
||||
// ── 13/14: registration lifecycle ────────────────────────────────────────────────────────
|
||||
await test('a registration moves through its real lifecycle, rejecting an invalid transition', async () => {
|
||||
const bad = await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'enrollment_activated' });
|
||||
assert.equal(bad.status, 409, 'cannot jump straight from started to enrollment_activated');
|
||||
for (const status of ['submitted', 'agreements_pending']) {
|
||||
const r = await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status });
|
||||
assert.equal(r.status, 200, `${status}: ${JSON.stringify(r.body)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 15: agreement-version acceptance ─────────────────────────────────────────────────────
|
||||
const agreementId = `agree-p7-${RUN_ID}`;
|
||||
await test('an agreement definition is created and a guardian acceptance is recorded with a version snapshot', async () => {
|
||||
const def = await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'P7 Test Agreement', bodySummary: 'Plain-language summary only.' });
|
||||
assert.equal(def.status, 201);
|
||||
const accept = await owner.request('POST', `/api/admin/registrations/${minorRegId}/agreements`, { agreementId, acceptedByType: 'guardian', acceptedByGuardianId: guardianId });
|
||||
assert.equal(accept.status, 201);
|
||||
assert.equal(accept.body.accepted_version, 1);
|
||||
const list = await owner.request('GET', `/api/admin/registrations/${minorRegId}/agreements`);
|
||||
assert.equal(list.body.length, 1);
|
||||
});
|
||||
|
||||
// ── 16/17/18/19: offering enrollment conversion, offering→cohort, self-paced without cohort ─
|
||||
await test('a confirmed registration converts to a real enrollment (self-paced offering, no cohort)', async () => {
|
||||
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'confirmed' });
|
||||
const convert = await owner.request('POST', `/api/admin/registrations/${minorRegId}/convert`, {});
|
||||
assert.equal(convert.status, 201, JSON.stringify(convert.body));
|
||||
assert.equal(convert.body.enrollment.program_id, PROGRAM);
|
||||
assert.equal(convert.body.enrollment.status, 'active');
|
||||
const student = convert.body.student;
|
||||
assert.ok(student.login_identifier && !student.email, 'a minor with no email gets a login_identifier, never a guardian email written to students.email');
|
||||
const cohortRow = psql(`SELECT count(*) FROM cohort_enrollments WHERE student_id = '${student.student_id}'`);
|
||||
assert.equal(Number(cohortRow), 0, 'offeringB has no cohort_id — no cohort membership should be created');
|
||||
});
|
||||
|
||||
// ── 21: historical package version stability ─────────────────────────────────────────────
|
||||
await test('bumping a curriculum package\'s version does not change its historical item list retroactively in a confusing way', async () => {
|
||||
psql(`UPDATE curriculum_packages SET version = version + 1 WHERE curriculum_package_id = '${pkgId}'`);
|
||||
const get = await owner.request('GET', `/api/admin/curriculum-packages/${pkgId}`);
|
||||
assert.equal(get.body.items.length, 2, 'items must survive a version bump on the package row itself');
|
||||
});
|
||||
|
||||
// ── 22/23: registration RBAC, guardian data RBAC ─────────────────────────────────────────
|
||||
await test('a plain instructor cannot view registrations or create a guardian/organization, but CAN list offerings', async () => {
|
||||
const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p7-rbac-${RUN_ID}@example.com`, display_name: 'P7 RBAC', role: 'instructor', password: 'P7RbacPass123!' });
|
||||
const stray = makeSession(GW);
|
||||
const login = await stray.request('POST', '/api/auth/instructor/login', { email: `p7-rbac-${RUN_ID}@example.com`, password: 'P7RbacPass123!' });
|
||||
assert.equal(login.status, 200);
|
||||
const regs = await stray.request('GET', `/api/admin/offerings/${offeringB}/registrations`);
|
||||
assert.equal(regs.status, 403);
|
||||
const guardianCreate = await stray.request('POST', '/api/admin/guardians', { guardianId: `g-rbac-${RUN_ID}`, displayName: 'Should Fail' });
|
||||
assert.equal(guardianCreate.status, 403);
|
||||
const orgCreate = await stray.request('POST', '/api/admin/organizations', { organizationId: `org-rbac-${RUN_ID}`, name: 'Should Fail' });
|
||||
assert.equal(orgCreate.status, 403);
|
||||
const listOfferings = await stray.request('GET', '/api/admin/offerings');
|
||||
assert.equal(listOfferings.status, 200, 'listing offerings themselves is not sensitive');
|
||||
const listLocations = await stray.request('GET', '/api/admin/locations');
|
||||
assert.equal(listLocations.status, 200);
|
||||
assert.ok(listLocations.body.every((l) => !('access_notes' in l)), 'a plain instructor must never receive access_notes');
|
||||
});
|
||||
|
||||
// ── 27: no age-based authorization lock anywhere ─────────────────────────────────────────
|
||||
await test('an offering with a recommended age range still accepts a registrant of any age (guidance only)', async () => {
|
||||
const agedOffering = `off-aged-${RUN_ID}`;
|
||||
const create = await owner.request('POST', '/api/admin/offerings', { offeringId: agedOffering, slug: `p7-aged-${RUN_ID}`, name: 'Aged Offering', programId: PROGRAM, deliveryMode: 'workshop', recommendedMinAge: 15, recommendedMaxAge: 18 });
|
||||
assert.equal(create.status, 201);
|
||||
await owner.request('POST', `/api/admin/offerings/${agedOffering}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${agedOffering}/transition`, { status: 'enrollment_open' });
|
||||
const reg = await owner.request('POST', `/api/admin/offerings/${agedOffering}/registrations`, { registrantDisplayName: 'Any Age Registrant', registrantEmail: `anyage-${RUN_ID}@example.com` });
|
||||
assert.equal(reg.status, 201, 'no age field on the registration itself, and none is checked');
|
||||
});
|
||||
|
||||
// ── 28/29/30: existing ROOT V1 / self-paced / classroom flows still work ─────────────────
|
||||
await test('an existing ROOT_ V1 self-paced enrollment (unrelated to any offering) still works end to end', async () => {
|
||||
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P7-RootV1-${RUN_ID}`, login_identifier: `p7-rootv1-${RUN_ID}` });
|
||||
const s = makeSession(GW);
|
||||
await s.request('POST', '/api/student/activate', { token: create.activation_token, password: 'RootV1Pass123!' });
|
||||
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
|
||||
const r = await s.request('POST', `/api/student/activities/root-01-m00-a01-lesson-welcome/complete`);
|
||||
assert.equal(r.status, 200);
|
||||
const { body } = await s.request('GET', `/api/student/programs/${enroll.enrollment_id}/courses`);
|
||||
assert.ok(body.courses.some((c) => c.course_id === 'root-01-computing-linux-foundations'));
|
||||
});
|
||||
|
||||
await test('an existing classroom (cohort) flow still works unaffected', async () => {
|
||||
const cohortId = `p7-cohort-check-${RUN_ID}`;
|
||||
const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P7 Cohort Check', tier: 'root' });
|
||||
assert.equal(create.status, 201);
|
||||
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P7 Check Session' });
|
||||
assert.equal(session.status, 201);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 8 — Enrollment Operations acceptance test. Admin offering/registration operations,
|
||||
// launch readiness, registration-window enforcement, capacity concurrency through the REAL
|
||||
// registration endpoints, the public offering serializer, the website registration integration
|
||||
// contract (idempotency, concurrent duplicates), agreements (required/missing/version snapshot,
|
||||
// blocking conversion), guardian recipient resolution, RBAC, and confirmation that ROOT_ V1/
|
||||
// self-paced/classroom flows are unaffected. Same conventions as e2e-phase6a-7.js.
|
||||
//
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||
// OWNER_EMAIL=... OWNER_PASSWORD=... INTEGRATION_SHARED_SECRET=... node test/e2e-phase8.js
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('./session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||
}
|
||||
function psql(sql) {
|
||||
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
|
||||
}
|
||||
async function integrationPost(path, body) {
|
||||
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 8 Enrollment Operations acceptance test\n');
|
||||
const owner = makeSession(GW);
|
||||
{ const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(r.status, 200); }
|
||||
|
||||
// ── 1/2/3: admin offering list/detail/CRUD ───────────────────────────────────────────────
|
||||
let offeringId, slug;
|
||||
await test('admin can create, list, and fetch an offering', async () => {
|
||||
offeringId = `off-p8-${RUN_ID}`; slug = `p8-camp-${RUN_ID}`;
|
||||
const create = await owner.request('POST', '/api/admin/offerings', { offeringId, slug, name: 'Phase 8 Test Camp', programId: PROGRAM, deliveryMode: 'camp', capacity: 3 });
|
||||
assert.equal(create.status, 201);
|
||||
const list = await owner.request('GET', '/api/admin/offerings');
|
||||
assert.ok(list.body.some((o) => o.offering_id === offeringId));
|
||||
const detail = await owner.request('GET', `/api/admin/offerings/${offeringId}`);
|
||||
assert.equal(detail.status, 200);
|
||||
assert.equal(detail.body.status, 'draft');
|
||||
});
|
||||
|
||||
// ── 4/5: status transitions, readiness calculation ───────────────────────────────────────
|
||||
await test('status transitions are server-defined; readiness reflects real configuration', async () => {
|
||||
const badTransition = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'in_progress' });
|
||||
assert.equal(badTransition.status, 409);
|
||||
const r1 = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
|
||||
assert.equal(r1.body.ready_to_open_registration, false);
|
||||
const instructorCreate = await owner.request('POST', '/api/admin/instructors', { email: `p8-lead-${RUN_ID}@example.com`, display_name: 'P8 Lead', role: 'instructor', password: 'P8LeadPass123!' });
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role: 'teacher' });
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
|
||||
const r2 = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
|
||||
const instructorCheck = r2.body.checks.find((c) => c.key === 'instructor');
|
||||
assert.equal(instructorCheck.ready, true);
|
||||
});
|
||||
|
||||
// ── 6/7: registration open/closed enforcement ────────────────────────────────────────────
|
||||
let closedOffering;
|
||||
await test('registration is rejected on a draft offering, and accepted once open', async () => {
|
||||
closedOffering = `off-p8-closed-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/offerings', { offeringId: closedOffering, slug: `p8-closed-${RUN_ID}`, name: 'Closed Offering', programId: PROGRAM, deliveryMode: 'workshop' });
|
||||
const rejected = await owner.request('POST', `/api/admin/offerings/${closedOffering}/registrations`, { registrantDisplayName: 'Too Early' });
|
||||
assert.equal(rejected.status, 409);
|
||||
assert.equal(rejected.body.error, 'OFFERING_NOT_OPEN');
|
||||
const accepted = await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, { registrantDisplayName: 'Adult First', registrantEmail: `adult-first-${RUN_ID}@example.com` });
|
||||
assert.equal(accepted.status, 201);
|
||||
});
|
||||
|
||||
// ── 8/9: full capacity + concurrent final-seat race ──────────────────────────────────────
|
||||
await test('capacity is enforced and a concurrent final-seat race yields exactly one winner', async () => {
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, { registrantDisplayName: 'Adult Second', registrantEmail: `adult-second-${RUN_ID}@example.com` });
|
||||
// capacity=3, 2 used — one seat left. Race two integration submissions for it.
|
||||
const [a, b] = await Promise.all([
|
||||
integrationPost('/api/integrations/registrations', { event_id: `p8-race-a-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race A', email: `racea-${RUN_ID}@example.com` } }),
|
||||
integrationPost('/api/integrations/registrations', { event_id: `p8-race-b-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race B', email: `raceb-${RUN_ID}@example.com` } }),
|
||||
]);
|
||||
const statuses = [a.status, b.status].sort();
|
||||
assert.deepEqual(statuses, [201, 409], `expected exactly one winner: ${JSON.stringify([a, b])}`);
|
||||
const final = await owner.request('GET', `/api/admin/offerings/${offeringId}`);
|
||||
assert.equal(final.body.reserved_seats, 3);
|
||||
assert.ok(final.body.reserved_seats <= final.body.capacity, 'must never overbook');
|
||||
});
|
||||
|
||||
// ── 10/11: public offering safe serializer, no internal fields leaked ───────────────────
|
||||
await test('the public offerings API never leaks internal/PII fields', async () => {
|
||||
const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json());
|
||||
const forbidden = ['access_notes', 'organization_id', 'location_id', 'created_by', 'reserved_seats', 'capacity', 'metadata'];
|
||||
for (const key of forbidden) assert.ok(!(key in pub), `public offering response must never include ${key}`);
|
||||
assert.equal(pub.availability, 'full', 'a fully-booked offering should report full availability, not a raw seat count');
|
||||
});
|
||||
|
||||
// ── 12/13/14: website registration integration, duplicate idempotency, concurrent dup ────
|
||||
let integrationOffering, integrationSlug;
|
||||
await test('website registration integration is idempotent under sequential AND concurrent replay', async () => {
|
||||
integrationOffering = `off-p8-web-${RUN_ID}`; integrationSlug = `p8-web-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/offerings', { offeringId: integrationOffering, slug: integrationSlug, name: 'Web Offering', programId: PROGRAM, deliveryMode: 'self_paced' });
|
||||
await owner.request('POST', `/api/admin/offerings/${integrationOffering}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${integrationOffering}/transition`, { status: 'enrollment_open' });
|
||||
const eventId = `p8-web-evt-${RUN_ID}`;
|
||||
const first = await integrationPost('/api/integrations/registrations', { event_id: eventId, offering_slug: integrationSlug, registrant: { display_name: 'Web Student', email: `webstudent-${RUN_ID}@example.com` } });
|
||||
assert.equal(first.status, 201);
|
||||
const replay = await integrationPost('/api/integrations/registrations', { event_id: eventId, offering_slug: integrationSlug, registrant: { display_name: 'Web Student', email: `webstudent-${RUN_ID}@example.com` } });
|
||||
assert.equal(replay.status, 200);
|
||||
assert.equal(replay.body.registration_id, first.body.registration_id);
|
||||
// Concurrent duplicate — two identical submissions racing the same event_id.
|
||||
const concEventId = `p8-web-conc-${RUN_ID}`;
|
||||
const [c1, c2] = await Promise.all([
|
||||
integrationPost('/api/integrations/registrations', { event_id: concEventId, offering_slug: integrationSlug, registrant: { display_name: 'Conc Student', email: `conc-${RUN_ID}@example.com` } }),
|
||||
integrationPost('/api/integrations/registrations', { event_id: concEventId, offering_slug: integrationSlug, registrant: { display_name: 'Conc Student', email: `conc-${RUN_ID}@example.com` } }),
|
||||
]);
|
||||
assert.equal(c1.body.registration_id, c2.body.registration_id, 'a concurrent duplicate submission must resolve to exactly one logical registration');
|
||||
const count = psql(`SELECT count(*) FROM offering_registrations WHERE registration_id = '${c1.body.registration_id}'`);
|
||||
assert.equal(Number(count), 1);
|
||||
});
|
||||
|
||||
// ── 15/16/17: adult registration, minor registration, guardian relationship ──────────────
|
||||
let guardianId, minorRegId;
|
||||
await test('adult registration needs no guardian; minor registration creates/links a real guardian', async () => {
|
||||
guardianId = `guardian-p8-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/guardians', { guardianId, displayName: 'P8 Guardian', email: `p8guardian-${RUN_ID}@example.com` });
|
||||
const minorReg = await owner.request('POST', `/api/admin/offerings/${integrationOffering}/registrations`, { guardianId, registrantDisplayName: 'P8 Minor', isMinor: true });
|
||||
assert.equal(minorReg.status, 201);
|
||||
minorRegId = minorReg.body.registration_id;
|
||||
const badMinor = await owner.request('POST', `/api/admin/offerings/${integrationOffering}/registrations`, { registrantDisplayName: 'No Guardian', isMinor: true });
|
||||
assert.equal(badMinor.status, 400);
|
||||
});
|
||||
|
||||
// ── 18: student email semantics preserved (no guardian email written to students.email) ──
|
||||
await test('converting a minor registration never writes the guardian email into students.email', async () => {
|
||||
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'submitted' });
|
||||
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'confirmed' });
|
||||
const convert = await owner.request('POST', `/api/admin/registrations/${minorRegId}/convert`, {});
|
||||
assert.equal(convert.status, 201, JSON.stringify(convert.body));
|
||||
assert.equal(convert.body.student.email, null);
|
||||
assert.ok(convert.body.student.login_identifier.startsWith('reg-'));
|
||||
});
|
||||
|
||||
// ── 19/20/21: required agreement behavior, version snapshot, missing blocks conversion ───
|
||||
let agreementId, agreementOffering;
|
||||
await test('a required agreement blocks conversion until accepted; acceptance snapshots the version', async () => {
|
||||
agreementId = `agree-p8-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'P8 Agreement' });
|
||||
agreementOffering = `off-p8-agree-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/offerings', { offeringId: agreementOffering, slug: `p8-agree-${RUN_ID}`, name: 'Agreement Offering', programId: PROGRAM, deliveryMode: 'workshop' });
|
||||
await owner.request('POST', `/api/admin/offerings/${agreementOffering}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${agreementOffering}/transition`, { status: 'enrollment_open' });
|
||||
await owner.request('POST', `/api/admin/offerings/${agreementOffering}/agreement-requirements`, { agreement_id: agreementId });
|
||||
const reg = await owner.request('POST', `/api/admin/offerings/${agreementOffering}/registrations`, { registrantDisplayName: 'Agreement Tester', registrantEmail: `agreetest-${RUN_ID}@example.com` });
|
||||
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'submitted' });
|
||||
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'confirmed' });
|
||||
const blocked = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/convert`, {});
|
||||
assert.equal(blocked.status, 409);
|
||||
assert.equal(blocked.body.error, 'AGREEMENTS_INCOMPLETE');
|
||||
const accept = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/agreements`, { agreementId, acceptedByType: 'registrant' });
|
||||
assert.equal(accept.status, 201);
|
||||
assert.equal(accept.body.accepted_version, 1);
|
||||
psql(`UPDATE agreement_definitions SET version = version + 1 WHERE agreement_id = '${agreementId}'`);
|
||||
const accepted = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/agreement-status`);
|
||||
assert.equal(accepted.body.missing.length, 0, 'the already-recorded acceptance must still count even after the definition\'s live version changed');
|
||||
const nowOk = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/convert`, {});
|
||||
assert.equal(nowOk.status, 201);
|
||||
});
|
||||
|
||||
// ── 22/23: registration→enrollment, offering→cohort ──────────────────────────────────────
|
||||
await test('a cohort-backed offering creates real cohort membership on conversion', async () => {
|
||||
const cohortId = `p8-cohort-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P8 Cohort', tier: 'root' });
|
||||
const cohortOffering = `off-p8-cohort-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/offerings', { offeringId: cohortOffering, slug: `p8-cohort-off-${RUN_ID}`, name: 'Cohort Offering', programId: PROGRAM, deliveryMode: 'cohort', cohortId });
|
||||
await owner.request('POST', `/api/admin/offerings/${cohortOffering}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${cohortOffering}/transition`, { status: 'enrollment_open' });
|
||||
const reg = await owner.request('POST', `/api/admin/offerings/${cohortOffering}/registrations`, { registrantDisplayName: 'Cohort Student', registrantEmail: `cohortstudent-${RUN_ID}@example.com` });
|
||||
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'submitted' });
|
||||
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'confirmed' });
|
||||
const convert = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/convert`, {});
|
||||
const cohortRow = psql(`SELECT count(*) FROM cohort_enrollments WHERE cohort_id = '${cohortId}' AND student_id = '${convert.body.student.student_id}'`);
|
||||
assert.equal(Number(cohortRow), 1);
|
||||
});
|
||||
|
||||
// ── 24/25: account activation, notification queued ───────────────────────────────────────
|
||||
await test('a converted student has a real activation token and a queued registration notification', async () => {
|
||||
const notifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
|
||||
assert.ok(notifs.body.length > 0, 'at least a registration_received notification must have been queued');
|
||||
assert.ok(notifs.body.every((n) => !n.recipient_email_masked || n.recipient_email_masked.includes('*')), 'recipient email must always be masked in the admin view');
|
||||
});
|
||||
|
||||
// ── 26: guardian recipient resolution ────────────────────────────────────────────────────
|
||||
await test('notification recipient resolution prefers a real guardian when the student has no email', async () => {
|
||||
const notifications = require('../api/src/notifications');
|
||||
const { Pool } = require('../api/node_modules/pg');
|
||||
const pool = new Pool({ connectionString: process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform' });
|
||||
try {
|
||||
const { rows: studentRows } = await pool.query(`SELECT student_id FROM offering_registrations WHERE registration_id = '${minorRegId}'`);
|
||||
const studentId = studentRows[0].student_id;
|
||||
await pool.query(`INSERT INTO student_guardians (student_id, guardian_id, is_primary_contact, can_receive_communications) VALUES ($1, $2, true, true) ON CONFLICT (student_id, guardian_id) DO NOTHING`, [studentId, guardianId]);
|
||||
const recipient = await notifications.resolveRecipient(pool, studentId, null);
|
||||
assert.equal(recipient, `p8guardian-${RUN_ID}@example.com`);
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
// ── 28/29: instructor offering access, instructor guardian-data denial ──────────────────
|
||||
await test('a plain instructor can see offerings but not guardians/registrations; RBAC holds', async () => {
|
||||
const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p8-stray-${RUN_ID}@example.com`, display_name: 'P8 Stray', role: 'instructor', password: 'P8StrayPass123!' });
|
||||
const stray = makeSession(GW);
|
||||
await stray.request('POST', '/api/auth/instructor/login', { email: `p8-stray-${RUN_ID}@example.com`, password: 'P8StrayPass123!' });
|
||||
const offeringsList = await stray.request('GET', '/api/admin/offerings');
|
||||
assert.equal(offeringsList.status, 200);
|
||||
const guardianDenied = await stray.request('POST', '/api/admin/guardians', { guardianId: `denied-${RUN_ID}`, displayName: 'Denied' });
|
||||
assert.equal(guardianDenied.status, 403);
|
||||
const registrationsDenied = await stray.request('GET', `/api/admin/offerings/${offeringId}/registrations`);
|
||||
assert.equal(registrationsDenied.status, 403);
|
||||
});
|
||||
|
||||
// ── 31: organization/location behavior ───────────────────────────────────────────────────
|
||||
await test('organization/location creation and access_notes RBAC stripping', async () => {
|
||||
const orgId = `org-p8-${RUN_ID}`, locId = `loc-p8-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/organizations', { organizationId: orgId, name: 'P8 Org', type: 'library' });
|
||||
await owner.request('POST', '/api/admin/locations', { locationId: locId, organizationId: orgId, name: 'P8 Loc', accessNotes: 'secret internal code' });
|
||||
const asOwner = await owner.request('GET', '/api/admin/locations');
|
||||
assert.ok(asOwner.body.find((l) => l.location_id === locId).access_notes);
|
||||
});
|
||||
|
||||
// ── 33/34/35: existing ROOT V1 / self-paced / classroom flows unaffected ────────────────
|
||||
await test('an existing ROOT_ V1 self-paced enrollment is completely unaffected by Phase 8', async () => {
|
||||
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P8-RootV1-${RUN_ID}`, login_identifier: `p8-rootv1-${RUN_ID}` });
|
||||
const s = makeSession(GW);
|
||||
await s.request('POST', '/api/student/activate', { token: create.activation_token, password: 'RootV1Pass123!' });
|
||||
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
|
||||
const r = await s.request('POST', `/api/student/activities/root-01-m00-a01-lesson-welcome/complete`);
|
||||
assert.equal(r.status, 200);
|
||||
});
|
||||
await test('an existing classroom (cohort) flow still works unaffected', async () => {
|
||||
const cohortId = `p8-cohort-check-${RUN_ID}`;
|
||||
const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P8 Cohort Check', tier: 'root' });
|
||||
assert.equal(create.status, 201);
|
||||
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P8 Check Session' });
|
||||
assert.equal(session.status, 201);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user