Author SHA1 Message Date
ced4568andClaude Sonnet 5 df5e39f767 docs(phase7): document the business/product architecture foundation
New: ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md (canonical business/platform
bridge — course families, delivery modes, BUILT NOW/PHASE 7 BUILT/FUTURE), OFFERING_AND_
REGISTRATION_MODEL.md (lifecycle diagrams, capacity mechanics), STUDENT_GUARDIAN_DATA_
MODEL.md (data minimization, RBAC, legal-review items), SCHOOL_AND_COMMUNITY_DELIVERY_
ARCHITECTURE.md (organization/location mapping, explicitly not multi-tenancy),
PHASE7_PRODUCT_ARCHITECTURE.md (phase summary).

Updated: WEBSITE_ENROLLMENT_INTEGRATION.md (documented future offering/registration flow,
current live contract unchanged), platform/README.md (test list).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 22:44:13 -05:00
ced4568andClaude Sonnet 5 7d870d8e2b test(phase7): add business/product architecture acceptance suite and Winter Camp rehearsal
- e2e-phase7.js (17/17): organizations/locations, guardians, curriculum packages
  (reference-only), offering capacity/transitions, registration lifecycle, agreements,
  registration-to-enrollment conversion, RBAC, age-guidance-independence, and confirmation
  that existing ROOT_ V1 self-paced/classroom flows are unaffected.
- test-phase7-winter-camp.js (18/18): the full Winter Break Camp operational path with NO
  payment code invoked — organization/location/curriculum-package/cohort/offering setup,
  guardian + adult registration, agreement acceptance, a simulated admin-side commerce
  confirmation, conversion to real enrollments and real cohort membership, real account
  activation via real one-time tokens, a real classroom session, and a REAL Lab task
  (linux-inspect-hardware) completed by the minor student inside a real disposable Lab
  container, confirmed to survive a real logout/login.

Regression: e2e-phase6f.js 17/17, e2e-phase5b.js 32/32 (including its own embedded Phase 5A
checks) on independent fresh stacks.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 22:44:02 -05:00
ced4568andClaude Sonnet 5 3510979196 feat(platform): business/product architecture — organizations, guardians, offerings, registrations
- organizations, locations, guardians, student_guardians (033) — additive, no existing
  table's meaning changes. Widens programs.family to accept 'bootcamp'.
- curriculum_packages/curriculum_package_items, offerings, instructor_offerings,
  offering_registrations, agreement_definitions/agreement_acceptances (034) — the full
  purchasable-delivery model. Server-defined status transitions only (no client-controlled
  offering/registration status). Capacity uses a single atomic UPDATE (reserved_seats),
  never a SELECT-count-then-INSERT race.
- offerings.js — business logic: reserveSeat/releaseSeat, transitionOffering/
  transitionRegistration (fixed transition tables), convertRegistrationToEnrollment
  (reuses enrollment.js's existing resolveOrCreateStudent/createManualEnrollment rather
  than duplicating student-matching logic).
