Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df5e39f767 | ||
|
|
7d870d8e2b | ||
|
|
3510979196 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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); });
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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'));
|
||||
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user