- enrollment.js — resolveOrCreateStudent gains an optional, default-off returnToken param
  (used only by offerings.js's admin-only conversion route; the public website integration
  endpoint's contract is completely unchanged, per docs/WEBSITE_ENROLLMENT_INTEGRATION.md).
- admin-offerings-routes.js — the full admin/owner API surface. Registration/guardian
  detail is admin/owner only; offering/location listing is any instructor (access_notes
  stripped for a plain instructor).
- A real bug found and fixed during this phase's own smoke testing before any commit: the
  registration route originally reserved a seat BEFORE validating the registration's own
  fields, letting a validation failure consume a real seat and get masked behind a false
  OFFERING_FULL under a full offering. Fixed: validate (create) first, reserve second, roll
  back the registration row if reservation then genuinely fails.

See docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md and
docs/OFFERING_AND_REGISTRATION_MODEL.md.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 22:43:52 -05:00
16 changed files with 1844 additions and 7 deletions
+117
View File
@@ -0,0 +1,117 @@
# Offering & Registration Model
Phase 7 Steps 3/6/12-14/18-20 — the schema and lifecycle for offerings, capacity, registrations,
agreements, and curriculum packages. See `platform/api/migrations/034_phase7_offerings_
registrations_agreements.sql` for the exact DDL and `platform/api/src/offerings.js` for the
implementation.
## 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.
**A real ordering bug found and fixed during this phase's own testing**: the registration route
originally reserved the seat *before* validating the registration's own fields (e.g. a minor
registration missing a `guardianId`). Under a full offering, that let a plain validation error
get masked behind a false `OFFERING_FULL`, and even when the check passed, a request that would
later fail validation had already consumed a real seat. Fixed by creating (and validating) the
registration row first, then reserving the seat, rolling back (deleting) the registration row if
the seat reservation itself then fails — see `admin-offerings-routes.js`'s own comment.
## 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.
+151
View File
@@ -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,143 @@
# 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) | **NOT BUILT this phase** — see known limitations |
| Public registration flow, 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.
+79
View File
@@ -0,0 +1,79 @@
# 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 (a future concern — this phase writes
the rows but does not wire them into `notifications.js`'s resolveRecipient yet), 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.
+31 -4
View File
@@ -171,10 +171,37 @@ 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.
## Future flow: offerings and registrations (Phase 7, not yet live on this endpoint)
RootNode Academy is now (rootnodeacademy.com, live separately from this repository) building
toward representing specific purchasable deliveries — camps, cohorts, self-paced access — as
first-class **offerings**, with a **registration** step before final enrollment (see
`docs/OFFERING_AND_REGISTRATION_MODEL.md`). The eventual intended flow:
```
Website: learner selects an offering (e.g. "Winter Break Linux & Systems Camp")
-> Website collects registration info (student/guardian, readiness, agreements)
-> Website checkout/payment (future — not built yet)
-> Verified commerce event -> RootNode confirms the registration
-> RootNode activates the enrollment (reusing this exact endpoint's machinery)
-> Student receives their existing activation email, unchanged
-> Student/camp access begins
```
**This is documentation of an intended future shape, not a live contract change.** The endpoint
below is unaffected — it continues to work exactly as documented, program-slug-based, with no
concept of an offering. A future phase will either extend this endpoint with an optional
`offering_slug` field or add a parallel endpoint once the website's actual checkout flow is
designed; that decision has not been made. Idempotency (`event_id`) will remain the mechanism
either way.
## What the student experiences after you call this (as of Phase 5C)
+34
View File
@@ -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,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); });
+6
View File
@@ -106,6 +106,12 @@ 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.
`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
@@ -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);
+182
View File
@@ -0,0 +1,182 @@
// 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 studentsAuth = require('./students-auth'); // same direct-require convention integration-routes.js already uses
function errStatus(code) {
if (code === 'NOT_FOUND' || code === 'PROGRAM_NOT_FOUND' || code === 'OFFERING_NOT_FOUND' || code === 'COURSE_NOT_FOUND' || code === 'AGREEMENT_NOT_FOUND') return 404;
if (code === 'VALIDATION' || code === 'GUARDIAN_REQUIRED') return 400;
if (code === 'DUPLICATE_SLUG' || code === 'OFFERING_FULL' || code === 'INVALID_TRANSITION' || code === 'INVALID_STATE') return 409;
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); }
});
// ── Registrations (admin/owner only) ────────────────────────────────────────────────────
// Validate-then-reserve, not reserve-then-validate — reserving the seat first would let a
// request that fails plain field validation (e.g. a minor with no guardianId) consume a real
// seat before ever being rejected, masking the actual validation error behind a false
// OFFERING_FULL once the last seat is gone. Found during this phase's own smoke testing.
admin.post('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => {
try {
const reg = await offerings.createRegistration(getPool(), { ...req.body, offeringId: req.params.id });
try {
await offerings.reserveSeat(getPool(), req.params.id);
} catch (seatError) {
// Field validation passed but capacity is genuinely exhausted — the registration row
// it already created must not silently exist as a phantom "started" entry with no
// seat behind it.
await getPool().query(`DELETE FROM offering_registrations WHERE registration_id = $1`, [reg.registration_id]);
throw seatError;
}
res.status(201).json(reg);
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => {
res.json(await offerings.listRegistrationsForOffering(getPool(), req.params.id));
});
admin.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.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));
});
};
+14 -3
View File
@@ -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,
};
+307
View File
@@ -0,0 +1,307 @@
// 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');
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: [],
};
async function createRegistration(pool, { offeringId, guardianId, registrantDisplayName, registrantEmail, isMinor, readinessNotes, accessibilityNotes, emergencyContactName, emergencyContactPhone, source, externalReference }) {
if (!offeringId || !registrantDisplayName) throw err('VALIDATION', 'offeringId and registrantDisplayName are required.');
if (isMinor && !guardianId) throw err('GUARDIAN_REQUIRED', 'A minor registration must include a guardianId.');
const offering = await getOffering(pool, offeringId);
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
const { rows } = await pool.query(
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, accessibility_notes, emergency_contact_name, emergency_contact_phone, source, external_reference)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9,COALESCE($10,'admin'),$11) RETURNING *`,
[offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null]
);
return rows[0];
}
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]);
return rows[0];
}
// 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.');
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 recordAgreementAcceptance(pool, { agreementId, acceptedByType, acceptedByStudentId, acceptedByGuardianId, registrationId, enrollmentId, ipAddress }) {
const { rows: defRows } = await pool.query(`SELECT * FROM agreement_definitions WHERE agreement_id = $1`, [agreementId]);
const def = defRows[0];
if (!def) throw err('AGREEMENT_NOT_FOUND', 'No such agreement.');
if (!acceptedByStudentId && !acceptedByGuardianId) throw err('VALIDATION', 'An acceptance must be tied to a student or a guardian.');
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;
}
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, recordAgreementAcceptance, listAcceptancesForRegistration,
};
+8
View File
@@ -413,6 +413,14 @@ 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,
});
// Fallback to the platform-shell SPA for any unmatched non-API route
app.get('*', (req, res) => {
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
+239
View File
@@ -0,0 +1,239 @@
#!/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 should ever mention these fields, and neither
// is an authorization/RBAC file — confirmed by name, not content-sniffing.
for (const file of src.trim().split('\n').filter(Boolean)) {
assert.ok(/offerings\.js|admin-offerings-routes\.js/.test(file), `unexpected file referencing age-guidance fields: ${file}`);
}
});
// ── 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');
});
// ── 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);
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); });