Compare commits
22
Commits
v0.6.0-root-v1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a5902ddf | ||
|
|
bfae2aafe0 | ||
|
|
74033b4cb8 | ||
|
|
0b2303a3e2 | ||
|
|
a4c0058f8c | ||
|
|
fbf52d8334 | ||
|
|
c86b3de611 | ||
|
|
77fd1869db | ||
|
|
f0ff14af38 | ||
|
|
80a5f6e457 | ||
|
|
281d60bc2d | ||
|
|
8b3e56e402 | ||
|
|
f0dde9b84a | ||
|
|
16622bd9ab | ||
|
|
b0e07bb17f | ||
|
|
788fb197e0 | ||
|
|
aa63d09fb8 | ||
|
|
40362ec0c9 | ||
|
|
24eee80354 | ||
|
|
df5e39f767 | ||
|
|
7d870d8e2b | ||
|
|
3510979196 |
@@ -0,0 +1,69 @@
|
||||
# Offering Admin Guide
|
||||
|
||||
Phase 8 — how to actually run an offering from `/admin` → Offerings, end to end. Pairs with
|
||||
`docs/OFFERING_AND_REGISTRATION_MODEL.md` (the underlying data model) and `docs/
|
||||
WINTER_BREAK_CAMP_OPERATIONS.md` (the specific Winter Break Camp walkthrough).
|
||||
|
||||
## Creating an offering
|
||||
|
||||
**Offerings → Create offering.** Required: offering ID, URL slug, name, program, delivery mode.
|
||||
Optional at creation: capacity (blank = unlimited), a location. Dates, the registration window,
|
||||
organization, cohort, curriculum package, and agreement requirements are all set from the
|
||||
offering's own detail page after creation — a new offering always starts `draft`, invisible to
|
||||
the public offerings API and closed to registration, so there's no rush to fill in everything
|
||||
before saving.
|
||||
|
||||
## The detail page
|
||||
|
||||
Opening an offering shows:
|
||||
|
||||
- **Overview** — status, seats, and the only status-transition buttons the offering can
|
||||
actually take next (the ones the UI doesn't show are ones the server would reject anyway —
|
||||
see `docs/OFFERING_AND_REGISTRATION_MODEL.md`'s transition diagram).
|
||||
- **Launch readiness** — a live, server-computed checklist (curriculum, instructor, dates,
|
||||
location, capacity, registration window, agreements configured). This is the same check the
|
||||
server itself could use to gate opening registration — it is never just a frontend hint.
|
||||
- **Required agreements** — which agreement definitions this offering requires before a
|
||||
registration can convert to enrollment, with a form to add another.
|
||||
- **Registrations / roster** — every registration against this offering, with a form to add one
|
||||
directly (useful for phone/in-person sign-ups an instructor is entering by hand).
|
||||
|
||||
## Moving an offering through its lifecycle
|
||||
|
||||
`draft → published → enrollment_open → full/in_progress → completed`, or `→ cancelled` from
|
||||
anywhere non-terminal. Only real, server-allowed transitions ever appear as buttons. `full` is
|
||||
not automatic — an offering set to `enrollment_open` that happens to be at capacity will reject
|
||||
new registrations with a clear error, but only moves to the visible `full` status when someone
|
||||
explicitly sets it (or the very next registration attempt trips it) — check the readiness
|
||||
panel/registration list to know why registration is currently unavailable.
|
||||
|
||||
## Working a registration
|
||||
|
||||
Open a registration from the offering's roster to see its own detail: registrant/guardian info,
|
||||
agreement status (required vs. accepted vs. missing), lifecycle transition buttons, and its
|
||||
communication history (every notification queued for it, with recipient always masked). Once a
|
||||
registration is `confirmed` and every required agreement is accepted, **Convert to enrollment**
|
||||
appears — this is the one action that creates the real student account (or matches an existing
|
||||
one), the real academic enrollment, and real cohort membership if the offering has a cohort.
|
||||
|
||||
## Agreements
|
||||
|
||||
**Agreements** (reachable from an offering's "Required agreements" section) lets you define a
|
||||
new agreement (title + a plain-language summary — never legal text, see `docs/
|
||||
STUDENT_GUARDIAN_DATA_MODEL.md`) and associate it with any offering that needs it. A registration
|
||||
cannot convert to enrollment while any required agreement is unaccepted.
|
||||
|
||||
## Guardians
|
||||
|
||||
A minor's registration always carries a linked guardian — create one from the registration form
|
||||
when registering a minor by hand, or let the website integration create/match one automatically
|
||||
from a real submission (see `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`). Guardian records are
|
||||
admin/owner-only — a plain instructor's cohort access to a student's academic progress is
|
||||
unchanged, but guardian contact detail is not part of that.
|
||||
|
||||
## What the admin UI deliberately does not do
|
||||
|
||||
No pricing/payment fields, no legal-document authoring, no bulk email composer — every
|
||||
communication is a templated, transactional notification triggered by a real lifecycle event
|
||||
(see `docs/REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md`), never a free-form message an admin
|
||||
types and blasts out.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Offering & Registration Model
|
||||
|
||||
Phase 7 Steps 3/6/12-14/18-20 (extended in Phase 8, extended again in Phase 9) — the schema and
|
||||
lifecycle for offerings, capacity, registrations, agreements, and curriculum packages. See
|
||||
`platform/api/migrations/034_phase7_offerings_registrations_agreements.sql`,
|
||||
`035_phase8_enrollment_operations.sql`, and `037_phase9_commerce.sql` for the exact DDL and
|
||||
`platform/api/src/offerings.js`/`commerce.js` for the implementation.
|
||||
|
||||
**Updated for Phase 8**: registration is now server-authoritative (draft/cancelled/completed/
|
||||
full/window-closed offerings reject registration attempts with distinct error codes — see
|
||||
`assertRegistrationOpen()`); offerings can require specific agreements before a registration may
|
||||
convert to enrollment (`offering_agreement_requirements`); `accepted_by_type` gained a
|
||||
`'registrant'` value for an adult self-accepting before their student_id exists; the website
|
||||
registration integration (`POST /api/integrations/registrations`) is live — see `docs/
|
||||
WEBSITE_ENROLLMENT_INTEGRATION.md`; and launch readiness is now server-computed
|
||||
(`computeOfferingReadiness()`), exposed at `GET /api/admin/offerings/:id/readiness`.
|
||||
|
||||
**Updated for Phase 9**: an offering can carry a real price (`price_amount`/`price_currency`); a
|
||||
paid offering's registration reserves its seat with a bounded expiry
|
||||
(`reservation_expires_at`) instead of holding it forever; agreement acceptance is now reachable
|
||||
from the public registration contract (closing Phase 8's one open gap); and a verified payment
|
||||
webhook — never a browser redirect — is what actually confirms a registration and activates the
|
||||
enrollment. See "Pricing" and "Payment and the commerce boundary" below, and `docs/
|
||||
PHASE9_COMMERCE_AND_LAUNCH.md` for the full phase report.
|
||||
|
||||
## Offering lifecycle (server-defined transitions only)
|
||||
|
||||
```
|
||||
draft ──► published ──► enrollment_open ──┬──► full ──► enrollment_open (reopen)
|
||||
│ │ │ │
|
||||
│ │ ├──► in_progress ──► completed
|
||||
│ │ │
|
||||
└──► cancelled ◄──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
No route accepts an arbitrary client-supplied status — `offerings.transitionOffering()` checks
|
||||
a fixed transition table and rejects anything not explicitly allowed with `409
|
||||
INVALID_TRANSITION`. `full` is a distinct status from `enrollment_open` (not a computed flag) so
|
||||
an admin can see at a glance why enrollment stopped, and can explicitly reopen it (e.g. a seat
|
||||
freed up) rather than have it silently reappear.
|
||||
|
||||
## Capacity — race-safe by construction
|
||||
|
||||
`offerings.capacity` is nullable (NULL = unlimited, the normal case for `self_paced`).
|
||||
`offerings.reserved_seats` is a maintained counter, incremented by exactly one SQL statement:
|
||||
|
||||
```sql
|
||||
UPDATE offerings SET reserved_seats = reserved_seats + 1
|
||||
WHERE offering_id = $1 AND reserved_seats < capacity
|
||||
RETURNING *;
|
||||
```
|
||||
|
||||
If zero rows come back, the offering is genuinely full — `409 OFFERING_FULL`. This is not a
|
||||
`SELECT count(*) ... then INSERT` pattern (the exact anti-pattern the authorization named): the
|
||||
row-level lock Postgres takes for the UPDATE's own duration makes two concurrent registration
|
||||
attempts for the last seat serialize correctly, with exactly one succeeding, never both.
|
||||
|
||||
**Two real ordering bugs found and fixed across Phase 7 and Phase 8, in opposite directions —
|
||||
the current, final order is: validate fields → reserve the seat → insert the row → notify.**
|
||||
|
||||
Phase 7's own testing found the route originally reserved the seat *before* validating the
|
||||
registration's fields (e.g. a minor missing `guardianId`), letting a plain validation error get
|
||||
masked behind a false `OFFERING_FULL` under a full offering. Fixed by validating first.
|
||||
|
||||
Phase 8 then added a `registration_received` notification fired right after the registration row
|
||||
was inserted — which briefly made the ORIGINAL Phase 7 fix's remaining shape (insert, then
|
||||
reserve, then roll back the row by deleting it if the seat turned out to be unavailable) newly
|
||||
unsafe: the notification row, created after insert but before the seat check, held a real
|
||||
foreign key to the registration — so when reservation failed and the code tried to delete the
|
||||
just-inserted registration row, that DELETE itself failed on the very foreign key the
|
||||
notification had just created. Found via this phase's own regression testing, fixed by moving
|
||||
seat reservation to happen BEFORE the insert (rolling the seat back, not a row, if the insert
|
||||
itself then somehow fails) and only ever queuing the notification once the registration is fully,
|
||||
durably committed — see `offerings.js`'s `createRegistration()` and its own header comment.
|
||||
|
||||
## Registration lifecycle
|
||||
|
||||
```
|
||||
started ──► submitted ──► reviewed ──┬──► agreements_pending ──┬──► payment_pending ──► confirmed ──► enrollment_activated
|
||||
│ │ │ │
|
||||
│ └──────────────────► confirmed ◄────────────────┘
|
||||
└──► cancelled (from any non-terminal state; releases the reserved seat)
|
||||
```
|
||||
|
||||
Not every offering needs every stage — a simple self-paced offering can go `started` →
|
||||
`confirmed` → `enrollment_activated` directly; a stage is a real state a registration CAN pass
|
||||
through, not one it MUST. `enrollment_activated` and `cancelled` are terminal.
|
||||
|
||||
**Phase 9** — for a PRICED offering, a verified `checkout.session.completed` (or the test
|
||||
provider's equivalent) webhook event is what actually walks the registration through whichever
|
||||
of `submitted`/`agreements_pending`/`reviewed`/`payment_pending` it's currently sitting in,
|
||||
straight to `confirmed`, using the exact same `transitionRegistration()` table every other path
|
||||
already enforces — never a shortcut that skips a real state. A failed payment does NOT cancel
|
||||
the registration or release its seat (the registrant can retry checkout against the same
|
||||
reservation); only an abandoned/expired checkout session, or the reservation reaper timing out an
|
||||
un-paid reservation, cancels the registration and releases the seat. See "Payment and the
|
||||
commerce boundary" below.
|
||||
|
||||
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.
|
||||
|
||||
## Pricing (Phase 9)
|
||||
|
||||
`offerings.price_amount` (integer minor units — cents, matching Stripe's own convention;
|
||||
`NULL` = free/no checkout) and `offerings.price_currency` (default `usd`), both editable via
|
||||
`PATCH /api/admin/offerings/:id`. A registration's checkout amount always comes from the
|
||||
offering's OWN server-side price at checkout-creation time — never from anything the browser or
|
||||
website backend supplies (`commerce.createCheckoutSession()` reads it directly from the
|
||||
`offerings` row; there is no `amount` field anywhere in the checkout request the website sends).
|
||||
`computeOfferingReadiness()` gained matching `price` and `payment_provider` checks — a priced
|
||||
offering isn't launch-ready until a real price is set and the configured payment provider is
|
||||
actually usable (the test provider always is; Stripe requires both `STRIPE_SECRET_KEY` and
|
||||
`STRIPE_WEBHOOK_SECRET` to be set).
|
||||
|
||||
## Payment and the commerce boundary (Phase 9)
|
||||
|
||||
**Payment is a narrow, provider-agnostic layer bolted onto the existing registration model, not
|
||||
a redesign of it.** `payment_transactions` (one row per checkout attempt: amount/currency
|
||||
snapshotted at creation, provider, provider_reference, status) and `payment_webhook_events` (one
|
||||
row per provider event id — the ENTIRE webhook idempotency mechanism, a real unique primary key,
|
||||
not a "check then insert") are the only two new tables. See `docs/PHASE9_COMMERCE_AND_LAUNCH.md`
|
||||
for the payment-provider abstraction, the webhook contract, and the reservation-reaper design in
|
||||
full, and `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the website's own checkout contract.
|
||||
|
||||
**Three separately-tracked statuses, never auto-cascading into each other:** the registration's
|
||||
own `status` (this document), the enrollment's own `status` (unchanged, `enrollment.js`), and a
|
||||
payment transaction's own `status` (`payment_transactions.status`). A refund updates ONLY the
|
||||
transaction's status — it never automatically cancels the registration or the enrollment; an
|
||||
admin decides separately whether a refund also means "this student is no longer enrolled,"
|
||||
because the two are genuinely different real-world decisions (a partial-camp refund for a missed
|
||||
day is not the same decision as pulling a student's access).
|
||||
|
||||
## What was deliberately not built
|
||||
|
||||
Subscriptions, membership billing, coupons/discount codes, gift cards, installment/financing
|
||||
plans, complex tax automation, and accounting-system (QuickBooks etc.) integration — all
|
||||
explicitly out of scope, per Phase 9's own authorization. A waitlist was considered and
|
||||
deliberately deferred (see `docs/PHASE9_COMMERCE_AND_LAUNCH.md`'s "What was deliberately not
|
||||
built" section for the reasoning). `offerings.metadata` (JSONB) still has room for future
|
||||
descriptive marketing fields, but nothing writes payment data into it — payment data lives only
|
||||
in `payment_transactions`.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Phase 10 — Canonical State Audit
|
||||
|
||||
Gate 1. Performed before any Phase 10 production/launch work, per Phase 10's own explicit
|
||||
requirement — Chase has been developing from two workstations, so nothing downstream was
|
||||
started until canonical state was actually verified, not assumed.
|
||||
|
||||
## Baseline
|
||||
|
||||
- Starting SHA (as reported at the end of Phase 9.1): `a4c00580...` (full:
|
||||
`a4c0058f8c3d9612f1d30508626fa5be00431fa7`).
|
||||
- This workstation's `HEAD` and `origin/main`, confirmed via `git fetch --all --prune`, are
|
||||
**both exactly `a4c0058f8c3d9612f1d30508626fa5be00431fa7`** — this workstation is fully
|
||||
up to date with canonical Gitea, and canonical Gitea has not moved since Phase 9.1 ended.
|
||||
|
||||
## 1.1 Current workstation state
|
||||
|
||||
- Branch: `main`. Working tree: clean (`git status --short` — no output).
|
||||
- `git remote -v`: `origin` → `gitea:ced4568/rootnode-academy.git` (fetch + push) — canonical,
|
||||
no GitHub `kubekosh` remote configured in this clone at all.
|
||||
- `git branch -vv`: `* main a4c0058 [origin/main]` — tracking correctly, no divergence.
|
||||
- No stashes, no dangling work, no uncommitted changes.
|
||||
|
||||
## 1.2 Remote audit
|
||||
|
||||
`git ls-remote --heads --tags origin` shows exactly one branch (`main`, at `a4c0058`) and four
|
||||
tags (`v0.4.1-preprod`, `v0.5.0-student-auth`, `v0.5.3-enrollment-comms`, `v0.6.0-root-v1`) — no
|
||||
other branches, no unexpected experimental branches, no stale branches to reconcile.
|
||||
`git log --all --oneline` and `git log --oneline` both return exactly **184** commits — every
|
||||
commit reachable in this repository is reachable from `main`. **There is no orphaned or
|
||||
unmerged work sitting in Gitea outside `main`.**
|
||||
|
||||
## 1.3 Second-PC reconciliation
|
||||
|
||||
This execution environment has no access to Chase's other workstation. Gitea's own state (§1.2)
|
||||
proves there is no SECOND branch or unpushed-but-fetchable ref waiting anywhere on the remote —
|
||||
if work exists only on the other PC, it is **local and unpushed there**, not merely unmerged
|
||||
here. This is not automatically resolvable from this side.
|
||||
|
||||
**Exact commands for Chase to run on PC #2** to prove no local-only RootNode work is stranded:
|
||||
|
||||
```bash
|
||||
cd ~/Dev/Gitea/ced4568/rootnode-academy # or wherever the clone lives on that machine
|
||||
git status # must show "nothing to commit, working tree clean"
|
||||
git branch -vv # every branch should show [origin/main] with no
|
||||
# "ahead" count, or should not exist at all
|
||||
git fetch origin
|
||||
git log origin/main..HEAD --oneline # MUST be empty — any output here is unpushed work
|
||||
git stash list # should be empty, or each entry reviewed manually
|
||||
```
|
||||
|
||||
If `git log origin/main..HEAD --oneline` on PC #2 produces ANY output, that is real, unpushed
|
||||
work — it must be reviewed and pushed (or explicitly discarded by Chase's own decision) before
|
||||
being considered part of canonical state. **This audit cannot rule that out from here — it can
|
||||
only confirm Gitea itself has no evidence of it.**
|
||||
|
||||
## 1.4 Expected milestone audit
|
||||
|
||||
Every phase SHA Phase 10's own authorization listed (Phase 2A through Phase 9.1 final) was
|
||||
verified present in canonical history, in full agreement with the commit message labels:
|
||||
|
||||
| Phase | SHA (verified) | Commit subject |
|
||||
|---|---|---|
|
||||
| 2A | `42dfb1c` | docs(phase2a): document gateway and student launch architecture |
|
||||
| 2B | `08ff531` | docs(phase2b): document admin and platform-data architecture |
|
||||
| 3A | `4069292` | docs(phase3a): document production identity architecture |
|
||||
| 3B | `45a147a` | docs(phase3b): document the learning and assessment engine |
|
||||
| 4 | `bf1e1b5` | docs(phase4): production infrastructure documentation + runbooks |
|
||||
| 4.1 | `5bc0743` | docs(phase4.1): pre-production hardening documentation |
|
||||
| 5A | `896c230` | docs(phase5a): student identity and remote classroom documentation |
|
||||
| 5B | `1c08a30` | docs(phase5b): self-paced enrollment documentation + website integration contract |
|
||||
| 5C | `777efd1` | docs(phase5c): enrollment communications documentation + Google Workspace runbook |
|
||||
| 6A | `fd38d06` | docs(phase6a): document RootNode course foundation |
|
||||
| 6B | `2db1368` | docs(phase6b): document curriculum expansion |
|
||||
| 6C | `bd0a10f` | docs(phase6c): pilot readiness standard, pilot curriculum manifest, ROOT 11 scope |
|
||||
| 6D | `b6516a7` | docs(phase6d): document the ROOT Foundations milestone |
|
||||
| 6E | `87ad125` | docs(phase6e): document the ROOT Foundations Completion milestone |
|
||||
| 6F | `d066410` | docs(phase6f): document ROOT_ V1 pilot readiness (tag `v0.6.0-root-v1`) |
|
||||
| 7 | `df5e39f` | docs(phase7): document the business/product architecture foundation |
|
||||
| 8 | `788fb19` | docs(phase8): document enrollment operations |
|
||||
| 9 | `80a5f6e` | docs(phase9): document commerce integration and Winter Break launch readiness |
|
||||
| 9.1 (prep) | `77fd186` | docs(stripe): document Phase 9.1 blocked status and live-mode readiness checklist |
|
||||
| 9.1 (final) | `a4c0058` | docs(stripe): document real Stripe TEST-mode validation results |
|
||||
|
||||
**Original Phase 9.5 commit — NOT FOUND, reconfirmed in Phase 10.1.** No commit, branch, or tag
|
||||
matching "Phase 9.5" or a founder-pricing document existed in this repository's reachable history
|
||||
(`git log --all` contains no such commit, searched again in Phase 10.1 with a broader content
|
||||
search for "FOUNDER APPROVED"/"LOCKED FOR V1"/"Founding Cohort"/`39900` — every hit traces to
|
||||
the Phase 9.1/10 work, never a separate Phase 9.5 commit). The complete founder-approved matrix
|
||||
was subsequently recovered from Chase's synced RootNode Academy conversation and canonicalized
|
||||
as `docs/ROOTNODE_PRICING_AND_OFFERS_V1.md`. The earlier belief that a unique document existed
|
||||
only on PC #2 was not verified and is no longer relied upon. The original desktop still requires
|
||||
inspection only to determine whether it holds an earlier unpushed copy or commit. The recovered
|
||||
document is now the authoritative V1 pricing record unless that inspection reveals a material
|
||||
difference requiring Chase's review.
|
||||
|
||||
**Website pricing is stale, confirmed with Chase.** A read-only fetch of `rootnodeacademy.com`
|
||||
during Phase 10.1 found founding-price RANGES (INIT_ $200–250, COMPILE_ $300–360, ROOT_
|
||||
$550–650, CAMPS_ $250–350 — no specific "Winter Break Camp" named, no ROOT_ tier breakdown) that
|
||||
do not match the exact fixed V1 prices Chase gave directly this phase. Chase confirmed the exact
|
||||
fixed prices are the correct, current figures — **the live website has not yet been updated to
|
||||
the real V1 pricing matrix.** This is the separate, not-RootNode-owned website repo; updating it
|
||||
is outside this repo's scope, noted here only as a real discrepancy for Chase's own awareness.
|
||||
|
||||
## 1.5 File/artifact audit
|
||||
|
||||
Searched all tracked files for secret-shaped filenames (`.env*`, `.local`, `credential`,
|
||||
`secret`, `.key`, `.pem`, `.p12`, service-account JSON). Every match is a legitimate
|
||||
template/placeholder or educational-content reference, never a real runtime secret:
|
||||
|
||||
- Course content (`content/root/root-07-.../m02-configuration-and-secrets/`) — teaching material
|
||||
about Kubernetes Secrets, no real secret data.
|
||||
- `infra/ansible/roles/*/templates/*.env.j2` — Ansible Jinja2 templates referencing
|
||||
`{{ vault_* }}` variables (values live in an Ansible Vault-encrypted file, never plaintext in
|
||||
git).
|
||||
- `infra/environments/{production,staging}/*.env.template` — checked-in templates with explicit
|
||||
`__REPLACE_WITH_...__`/`__REPLACE_openssl_rand_hex_32__` placeholders, never real values.
|
||||
- `platform/provisioner/.env.example`, migration `017_student_credentials.sql` (schema, not
|
||||
data), `scenarios/data/*secret*.json` (fake Kubernetes lab-scenario fixtures).
|
||||
|
||||
`.gitignore` correctly excludes `.env` (blanket pattern) — confirmed via `git status
|
||||
--porcelain --ignored`: `platform/.env` and `platform/provisioner/.env` (both containing real
|
||||
local secrets) are ignored, never tracked.
|
||||
|
||||
## 1.6 Secret history audit
|
||||
|
||||
Searched the **entire** git history (`git log --all -p`, pickaxe search `-S`) for:
|
||||
`sk_test_`, `sk_live_`, `whsec_`, `BEGIN PRIVATE KEY`, `BEGIN RSA PRIVATE KEY`, `"private_key"`,
|
||||
and a broad regex for any `sk_test_`/`sk_live_`/`whsec_`-prefixed string of realistic secret
|
||||
length (20+ trailing characters).
|
||||
|
||||
**Result: clean. No real secret value has ever been committed.** Every `sk_test_`/`sk_live_`/
|
||||
`whsec_` hit is a code guard-clause literal (e.g. `.startsWith('sk_test_')`) or documentation
|
||||
prose referencing the prefix pattern, from this session's own Phase 9.1 commits — never a full
|
||||
key. The one `"private_key"` hit (`b38c46a`, Phase 5C) is a code comment showing an EXAMPLE
|
||||
env-var invocation with a `"..."` placeholder, not a real value. No `BEGIN PRIVATE KEY`/`BEGIN
|
||||
RSA PRIVATE KEY` header appears anywhere in history. **No credential rotation is required.**
|
||||
|
||||
## 1.7 Current runtime secret state (this workstation)
|
||||
|
||||
`platform/.env` (gitignored, never committed) holds real local-dev values for
|
||||
`PROVISIONER_SHARED_SECRET`, `INTEGRATION_SHARED_SECRET`, `GOOGLE_SERVICE_ACCOUNT_JSON`,
|
||||
`EMAIL_FROM_ADDRESS`, `GOOGLE_DELEGATED_USER` — all pre-existing from earlier phases. Its
|
||||
`PAYMENT_PROVIDER`/`STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` lines are present but **blank**,
|
||||
per this repo's own "set only for a deliberate session, unset again after" convention (matching
|
||||
`EMAIL_PROVIDER`'s established pattern) — confirmed after Phase 9.1's real-Stripe validation
|
||||
session ended. See `docs/SECRETS_AND_INFISICAL.md` for the full inventory and centralization
|
||||
status.
|
||||
|
||||
## Discrepancies found
|
||||
|
||||
1. **The original "Phase 9.5" has no canonical commit** — see §1.4. The complete approved matrix
|
||||
was recovered from the synced RootNode Academy conversation and canonicalized in
|
||||
`docs/ROOTNODE_PRICING_AND_OFFERS_V1.md`. PC #2 inspection remains useful only to detect an
|
||||
earlier local copy or commit and compare it for material differences.
|
||||
2. **Chase's Phase 10.1 authorization states RootNode Academy "ALREADY EXISTS" as an Infisical
|
||||
project** on `infisical.cedshomelab.com`. Re-investigated in Phase 10.1 with a proper
|
||||
org-scoped API enumeration (`GET /api/v2/organizations/{orgId}/workspaces`, not just the
|
||||
CLI's interactive picker, which turned out to be incomplete — it showed 6 projects in Phase
|
||||
10, the API shows 8 for the same identity). **Still no project named "RootNode Academy" (or
|
||||
similar) is visible to this machine identity** even with the more complete method — the
|
||||
full, real list is: Core Infrastructure, Kubernetes, Media Platform, Monitoring, Network
|
||||
Service, Privileged Access Manager, Storage, Synthos Systems. **Root cause is now narrowed,
|
||||
not fully resolved**: this machine identity's own project list only shows projects it has
|
||||
been explicitly granted access to (standard Infisical per-project ACL, separate from org
|
||||
membership) — so the most likely explanation is that "RootNode Academy" exists but this
|
||||
identity was never added to it, not that it doesn't exist. This cannot be distinguished from
|
||||
here without either org-owner-level API access or the web dashboard, neither available to
|
||||
this session. **No duplicate project was created.** See `docs/SECRETS_AND_INFISICAL.md` for
|
||||
the exact one-line fix (grant the `claude-code-migration` identity access to the existing
|
||||
project via its Project Settings → Access page).
|
||||
3. **No dedicated production server/Proxmox VM has been provisioned** — `infra/terraform/` has
|
||||
no `terraform.tfvars` (only `.example`) and no state file; `infra/ansible/group_vars/
|
||||
production.yml` still holds literal placeholders (`__PLATFORM_VM_IP__`,
|
||||
`__LAB_HOST_VM_IP__`, `__PROXMOX_HOST_MGMT_CIDR__`). See `docs/
|
||||
PHASE10_PRODUCTION_DEPLOYMENT.md`.
|
||||
|
||||
## Remediation performed
|
||||
|
||||
The complete founder-approved Phase 9.5 matrix was recovered from synced conversation history
|
||||
and written to `docs/ROOTNODE_PRICING_AND_OFFERS_V1.md`. This audit was corrected to distinguish
|
||||
the missing original Phase 9.5 commit from the now-recovered pricing decision. No existing Git
|
||||
history was rewritten.
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Canonical Gitea history through Phase 10.1 is internally consistent, fully verified against
|
||||
every claimed milestone, and free of any committed secret.** The Phase 9.5 pricing matrix has
|
||||
now been recovered locally from synced conversation history and awaits review/commit. The only
|
||||
remaining multi-PC item is running the PC #2 commands in §1.3 to positively rule out stranded
|
||||
local work or identify an earlier Phase 9.5 copy for comparison.
|
||||
@@ -0,0 +1,167 @@
|
||||
# Phase 10 — Launch Readiness
|
||||
|
||||
Gates 2, 10-14, 19-20, and the final launch matrix. See `docs/PHASE10_CANONICAL_STATE_AUDIT.md`
|
||||
(Gate 1), `docs/SECRETS_AND_INFISICAL.md` (Gate 3), `docs/PHASE10_PRODUCTION_DEPLOYMENT.md`
|
||||
(Gates 4-9/15-16), and `docs/PHASE10_SECURITY_AUDIT.md` (Gate 18) for the rest.
|
||||
|
||||
## Gate 2 — Full application audit
|
||||
|
||||
Reviewed for architectural coherence across every phase; nothing was found inconsistent or in
|
||||
need of a fix. Identity/auth, learning, Lab platform, business (offerings/registrations/
|
||||
guardians/agreements), and commerce (pricing/payments/webhooks) all remain exactly as documented
|
||||
in their own phase docs — re-verified by actually rerunning every suite (Gate 19 below), not by
|
||||
re-reading code and assuming it still works.
|
||||
|
||||
## Gate 10 — Website integration
|
||||
|
||||
**Update (post-Phase 10.1)**: the website side is now actually built, not just documented.
|
||||
`ced4568/RootNode-academy-website` (a separate GitHub repository — Chase provided access and
|
||||
explicitly authorized this phase to build against it) now has a real registration/agreement/
|
||||
checkout/status flow implementing `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`'s full contract:
|
||||
public offering lookup, registration submission (adult and guardian+minor), agreement
|
||||
acceptance, checkout creation, and a status-polling success page that never trusts the redirect
|
||||
URL alone, exactly as that document specifies. Verified there: a clean production build, `tsc`,
|
||||
eslint, and 9/9 tests all pass. The site's own stale pricing was also corrected to match
|
||||
`docs/ROOTNODE_PRICING_AND_OFFERS_V1.md`.
|
||||
|
||||
It has nothing to talk to yet — `ROOTNODE_API_URL`/`ROOTNODE_INTEGRATION_KEY` are unset on the
|
||||
website side (no real Platform API exists to point them at), so every RootNode-facing route on
|
||||
the website returns a clean `503`, never a `500`, until Gate 4's hardware blocker resolves and a
|
||||
real Platform API origin exists. `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` and `docs/
|
||||
WEBSITE_ENROLLMENT_INTEGRATION.md` remain the accurate contract both sides now implement.
|
||||
|
||||
## Gate 11 — Winter Break offering
|
||||
|
||||
Pricing is locked and real: **$399.00 launch ("Founding Cohort") / $449.00 standard reference,
|
||||
39900/44900 cents, paid in full, no deposit, no payment plan** — given directly by Chase,
|
||||
validated against real Stripe TEST mode at the exact $399 amount in Phase 9.1. **Dates, venue,
|
||||
and capacity remain undecided** — not invented by this phase, exactly as every prior phase's own
|
||||
documentation already states. The offering therefore cannot be transitioned to
|
||||
`enrollment_open` for real, public registration yet — the platform mechanism is ready
|
||||
(`PATCH /api/admin/offerings/:id` accepts all four fields the moment they're decided).
|
||||
|
||||
## Gate 12 — Agreements / legal
|
||||
|
||||
`agreement_definitions`/`agreement_acceptances` architecture is real, tested, and versioned
|
||||
(Phase 7-9). **No attorney-approved legal text exists for any required Winter Break agreement**
|
||||
— `agreement_definitions.body_summary` remains a plain-language placeholder, exactly as every
|
||||
prior phase's docs have stated since Phase 7. This phase did not write legal language and did
|
||||
not treat any placeholder as attorney-approved, per Phase 10's own explicit instruction.
|
||||
|
||||
**LAUNCH BLOCKED — APPROVED AGREEMENT TEXT REQUIRED.** Likely required categories (not
|
||||
authored, just the taxonomy Phase 10 asked to identify): program participation terms, guardian
|
||||
consent (minor registrants), a participation/liability waiver, acceptable-use / lab/technology
|
||||
rules, a cancellation/refund acknowledgment tied to whatever policy Gate 14 resolves, and a
|
||||
privacy/data-handling notice if RootNode's counsel determines one is needed beyond what's already
|
||||
in `docs/STUDENT_GUARDIAN_DATA_MODEL.md`. A media release is optional — RootNode's own choice,
|
||||
not decided here.
|
||||
|
||||
## Gate 13 — Tax
|
||||
|
||||
**TAX DECISION REQUIRED.** No deliberate Mississippi (or any jurisdiction's) tax treatment has
|
||||
been configured anywhere in this codebase — `payment_transactions.amount` is charged exactly as
|
||||
the offering's own price, with no tax line item, no tax calculation, and no tax remittance logic
|
||||
anywhere (Phase 9's own explicit "no complex tax automation" scope boundary, unchanged). This is
|
||||
not this phase's decision to make, and the current absence of tax handling should not be read as
|
||||
an implicit "no tax owed" determination — that requires Chase (or a professional) to actually
|
||||
decide, then tell the platform what to do (charge tax as a line item, treat the $399 as
|
||||
tax-inclusive, or confirm no sales tax applies to this specific service in this jurisdiction).
|
||||
|
||||
## Gate 14 — Refund / cancellation policy
|
||||
|
||||
**LAUNCH BLOCKED — REFUND/CANCELLATION POLICY REQUIRED.** The platform's MECHANICAL behavior is
|
||||
real, tested, and proven against actual Stripe money-movement in Phase 9.1: a refund is recorded
|
||||
as its own tracked state and **never automatically** cancels the registration or revokes academic
|
||||
enrollment — confirmed directly against the database after a real Stripe TEST refund. What is
|
||||
still missing is the actual customer-facing POLICY text (how many days before the camp is a
|
||||
refund allowed, is it full or partial, etc.) — a founder decision this phase does not invent.
|
||||
|
||||
## Gate 19 — Complete regression
|
||||
|
||||
Fresh stack, this phase, after confirming canonical baseline (Gate 1):
|
||||
|
||||
| Suite | Result |
|
||||
|---|---|
|
||||
| `e2e-phase5b.js` | 32/32 |
|
||||
| `e2e-phase5c.js` | 29/29 |
|
||||
| `e2e-phase6f.js` | 17/17 |
|
||||
| `e2e-phase7.js` | 17/17 |
|
||||
| `e2e-phase8.js` | 16/16 |
|
||||
| `e2e-phase9.js` | 20/20 |
|
||||
| Course-content validator | PASS, 0 errors |
|
||||
| Migration idempotency | Confirmed both ways — a full `down -v`/`up` cycle applies all 37 migrations cleanly from empty, and a same-database container restart re-applies with zero errors |
|
||||
| Frontend production build | Clean (only the pre-existing chunk-size advisory) |
|
||||
| `tofu validate` (Terraform/OpenTofu) | Clean |
|
||||
| `ansible-playbook --syntax-check` | Clean |
|
||||
|
||||
**131 automated checks, 0 failures.** No code changed this phase (Phase 10 is audit/launch-prep,
|
||||
not feature work), so this is confirmation, not new coverage.
|
||||
|
||||
`platform/test/stripe-integration-test.js` (the Phase 9.1 opt-in real-Stripe check) was **not**
|
||||
rerun this phase — it requires real Stripe TEST credentials, which currently have no durable home
|
||||
(Gate 3's Infisical project doesn't exist yet) and were correctly unset from the local `.env`
|
||||
after Phase 9.1 ended. Phase 9.1's own evidence (`docs/PHASE9_1_STRIPE_TEST_VALIDATION.md`)
|
||||
stands as the real-Stripe proof; nothing in this phase's own code changes touches that path.
|
||||
|
||||
## Gate 20 — Pre-launch dress rehearsal
|
||||
|
||||
`infra/scripts/drills/test-phase9-winter-camp-commerce.js` rerun fresh this phase against
|
||||
current `main`: **13/13** — website registration → guardian → agreement → checkout → webhook →
|
||||
enrollment → activation → classroom → a real disposable Lab task → instructor visibility, plus
|
||||
failed-payment/duplicate-webhook/abandoned-checkout/last-seat-race, all using the deterministic
|
||||
test provider (no real money, as instructed). This is the closest possible complete rehearsal
|
||||
without real production infrastructure or real dates/venue/capacity — it cannot be run against
|
||||
the ACTUAL Winter Break offering because that offering cannot legitimately open registration yet
|
||||
(Gate 11). No fake evidence was created for the steps genuinely blocked by unresolved business
|
||||
decisions.
|
||||
|
||||
## Final launch matrix
|
||||
|
||||
Phase 10.1 adds a **Category** column distinguishing four kinds of non-complete gate, since
|
||||
they need entirely different people to resolve them: **TECHNICAL READY** (code/infra is done,
|
||||
nothing left to build), **OWNER DECISION REQUIRED** (Chase has to decide something, no code
|
||||
involved), **EXTERNAL BLOCKER** (a third party — Stripe, an attorney, a landlord — has to act or
|
||||
verify something), **HARDWARE BLOCKER** (specifically the missing production server, since it
|
||||
gates so many other rows).
|
||||
|
||||
| Gate | Status | Category | Evidence | Owner action | Blocks registration? |
|
||||
|---|---|---|---|---|---|
|
||||
| 1. Canonical repo reconciled | ✅ COMPLETE | — | `docs/PHASE10_CANONICAL_STATE_AUDIT.md` | None | No |
|
||||
| 2. Second-PC state reconciled | ⚠️ Pending verification | OWNER DECISION REQUIRED | Gitea shows no stranded work; PC #2 itself unverified from here; the real Phase 9.5 pricing doc is confirmed to live there, unpushed | Run §1.3's commands on PC #2, then push the real Phase 9.5 document | No (informational) |
|
||||
| 3. Secrets centralized (Infisical) | 🔧 One access grant away | OWNER DECISION REQUIRED | `docs/SECRETS_AND_INFISICAL.md` — project exists per Chase, this identity lacks access to it | Grant `claude-code-migration` access to the existing RootNode Academy project | No (dev secrets already safe locally) |
|
||||
| 4. Production server | ⛔ BLOCKED | HARDWARE BLOCKER | `docs/PHASE10_PRODUCTION_DEPLOYMENT.md` | Purchase/provision hardware | **Yes** |
|
||||
| 5. Proxmox / VMs | ⛔ BLOCKED | HARDWARE BLOCKER | Same — no host to provision on | Same | **Yes** |
|
||||
| 6. Firewall/isolation | ✅ COMPLETE (code) | TECHNICAL READY | `docs/PHASE10_SECURITY_AUDIT.md` | None until real host exists | No (code ready) |
|
||||
| 7. Production database | ⛔ BLOCKED | HARDWARE BLOCKER | Same hardware blocker | Same | **Yes** |
|
||||
| 8. DNS | ✅ PARTIAL | HARDWARE BLOCKER (API portion) | `rootnodeacademy.com` live; no Platform API subdomain yet | Decide/create the API subdomain once a server exists | **Yes**, for the API portion |
|
||||
| 9. HTTPS | ✅ PARTIAL | HARDWARE BLOCKER (API portion) | Website real HTTPS; Platform API HTTPS pending a real host | Same as above | **Yes**, for the API portion |
|
||||
| 10. Monitoring | ✅ COMPLETE (code) | TECHNICAL READY | `/healthz`/`/metrics` live in every test run | None until deployed | No |
|
||||
| 11. Backups | ✅ COMPLETE (code), ⛔ unexecuted | HARDWARE BLOCKER (execution) | Ansible `backup` role exists, never run | Run once a real host exists | **Yes** (restore-test) |
|
||||
| 12. Restore test | ⛔ BLOCKED | HARDWARE BLOCKER | Never executed — no target | Same | **Yes** |
|
||||
| 13. Stripe TEST validation | ✅ COMPLETE | — | `docs/PHASE9_1_STRIPE_TEST_VALIDATION.md` | None | No |
|
||||
| 14. Stripe LIVE configuration | ⛔ BLOCKED | EXTERNAL BLOCKER | Not configured, not attempted | Chase completes Stripe business/identity verification, provides LIVE credentials | **Yes** |
|
||||
| 15. Live webhook configuration | ⛔ BLOCKED | EXTERNAL + HARDWARE BLOCKER | Depends on #14 and a real HTTPS endpoint (#9) | Same | **Yes** |
|
||||
| 16. Google Workspace | ✅ COMPLETE (validated in Phase 5C) | TECHNICAL READY | `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` | Deploy alongside the real server | No (already proven) |
|
||||
| 17. Website integration | ✅ COMPLETE, both sides | — | Contract tested end to end; website-side registration/checkout flow now built and verified (see Gate 10 above) | None | No |
|
||||
| 18. Winter Break price | ✅ COMPLETE | — | $399/$449, real, Stripe-validated, confirmed current with Chase (the live website's own pricing is stale — see `docs/PHASE10_CANONICAL_STATE_AUDIT.md`) | None (website update is separate, not-RootNode-owned repo work) | No |
|
||||
| 19. Winter Break dates | ⚠️ DECISION REQUIRED | OWNER DECISION REQUIRED | Not decided | Chase decides | **Yes** |
|
||||
| 20. Venue | ⚠️ DECISION REQUIRED | OWNER DECISION REQUIRED | Not decided | Chase decides | **Yes** |
|
||||
| 21. Capacity | ⚠️ DECISION REQUIRED | OWNER DECISION REQUIRED | Not decided | Chase decides | **Yes** |
|
||||
| 22. Agreements (legal text) | ⛔ BLOCKED | EXTERNAL BLOCKER | Placeholder only | Attorney-approved text required | **Yes** |
|
||||
| 23. Refund/cancellation policy | ⚠️ DECISION REQUIRED | OWNER DECISION REQUIRED | Mechanism proven, policy undecided | Chase decides | **Yes** |
|
||||
| 24. Tax decision | ⚠️ DECISION REQUIRED | OWNER DECISION REQUIRED / EXTERNAL | Not configured | Chase/professional decides | **Yes** |
|
||||
| 25. Production offering | ⛔ BLOCKED | OWNER DECISION REQUIRED (compound) | Cannot legitimately open — depends on #19-24 | Depends on all of the above | **Yes** |
|
||||
| 26. Instructor assignment | 🔧 MANUAL OWNER ACTION | OWNER DECISION REQUIRED | Mechanism ready (`instructor_offerings`) | Chase assigns once the real offering exists | **Yes** |
|
||||
| 27. Roster process | ✅ COMPLETE (mechanism) | TECHNICAL READY | Rehearsed in Gate 20 | None | No |
|
||||
| 28. Student activation | ✅ COMPLETE | TECHNICAL READY | Proven real, twice (Phase 9.1 + Gate 20) | None | No |
|
||||
| 29. Lab capacity | ✅ COMPLETE (code) | HARDWARE BLOCKER (real sizing) | Capacity/race logic proven; real server capacity planning needs #4 | Plan once real hardware specs are known | No (mechanism), **yes** (real capacity) |
|
||||
| 30. Complete dress rehearsal | ✅ COMPLETE (deterministic) | TECHNICAL READY | Gate 20, 13/13 | Repeat against the real offering once it can legitimately open | No |
|
||||
|
||||
## Bottom line
|
||||
|
||||
## PHASE 10 PARTIAL / WINTER BREAK LAUNCH BLOCKED
|
||||
|
||||
Every gate that is genuinely software/audit work is complete and verified. Every gate that is a
|
||||
real business, legal, tax, or hardware decision remains exactly that — a decision, not
|
||||
something this phase invented on Chase's behalf. See `docs/WINTER_BREAK_LAUNCH_CHECKLIST.md` for
|
||||
the single consolidated, categorized checklist Chase can work down.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Phase 10 — Production Deployment
|
||||
|
||||
Gates 4-9, 15-16. Covers production infrastructure, network security, DNS/HTTPS, database
|
||||
productionization, Stripe LIVE configuration, Google Workspace production email, observability,
|
||||
and backups/restore.
|
||||
|
||||
## Hardware status — PRODUCTION DEPLOYMENT BLOCKED — SERVER REQUIRED
|
||||
|
||||
Verified, not assumed: `infra/terraform/` has no real `terraform.tfvars` (only
|
||||
`terraform.tfvars.example`) and no `.tfstate` file anywhere in the repo or working tree —
|
||||
`tofu apply` has never been run against real infrastructure. `infra/ansible/group_vars/
|
||||
production.yml` still holds literal, unfilled placeholders: `platform_ip:
|
||||
__PLATFORM_VM_IP__`, `lab_host_ip: __LAB_HOST_VM_IP__`, `proxmox_mgmt_cidr:
|
||||
__PROXMOX_HOST_MGMT_CIDR__`. No SSH config, known-hosts entry, or inventory file on this
|
||||
workstation references a real `rn-platform-01`/`rn-lab-host-01`/Proxmox host.
|
||||
|
||||
**The dedicated RootNode Academy production server has not been purchased or provisioned.**
|
||||
Per Phase 10's own explicit instruction, this phase does not fake a production deployment
|
||||
against placeholder infrastructure. Everything below is preparation, validation, and rehearsal —
|
||||
genuinely useful the moment real hardware exists, but none of it is a live deployment.
|
||||
|
||||
## What WAS done this phase (all safe, all pre-hardware)
|
||||
|
||||
- **Gate 4 — IaC validated**: `tofu validate` (OpenTofu, the Terraform-compatible tool actually
|
||||
installed) passes cleanly against `infra/terraform/`'s existing Proxmox provider config — no
|
||||
redesign, the existing config is syntactically sound and ready for real `terraform.tfvars`.
|
||||
`ansible-playbook site.yml --syntax-check -i inventory` passes cleanly against `infra/
|
||||
ansible/`'s existing `site.yml` and its `firewall`/`docker-tls`/`platform`/`lab-host`/
|
||||
`monitoring-agent`/`backup`/`common` roles (all present from Phase 4/4.1, unmodified).
|
||||
- **Gate 5 — network security architecture reviewed, unchanged**: RFC1918 egress
|
||||
denial/allowlisting present in `platform/provisioner/src/egress.js`/`lifecycle.js` (Provisioner
|
||||
owns lab-container network policy); mTLS wiring present in `infra/ansible/roles/docker-tls/`
|
||||
and referenced by `gateway.env.j2`; the `firewall` Ansible role exists for both host tiers. All
|
||||
of this is existing Phase 4/4.1 work, re-confirmed present and un-redesigned — none of it can
|
||||
be verified AGAINST a real host without one existing.
|
||||
- **Gate 6 — DNS/HTTPS audited (read-only)**: `rootnodeacademy.com` **is already live** —
|
||||
resolves via Cloudflare (`172.67.141.39`, `104.21.38.247`), serves a real HTTPS site (`HTTP/2
|
||||
200`, Next.js application headers), and its mail is already routed to Google
|
||||
(`smtp.google.com`), consistent with the existing `[email protected]` Google
|
||||
Workspace setup. This is the separate, not-RootNode-owned marketing website (`docs/
|
||||
WEBSITE_REGISTRATION_AND_CHECKOUT.md`'s explicit boundary) — nothing here was touched, and no
|
||||
DNS record was added or changed. **No subdomain or DNS record exists yet for the Platform API
|
||||
itself** (e.g. `api.rootnodeacademy.com` or similar) — that's the piece blocked on real
|
||||
server hardware.
|
||||
- **Gate 7 — database productionization reviewed**: migrations (37, through Phase 9) verified
|
||||
idempotent again this phase (a fresh `down -v`/`up` cycle and a same-database container
|
||||
restart both apply/re-apply cleanly with zero errors — see `docs/
|
||||
PHASE10_LAUNCH_READINESS.md`'s regression section). The Ansible `platform` role's
|
||||
`postgres.env.j2`/`.template` already model least-privilege connection strings
|
||||
(`rootnode_platform` app user, not a superuser). No student records of any kind exist to
|
||||
accidentally copy into a production database — this dev/test Postgres only ever holds
|
||||
disposable test fixtures, all cleaned up after each rehearsal.
|
||||
- **Gate 8 — Stripe LIVE**: explicitly **not configured, not attempted**. See "Stripe LIVE
|
||||
status" below.
|
||||
- **Gate 9 — Google Workspace**: `GOOGLE_SERVICE_ACCOUNT_JSON`/`GOOGLE_DELEGATED_USER` already
|
||||
exist as real, previously-validated local values (Phase 5C's own `docs/
|
||||
RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` documents that validation) — `[email protected]`,
|
||||
`gmail.send` scope only, domain-wide delegation. Not re-sent this phase (no new test email was
|
||||
needed to confirm what Phase 5C already proved, and Phase 10 explicitly says not to make GWS a
|
||||
blocker for Stripe/launch work). Production DEPLOYMENT of this config is blocked on the same
|
||||
missing server as everything else.
|
||||
- **Gates 15/16 — observability and backups reviewed, unchanged**: `/healthz` (Platform API,
|
||||
Gateway, Provisioner) and `/metrics` (Prometheus text exposition, low-cardinality labels only —
|
||||
reconfirmed by this phase's own audit in `docs/PHASE10_SECURITY_AUDIT.md`) already exist and
|
||||
were exercised repeatedly by every regression suite rerun this phase. The `monitoring-agent`
|
||||
and `backup` Ansible roles exist (`infra/ansible/roles/monitoring-agent/`, `.../backup/`,
|
||||
including a templated `run-backup.sh.j2`) — built in Phase 4, never yet run against a real
|
||||
host. **A restore has never been tested against real infrastructure, because none exists** —
|
||||
this phase cannot manufacture that proof; it is not sufficient for launch per Phase 10's own
|
||||
"a backup that has never been restore-tested is not sufficient" rule, and is listed as a real
|
||||
blocker in the launch checklist, not glossed over.
|
||||
|
||||
## Stripe LIVE status
|
||||
|
||||
**Not configured.** No live Stripe credential was provided to this session, and this phase did
|
||||
not request one — activating Stripe live mode requires Chase's own Stripe account
|
||||
business/identity verification (a real KYC-style process only Chase can complete), which is an
|
||||
explicit Phase 10 STOP condition ("identity verification requires Chase"). Phase 9.1's real
|
||||
TEST-mode validation (`docs/PHASE9_1_STRIPE_TEST_VALIDATION.md`) remains the full proof of the
|
||||
application-layer correctness; going live only needs Chase's real credentials plugged into the
|
||||
same, already-proven code path — see `docs/STRIPE_INTEGRATION_RUNBOOK.md`'s "Going live"
|
||||
section, unchanged by this phase.
|
||||
|
||||
## Bottom line
|
||||
|
||||
**PRODUCTION DEPLOYMENT BLOCKED — SERVER REQUIRED.** Every gate that depends on real hardware
|
||||
(4, 5's live verification, 6's platform subdomain, 7's live database, 8, 9's live deployment, 15,
|
||||
16) is prepared, validated, and ready to execute the moment `rn-platform-01`/`rn-lab-host-01`
|
||||
exist on real Proxmox hardware and `terraform.tfvars`/`group_vars/production.yml` are filled
|
||||
with real values — none of it requires new code or a redesign.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Phase 10 — Security Audit
|
||||
|
||||
Gate 18. A production-oriented review of RootNode Academy's security posture, drawing on every
|
||||
regression suite rerun this phase (`docs/PHASE10_LAUNCH_READINESS.md`) plus direct code review.
|
||||
No genuine launch-critical defect was found this phase — the one real defect Phase 10's
|
||||
predecessor phases exposed (Phase 9.1's webhook-correlation bug) was already fixed and
|
||||
regression-tested before this phase began.
|
||||
|
||||
| Area | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Student authentication | ✅ Verified | `e2e-phase5b.js`/`e2e-phase5c.js` rerun clean this phase — activation, single-use tokens, expiration, password reset with anti-enumeration, session revocation on reset. |
|
||||
| Instructor authentication | ✅ Verified | Same suites — instructor login, RBAC roles (owner/admin/instructor). |
|
||||
| Sessions | ✅ Verified | Persistent, server-controlled sessions (Phase 3A); a real Platform API restart preserves sessions/enrollment state (rerun clean this phase). |
|
||||
| Password reset / activation | ✅ Verified | Single-use, server-side expiration, no plaintext token ever returned by any admin API (`toAdminView` never includes it). |
|
||||
| RBAC | ✅ Verified | Admin/owner-only routes (guardian detail, payment data) vs. any-instructor routes (offering/location lists) — reconfirmed across Phase 7/8/9 suites this phase. |
|
||||
| Cohort scoping | ✅ Verified | An instructor sharing a cohort can manage; an unrelated instructor cannot (Phase 5B/7 suites). |
|
||||
| CSRF | ✅ Present, unchanged | `platform/api/src/security.js` + per-route session/cookie checks (student/instructor auth routes) — not modified this phase, no regression. |
|
||||
| Rate limiting / lockout | ✅ Present, unchanged | Login-attempt tracking in `security.js` (matches the existing sweeper pattern also used for session expiry). |
|
||||
| Audit logging | ✅ Verified | Every admin/integration action recorded; audit-hygiene test (no secrets/PII in audit metadata) rerun clean in `e2e-phase9.js`. |
|
||||
| Server-authoritative pricing | ✅ Verified, real Stripe | Phase 9.1 proved a tampered caller amount is ignored against **Stripe's own recorded object**, not just RootNode's database. |
|
||||
| Agreement enforcement | ✅ Verified | Checkout blocked (`409`) until acceptance, in both the deterministic suite and real Stripe TEST mode. |
|
||||
| Capacity enforcement | ✅ Verified | Race-safe seat reservation (`reserved_seats < capacity` in one UPDATE) — last-seat race test rerun clean this phase. |
|
||||
| No card-data storage | ✅ Verified | `payment_transactions` schema has no card fields; explicit admin-view test asserts none appear; Stripe's hosted Checkout is the only thing that ever sees a real card. |
|
||||
| Webhook signature verification | ✅ Verified, real Stripe | Invalid/forged signatures rejected (both deterministic and real-Stripe-signed tests); idempotent via a real DB primary key on the provider's event id, reconfirmed with a genuinely re-signed real Stripe event in Phase 9.1. |
|
||||
| Logging/audit redaction | ✅ Verified | This session's own Stripe secret/webhook-secret values were grepped across every log/scratch file produced — none found; `e2e-phase9.js`'s hygiene test asserts no `sk_`/`whsec_`/card-number pattern in audit metadata or metrics text. |
|
||||
| Secrets never committed | ✅ Verified | Full-history pickaxe search this phase — clean (`docs/PHASE10_CANONICAL_STATE_AUDIT.md` §1.6). |
|
||||
| mTLS (Provisioner ↔ Docker, Gateway ↔ Lab Host Router) | ✅ Present, unchanged, not re-verified live | Ansible `docker-tls` role and TLS env vars exist; cannot be exercised against a real mTLS handshake without real hardware (Gate 4/5's hardware blocker). |
|
||||
| Privileged lab boundary / RFC1918 egress denial | ✅ Present, unchanged | `provisioner/src/egress.js`; this phase reconfirmed the module exists and is wired, did not re-run Phase 1/2's own dedicated isolation drills (out of this phase's changed-code scope — nothing touched networking code). |
|
||||
| Production cookie settings (`COOKIE_SECURE`) | ✅ Present, verified pattern | `e2e-phase5c.js`'s own test explicitly proves `COOKIE_SECURE=1` production gating via a real container recreate — reconfirmed present, not re-executed as a fresh drill this phase since nothing in that code path changed. |
|
||||
| HTTPS end-to-end | ⏸ Cannot verify — no production host | `COOKIE_SECURE=1`/`TRUST_PROXY=1` require a real HTTPS-terminating reverse proxy in front of a real Gateway, which doesn't exist yet (Gate 4-6's hardware blocker). The marketing site at `rootnodeacademy.com` IS real HTTPS (Cloudflare) — the Platform API itself has no production endpoint yet. |
|
||||
|
||||
## Findings requiring action
|
||||
|
||||
**None launch-critical within existing architecture.** Everything marked ⏸ above is blocked on
|
||||
the same missing production server documented in `docs/PHASE10_PRODUCTION_DEPLOYMENT.md` — not
|
||||
a code defect, and building around it (e.g. faking an HTTPS check) would violate this phase's
|
||||
own "do not fake production readiness" instruction.
|
||||
|
||||
## Scope discipline
|
||||
|
||||
This audit reviewed and reconfirmed existing security properties; it did not expand into new
|
||||
security tooling, new scanning infrastructure, or unrelated hardening work not already part of
|
||||
Phase 1-9's architecture, per Phase 10's own "do not expand into unrelated feature work"
|
||||
instruction.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Phase 7 — Business & Product Architecture Foundation
|
||||
|
||||
Phase 7's mission: formalize RootNode Academy as a business/product platform capable of a future
|
||||
catalog across INIT_/COMPILE_/ROOT_/BOOTCAMP_, without rebuilding any proven system and without
|
||||
authoring new curriculum.
|
||||
|
||||
## Baseline
|
||||
|
||||
Confirmed at start: branch `main`, clean tree, HEAD = `origin/main` = Phase 6F's
|
||||
`d0664104515e4bd389c8e7bc88293327e7eda100`, tag `v0.6.0-root-v1` present. Reviewed existing
|
||||
`programs`/`enrollments`/`cohorts`/`classroom_sessions`/`students` schema before designing
|
||||
anything new — found `programs.family`/`delivery_mode` and `cohorts.seat_target` already
|
||||
existed, so Phase 7 widened rather than duplicated them where it could.
|
||||
|
||||
## What was built
|
||||
|
||||
- **Organizations, locations** (`033_phase7_organizations_locations_guardians.sql`) — a business/
|
||||
delivery relationship and a physical/virtual venue, deliberately not SaaS multi-tenancy.
|
||||
- **Guardians, student_guardians** (same migration) — the real foundation replacing the Phase
|
||||
5C `contact_email` workaround, left functioning unchanged for the live website integration.
|
||||
- **Offerings, curriculum packages, registrations, agreements**
|
||||
(`034_phase7_offerings_registrations_agreements.sql`) — the full purchasable-delivery model,
|
||||
pre-enrollment registration lifecycle, versioned agreement acceptance records, and
|
||||
reference-only curriculum subsets.
|
||||
- **`platform/api/src/offerings.js`** — the business logic: server-defined status transitions
|
||||
(offerings and registrations both), race-safe atomic seat reservation, and
|
||||
`convertRegistrationToEnrollment()` reusing `enrollment.js`'s existing student-matching and
|
||||
enrollment-creation logic rather than duplicating it.
|
||||
- **`platform/api/src/admin-offerings-routes.js`** — the full admin/owner API surface for all of
|
||||
the above, registered in `server.js` alongside the existing route modules.
|
||||
- **`programs.family` widened** to accept `bootcamp` (a pure CHECK-constraint widening; no
|
||||
existing row changes).
|
||||
|
||||
## What was NOT built this phase
|
||||
|
||||
**Admin UI** (Steps 16/17 — Offerings/Registrations screens in `/admin`). This was a deliberate
|
||||
scope call: given the phase's own emphasis ("BUSINESS DOMAIN MODEL, PRODUCT ARCHITECTURE,
|
||||
ENROLLMENT/OPERATIONS ARCHITECTURE"), the schema, business logic, RBAC, tests, and a full
|
||||
working rehearsal were prioritized as the load-bearing deliverable over a polished React
|
||||
surface. Every capability Steps 16-17 describe is fully implemented and tested at the API layer
|
||||
— an admin UI is now a bounded, well-specified follow-up (the routes and their exact shapes
|
||||
already exist), not a design question.
|
||||
|
||||
Also not built, per the explicit DO-NOT-BUILD list: payment processing, subscription billing,
|
||||
public checkout, INIT_/COMPILE_ curriculum, ROOT 08/11 expansion, a Parent Portal UI, school
|
||||
SIS integrations, certificates.
|
||||
|
||||
## A real bug found and fixed during this phase's own testing
|
||||
|
||||
The registration-creation route originally reserved an offering's seat *before* validating the
|
||||
registration's own fields. Under a full offering, this let a plain validation failure (e.g. a
|
||||
minor registration missing `guardianId`) get masked behind a false `409 OFFERING_FULL`, and even
|
||||
when capacity wasn't the issue, a request that would fail validation anyway had already consumed
|
||||
a real seat. Found via this phase's own smoke testing before any commit; fixed by validating
|
||||
(creating) the registration first, then reserving the seat, rolling back the registration row if
|
||||
reservation then genuinely fails. See `docs/OFFERING_AND_REGISTRATION_MODEL.md`.
|
||||
|
||||
## Testing
|
||||
|
||||
`platform/test/e2e-phase7.js` — 17/17 passing (fresh stack): course-family/age-guidance
|
||||
independence from authorization, offerings across two distinct locations, curriculum-package
|
||||
content-reference integrity (including rejecting a fabricated course id), atomic capacity
|
||||
enforcement with correct validate-before-reserve ordering, instructor assignment, guardian
|
||||
creation/linking, minor-vs-adult registration paths, registration lifecycle transitions
|
||||
(including a rejected invalid jump), agreement-version-snapshotted acceptance, registration→
|
||||
enrollment conversion with correct cohort/no-cohort behavior, curriculum-package version-bump
|
||||
stability, registration/guardian RBAC (admin/owner only) vs. offering/location visibility (any
|
||||
instructor, with `access_notes` stripped), explicit age-independence, and confirmation that an
|
||||
existing ROOT_ V1 self-paced enrollment and an existing classroom/cohort flow are both
|
||||
completely unaffected.
|
||||
|
||||
## Winter Break Camp rehearsal
|
||||
|
||||
`infra/scripts/drills/test-phase7-winter-camp.js` — 18/18 passing against a real, freshly-built
|
||||
stack, with **no payment code invoked anywhere**: admin creates a real organization/location, a
|
||||
curriculum package referencing the existing ROOT 01-02 courses (matching `docs/
|
||||
WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md`'s Option A exactly), a real cohort, and a DRAFT offering
|
||||
with dates/price/exact seat count left unset as authorized; a guardian registers a minor and an
|
||||
adult registers themselves; both move through submission, agreement acceptance, and a simulated
|
||||
admin-side commerce confirmation; both convert to real enrollments and real cohort membership
|
||||
with real one-time activation tokens; both activate real accounts; the instructor starts a real
|
||||
classroom session and Prepares Labs; the minor student joins, completes real ROOT 01 orientation
|
||||
and lesson activities, and passes a REAL Lab task (`linux-inspect-hardware`) inside a real
|
||||
disposable Lab container — with the pass then confirmed to survive a real logout/login; the
|
||||
instructor sees real roster state; the offering moves through `in_progress` to `completed`.
|
||||
|
||||
## ROOT_ V1 regression
|
||||
|
||||
`e2e-phase6f.js` rerun fresh: 17/17 unaffected. `e2e-phase5b.js` (the enrollment-related suite
|
||||
most exposed by this phase's one shared-code change — `enrollment.js`'s `resolveOrCreateStudent`
|
||||
gained an optional, default-off parameter) rerun fresh: 32/32, including its own embedded
|
||||
"Phase 5A student authentication still works unmodified" and "Phase 5A remote classroom still
|
||||
works unmodified" checks. `e2e-phase5c.js` was not independently re-run: no code in
|
||||
`notifications.js`/`students-auth.js` itself changed, only an additive, default-off parameter on
|
||||
a function `notifications.js` doesn't call.
|
||||
|
||||
## Frontend, migrations, content validation
|
||||
|
||||
Frontend: `npm run build` clean, no frontend code touched this phase. Content validation:
|
||||
0 errors, unchanged from Phase 6F's own numbers (this phase touched no course/module/activity
|
||||
content at all). Migrations: additive-only, `CREATE TABLE IF NOT EXISTS`/`DROP CONSTRAINT IF
|
||||
EXISTS` throughout, applied once via the existing `schema_migrations` tracking mechanism — no
|
||||
destructive statement anywhere in `033`/`034`.
|
||||
|
||||
## Documentation
|
||||
|
||||
New: `ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md`, `OFFERING_AND_REGISTRATION_
|
||||
MODEL.md`, `STUDENT_GUARDIAN_DATA_MODEL.md`, `SCHOOL_AND_COMMUNITY_DELIVERY_ARCHITECTURE.md`,
|
||||
this document. Updated: `WEBSITE_ENROLLMENT_INTEGRATION.md` (a documented future flow, current
|
||||
contract unchanged). Reviewed, no change needed: `REMOTE_AND_SELF_PACED_ARCHITECTURE.md`,
|
||||
`ENGINEERING_SOP.md`, `LAB_ARCHITECTURE.md`, `platform/README.md` (no new e2e-suite convention
|
||||
beyond what its existing list format already documents — see commit history for the actual
|
||||
`platform/README.md` addition covering `e2e-phase7.js`).
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **No admin UI** for offerings/registrations — the API is complete and tested; the screens are
|
||||
a well-specified follow-up, not a design question.
|
||||
- **Guardian communications are not yet wired into `notifications.js`** —
|
||||
`student_guardians.can_receive_communications`/`is_primary_contact` are recorded but nothing
|
||||
currently reads them to route an actual email.
|
||||
- **The website integration endpoint does not yet accept an `offering_slug`** — the future flow
|
||||
is documented, not implemented; that decision (extend the existing endpoint vs. a parallel
|
||||
one) was deliberately left open pending real website/checkout design.
|
||||
- **`agreement_acceptances.accepted_by_type = 'student'`** is only meaningful post-conversion,
|
||||
once a real `student_id` exists — an adult accepting at registration time (before their
|
||||
account exists) currently must accept as `'guardian'`-style even when self-registering; a
|
||||
future phase could add a `'self'` accepted-by-type distinct from `'guardian'` if that
|
||||
distinction becomes operationally important.
|
||||
|
||||
## Items requiring legal/business review before production use with real minors
|
||||
|
||||
See `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s own list in full: the actual legal text of any
|
||||
participation/media/liability agreement, jurisdiction-specific minor-consent requirements,
|
||||
data-retention/deletion policy for guardian and registration records, and whether emergency-
|
||||
contact fields need additional encryption at rest. None of these are resolved by this phase's
|
||||
work — they are named so they are never silently assumed away.
|
||||
|
||||
## Bugs found/fixed
|
||||
|
||||
One (the seat-reservation-before-validation ordering bug above), found and fixed before any
|
||||
commit via this phase's own smoke testing — not discovered by a later test run.
|
||||
|
||||
## Recommended Phase 8 scope
|
||||
|
||||
The admin UI for offerings/registrations (the one deliberately deferred piece of this phase's
|
||||
own mission); wiring guardian communications into `notifications.js`; and, informed by real
|
||||
business decisions, either extending `/api/integrations/enrollments` with an `offering_slug`
|
||||
or designing the parallel endpoint the future commerce flow needs — plus whatever the business
|
||||
decides about actually opening Winter Break Camp registration (which requires payment
|
||||
processing, explicitly out of every phase's scope so far).
|
||||
@@ -0,0 +1,189 @@
|
||||
# Phase 8 — Enrollment Operations, Guardian Communications & Website Integration
|
||||
|
||||
Phase 8's mission: turn Phase 7's business/product backend into an OPERABLE enrollment system —
|
||||
Offering → Registration → Guardian/Adult Contact → Agreements → Communications → Review →
|
||||
Confirmation → Enrollment → Cohort → Account Activation → Classroom/Lab Access — without direct
|
||||
database manipulation, ahead of RootNode's first commercial launch (Winter Break Camp). Not a
|
||||
payment phase — everything is built around where a future payment step will plug in.
|
||||
|
||||
## Baseline
|
||||
|
||||
Confirmed at start: branch `main`, clean tree, HEAD = `origin/main` = Phase 7's
|
||||
`df5e39f767a6af3ba50daabde5de97fa1b1a5f3b`.
|
||||
|
||||
## What was built
|
||||
|
||||
- **Migrations 035/036** — registration-stage notifications (nullable `student_id` +
|
||||
`registration_id` on `notification_events`), a `'registrant'` agreement-acceptance type,
|
||||
`offering_agreement_requirements`, a real unique index on `external_reference` (website
|
||||
registration idempotency), and the notification_type CHECK widening (see "bugs found" below).
|
||||
- **`offerings.js` extended**: `assertRegistrationOpen()` (server-authoritative registration
|
||||
windows), `addAgreementRequirement`/`getRegistrationAgreementStatus` (required vs. accepted vs.
|
||||
missing), a conversion-time block on missing required agreements, `computeOfferingReadiness()`
|
||||
(server-derived launch checklist), and `createRegistrationFromIntegrationEvent()` (the
|
||||
website's idempotent submission path, mirroring `enrollment.js`'s established pattern exactly).
|
||||
- **`notifications.js` extended**: a guardian-email fallback in `resolveRecipient()`, and a
|
||||
parallel `enqueueForRegistration()`/`resolveRegistrationRecipient()` pair for the pre-account
|
||||
registration stage — same table, same worker, same masked admin view, no second system.
|
||||
- **Five new templates** (`registrationReceived`, `registrationConfirmed`,
|
||||
`registrationNeedsAction`, `agreementRequired`, `campOperationalUpdate`) — HTML + text,
|
||||
RootNode-branded, no tracking, no marketing content.
|
||||
- **`public-offerings-routes.js`** (new) — the unauthenticated, explicitly-allowlisted offering
|
||||
read API the website will consume.
|
||||
- **`POST /api/integrations/registrations`** (new, in the existing `integration-routes.js`) —
|
||||
the website's registration submission contract, same shared-secret boundary as
|
||||
`/api/integrations/enrollments`, fully idempotent.
|
||||
- **Admin UI**: a real `/admin` → Offerings section (`OfferingsView.jsx`) — list, create, detail
|
||||
(overview with valid-transitions-only buttons, launch readiness, required agreements,
|
||||
registrations/roster) and registration detail (lifecycle, agreement status, communication
|
||||
history, convert-to-enrollment). Reuses the existing `Admin.module.css`/`adminApi` conventions
|
||||
exactly — no new UI framework or pattern introduced.
|
||||
|
||||
## Two real bugs found and fixed during this phase's own testing (before any commit)
|
||||
|
||||
1. **`notification_type` CHECK constraint mismatch** — the five new registration-stage
|
||||
notification types were wired into `notifications.js`/`notification-worker.js` before the
|
||||
database's own CHECK constraint was widened to accept them. Every `enqueueForRegistration()`
|
||||
call was silently failing (caught by the deliberate "a notification failure never blocks the
|
||||
real action" try/catch) until a comprehensive smoke test surfaced it directly. Fixed in
|
||||
`036_phase8_notification_types.sql`. Also hardened: every previously-silent
|
||||
`.catch(() => {})` around a notification call now at least logs the error, so a future
|
||||
regression like this surfaces immediately instead of silently vanishing.
|
||||
2. **A registration-insert/seat-reservation/notification ordering bug** — adding a
|
||||
`registration_received` notification right after the registration row's insert (but before
|
||||
the seat reservation that could still fail and roll the row back) meant a failed reservation's
|
||||
cleanup DELETE could hit a foreign-key violation against the notification row it had just
|
||||
created. Found via `e2e-phase7.js`'s own regression rerun. Fixed by reordering
|
||||
`createRegistration()` to validate → reserve the seat → insert → notify, strictly in that
|
||||
order, and simplifying the admin route accordingly (see `docs/OFFERING_AND_REGISTRATION_
|
||||
MODEL.md` for the full before/after).
|
||||
|
||||
## Cascading regression fixes (stale test assumptions, not new bugs)
|
||||
|
||||
Enforcing real registration-window checks (Step 21) broke `e2e-phase7.js`'s own `offeringB`
|
||||
(created in Phase 7, never transitioned out of `draft`, registered against successfully only
|
||||
because no such enforcement existed yet) and a new age-guidance offering created the same way.
|
||||
Fixed by publishing/opening both before registering, matching the new, correct requirement. A
|
||||
third `e2e-phase7.js` test's own file-allowlist check needed widening to permit the new, entirely
|
||||
legitimate `public-offerings-routes.js` reference to age-guidance fields (display-only, never
|
||||
authorization).
|
||||
|
||||
## Testing
|
||||
|
||||
`platform/test/e2e-phase8.js` — 16/16 passing (fresh stack): offering CRUD/transitions/
|
||||
readiness, registration open/closed enforcement, capacity + concurrent final-seat race through
|
||||
the real registration endpoints, the public serializer's safety (no internal/PII fields), website
|
||||
integration idempotency (sequential replay + concurrent duplicate), adult/minor registration
|
||||
paths, student-identity preservation on conversion, required-agreement conversion blocking with
|
||||
version snapshotting even after the live definition's version later changes, offering→cohort
|
||||
membership, a real activation token + queued notification on conversion, guardian recipient
|
||||
resolution, RBAC (offering/location visible to any instructor, guardian/registration detail
|
||||
admin-only), organization/location `access_notes` stripping, and confirmation ROOT_ V1 self-paced
|
||||
and classroom flows are unaffected.
|
||||
|
||||
## Winter Break commercial-path rehearsal
|
||||
|
||||
`infra/scripts/drills/test-phase8-winter-camp-registration.js` — 12/12 passing against a real,
|
||||
freshly-built stack, with no payment code anywhere: admin configures a real offering (location,
|
||||
capacity, instructor, required agreement), the website integration submits a REAL minor
|
||||
registration (a real guardian created/matched, a confirmation notification queued), guardian
|
||||
acceptance is recorded through the supported application flow, a future payment confirmation is
|
||||
SIMULATED via a plain admin transition, the registration converts to a real enrollment and real
|
||||
cohort membership, the account activates with a real one-time token, an adult registers and
|
||||
converts independently, two students genuinely compete for the final seat (both correctly
|
||||
rejected once capacity was already exhausted — no overbooking), the instructor runs a real
|
||||
classroom session and Prepares Labs, and the minor student completes real ROOT 01 orientation
|
||||
plus a REAL Lab task (`linux-inspect-hardware`) inside a real disposable Lab container, with the
|
||||
instructor then seeing the real progress.
|
||||
|
||||
## Regression
|
||||
|
||||
Fresh-stack reruns after every fix: `e2e-phase7.js` 17/17, `e2e-phase6f.js` 17/17, `e2e-phase5b.js`
|
||||
32/32 (enrollment.js's `resolveOrCreateStudent` gained an optional, default-off `returnToken`
|
||||
param in Phase 7, re-verified unaffected here), `e2e-phase5c.js` 29/29 (notifications.js was
|
||||
directly extended this phase — every existing Phase 5C notification type/flow reconfirmed
|
||||
byte-for-byte unaffected). `e2e-phase5a.js` not independently rerun: its own checks are embedded
|
||||
in and reconfirmed by both 5B's and 5C's "Phase 5A ... still works unmodified" assertions, and no
|
||||
code Phase 5A itself owns was touched.
|
||||
|
||||
## Frontend build
|
||||
|
||||
`npm run build` clean — the new `OfferingsView.jsx` compiles without warning; the only new
|
||||
warning is the pre-existing "chunk larger than 500kB" advisory, unchanged in cause from every
|
||||
prior phase.
|
||||
|
||||
## Real Lab result
|
||||
|
||||
Both rehearsals (Phase 7's from the prior phase, re-verified here, and this phase's own) drive a
|
||||
real disposable Lab container through a genuine `linux-inspect-hardware` scenario setup/validate
|
||||
cycle — confirmed passing for real, not simulated, in the Winter Break commercial-path rehearsal.
|
||||
|
||||
## Security/privacy findings
|
||||
|
||||
The public offerings API was built with an explicit field allowlist from the start (never
|
||||
`SELECT *`) — `e2e-phase8.js` asserts directly that `access_notes`, `organization_id`,
|
||||
`location_id`, `created_by`, `reserved_seats`, `capacity`, and `metadata` never appear in a
|
||||
public response, and that a fully-booked offering reports `"full"` rather than a raw seat count
|
||||
(avoiding a scraping/timing vector against a public, unauthenticated endpoint). No new security
|
||||
weakening was introduced; the two bugs found above were both silent-failure/ordering bugs, not
|
||||
authorization gaps.
|
||||
|
||||
## Legal-review requirements — unchanged, restated
|
||||
|
||||
See `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s list in full, still entirely unresolved by this
|
||||
phase's work: the actual legal text of any participation/media/liability agreement,
|
||||
jurisdiction-specific minor-consent requirements, data-retention/deletion policy, and whether
|
||||
emergency-contact fields need additional encryption at rest. `agreement_definitions.body_summary`
|
||||
remains a plain-language placeholder, never legal text.
|
||||
|
||||
## Documentation
|
||||
|
||||
New: `PHASE8_ENROLLMENT_OPERATIONS.md` (this document), `WINTER_BREAK_CAMP_OPERATIONS.md`,
|
||||
`REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md`, `OFFERING_ADMIN_GUIDE.md`. Updated:
|
||||
`ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md` (BUILT NOW/PHASE 8 BUILT/FUTURE table),
|
||||
`OFFERING_AND_REGISTRATION_MODEL.md` (the two ordering bugs, launch readiness, registration
|
||||
enforcement), `STUDENT_GUARDIAN_DATA_MODEL.md` (guardian communications now wired),
|
||||
`WEBSITE_ENROLLMENT_INTEGRATION.md` (the real, live registration contract with worked examples),
|
||||
`platform/README.md` (test list). Reviewed, no change needed: `docs/ENGINEERING_SOP.md`,
|
||||
`docs/LAB_ARCHITECTURE.md`, `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md`,
|
||||
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` (the existing provider adapter needed no changes — the
|
||||
new templates render through the exact same delivery path).
|
||||
|
||||
## Known limitations
|
||||
|
||||
- The website's own registration UI does not exist yet in this repository (rootnodeacademy.com
|
||||
is built separately) — the contract is ready and tested, nobody has built the website-side
|
||||
form against it yet.
|
||||
- Agreement acceptance is not exposed on the website registration endpoint itself — currently a
|
||||
separate admin-recorded (or future self-service) step after submission.
|
||||
- `registration_needs_action` has no automatic trigger — an admin action, deliberately, since
|
||||
"needs action" covers many distinct real reasons an admin should state explicitly rather than
|
||||
the platform guessing one.
|
||||
- No waitlist was built — a `waitlisted` registration status was considered and deliberately
|
||||
deferred; the existing `cancelled`-releases-a-seat mechanism plus manual re-registration covers
|
||||
the real, near-term operational need without adding a new status machine branch this phase
|
||||
didn't have concrete evidence would be used.
|
||||
- No payment processing — every "payment confirmed" step in both rehearsals is a plain admin
|
||||
transition, exactly as authorized.
|
||||
|
||||
## Remaining blockers before opening Winter Break registration to the public
|
||||
|
||||
1. Real dates, venue, capacity, and price are still undecided (not invented by this phase).
|
||||
2. The agreement's actual legal text requires professional review.
|
||||
3. No payment processing exists yet.
|
||||
4. The website's own registration form doesn't exist yet.
|
||||
|
||||
None of these are platform readiness gaps — the platform is ready to operate the flow the moment
|
||||
those four external decisions/assets exist.
|
||||
|
||||
## Commits
|
||||
|
||||
See `git log` for this phase's exact commit sequence (migrations/backend, admin UI, tests +
|
||||
rehearsal, docs) — grouped per `docs/ENGINEERING_SOP.md`'s existing convention.
|
||||
|
||||
## Recommended Phase 9
|
||||
|
||||
Real payment integration (Stripe or equivalent) wired into the registration-confirmation step;
|
||||
the actual website-side registration form; agreement-acceptance exposed on the public
|
||||
registration contract once the legal text is finalized; and, informed by real Winter Break Camp
|
||||
operational experience, whether a waitlist is actually worth building.
|
||||
@@ -0,0 +1,363 @@
|
||||
# Phase 9.1 — Real Stripe TEST-Mode Integration Validation
|
||||
|
||||
Status: **VALIDATED.** A real Stripe TEST Checkout Session, a real Stripe TEST payment ($399.00
|
||||
— the founder-approved Winter Break Founding Cohort tuition), a genuine Stripe-signed webhook,
|
||||
and the existing registration→enrollment conversion path were all proven end to end against
|
||||
Stripe's real TEST-mode API — no live money, no live-mode credential, nothing simulated. This
|
||||
document also records the first, credential-less attempt at this phase (which correctly stopped
|
||||
rather than fake anything) and a real defect the live traffic exposed and fixed.
|
||||
|
||||
## 1. Baseline verification
|
||||
|
||||
- First attempt (credential-less): branch `main`, clean tree, HEAD = `origin/main` =
|
||||
`80a5f6e4576cb9b83a60c9e9b57e4ebc52b62f07` — the Phase 9 milestone.
|
||||
- Resume attempt (this run): `git fetch origin` confirmed HEAD = `origin/main` =
|
||||
`77fd1869dbe694a7ff323a6fac07382e1f55b225` — the prior, credential-less Phase 9.1 commit.
|
||||
- No `docs/ROOTNODE_PRICING_AND_OFFERS_V1.md` or any "Phase 9.5" commit exists anywhere in this
|
||||
repository's history, branches, or tags (verified via `git log --all`) — the pricing decision
|
||||
below came directly from Chase in this session, not from a document that turned out not to
|
||||
have reached this canonical repo yet (it was apparently done on a different machine and not
|
||||
yet pushed). Used directly; no invented business decision was needed to proceed.
|
||||
- Phase 9 payment code and documentation re-inspected: `payment-provider.js`, `commerce.js`,
|
||||
`webhook-routes.js`, `offerings.js`'s conversion path, `admin-offerings-routes.js`'s payment
|
||||
routes, `integration-routes.js`'s checkout/agreement/status routes, `platform/test/
|
||||
e2e-phase9.js`, `infra/scripts/drills/test-phase9-winter-camp-commerce.js`.
|
||||
|
||||
## 2. Stripe TEST credential status
|
||||
|
||||
**First attempt**: none found anywhere reachable (process env, every `.env` file, this repo's
|
||||
Infisical wiring, session memory) — correctly stopped and documented rather than faked (see git
|
||||
history for that version of this document).
|
||||
|
||||
**This run**: Chase provided a real `sk_test_...` secret key directly. Format-verified as TEST
|
||||
mode (never printed in full anywhere in this document, any commit, or any persisted log — only
|
||||
an 8-character prefix `sk_test_` ever appears). Stored in `platform/.env` (gitignored, never
|
||||
committed — confirmed via `git status` throughout this phase). A push to Infisical for durable
|
||||
storage was attempted (per Chase's request) but the existing machine identity credential at
|
||||
`~/.config/infisical/identity.env` returned a consistent `401 Invalid credentials` from
|
||||
`app.infisical.com` across three attempts — a pre-existing, unrelated credential-store problem,
|
||||
not something fixable from this session. The Stripe key remains safely local in the meantime.
|
||||
|
||||
The webhook signing secret was obtained via `stripe listen --api-key ... --forward-to
|
||||
http://localhost:4200/api/webhooks/payment --print-secret` (the Stripe CLI, installed to
|
||||
`~/.local/bin/stripe` this session for exactly this purpose — no Dashboard endpoint needed for
|
||||
local TEST-mode validation).
|
||||
|
||||
## 3. Stripe adapter architecture — confirmed, unchanged
|
||||
|
||||
`StripeProvider.createCheckoutSession()` uses **dynamic Checkout line-item `price_data`**, never
|
||||
pre-created Stripe Price/Product IDs. RootNode's `offerings` table remains the sole source of
|
||||
truth for identity, price, and currency. Confirmed still true after this phase's fix (below) —
|
||||
the fix only changes how a LATER webhook event is correlated back to a transaction, not how the
|
||||
Checkout Session itself is created.
|
||||
|
||||
## 4. Disposable TEST offering used
|
||||
|
||||
A clearly-labeled, disposable offering — `[TEST] Stripe Integration Check <run-id>` —
|
||||
`delivery_mode: cohort`, price **39900 (cents) / usd = $399.00**, the founder-approved Winter
|
||||
Break "Founding Cohort" launch tuition Chase gave directly in this session (standard/reference
|
||||
retail $449, paid in full, no deposit, no payment plan — none of that billing structure was
|
||||
built, only the flat price was used for this validation). **Not** the real Winter Break offering
|
||||
— a separate, disposable fixture, transitioned to `cancelled` at the end of the run (confirmed).
|
||||
|
||||
## 5. Registration result
|
||||
|
||||
A real **guardian + minor** registration (the more complex path, preferred per the mission spec
|
||||
since the Winter Break audience may include minors) via
|
||||
`POST /api/integrations/registrations` — guardian `display_name`/email/phone real-shaped test
|
||||
data, registrant `is_minor: true`, no real child PII. Registration created, seat reserved,
|
||||
payment status tracked separately from registration status, no enrollment existed before payment
|
||||
— all as designed.
|
||||
|
||||
## 6. Agreement gate result
|
||||
|
||||
Checkout attempted before agreement acceptance → `409 AGREEMENTS_INCOMPLETE`. Agreement then
|
||||
accepted via `POST /api/integrations/registrations/:id/agreements` → `201`, `accepted_version:
|
||||
1` persisted. Checkout attempted again → succeeded. The real, live enforcement path, not
|
||||
bypassed.
|
||||
|
||||
## 7. Real Stripe TEST Checkout Session result
|
||||
|
||||
`POST /api/integrations/registrations/:id/checkout` returned a real
|
||||
`https://checkout.stripe.com/...` URL. Independently retrieved via **Stripe's own**
|
||||
`checkout.sessions.retrieve` API (never trusting RootNode's own echo):
|
||||
|
||||
```
|
||||
Stripe session cs_test_a1VBoHHATcDr3DRTnZixQX8E0E5Ulk3H1UAKwTYVEOYVboOcaKCuZEMuff:
|
||||
amount_total = 39900, currency = usd, mode = payment, livemode = false
|
||||
```
|
||||
|
||||
## 8. Server-authoritative amount verification
|
||||
|
||||
The checkout request was sent with a tampered body (`amount: 1, currency: "eur"` — neither field
|
||||
exists on the real contract, sent anyway to prove they're inert). Stripe's own recorded session
|
||||
still shows `amount_total: 39900, currency: usd` — the real, configured offering price, not the
|
||||
caller-supplied value. **The caller cannot choose the price or currency.**
|
||||
|
||||
## 9. Real TEST payment result
|
||||
|
||||
Chase completed the real Stripe-hosted TEST Checkout in a browser with Stripe's official test
|
||||
card (4242 4242 4242 4242). Independently confirmed via Stripe's own API (not just the
|
||||
redirect):
|
||||
|
||||
```
|
||||
GET /v1/checkout/sessions/cs_test_a1VB... → status: complete, payment_status: paid,
|
||||
payment_intent: pi_3U9QuqPl0OwZKsjY0lprOPWi
|
||||
```
|
||||
|
||||
## 10. Real signed webhook result
|
||||
|
||||
`stripe listen` relayed a genuine Stripe-signed `checkout.session.completed` event
|
||||
(`evt_1U9QutPl0OwZKsjYSkW8Le84`) to `POST /api/webhooks/payment`, which accepted it (`200`,
|
||||
`{"received":true,"result":"processed"}`). This is Stripe's own real signature over Stripe's own
|
||||
real event body — not a locally-fabricated one.
|
||||
|
||||
## 11. Invalid-signature result
|
||||
|
||||
A garbage `Stripe-Signature` header against a fabricated body → `400 invalid_signature`. The
|
||||
registration's status was re-read before and after and confirmed unchanged — zero domain side
|
||||
effect from a rejected signature.
|
||||
|
||||
## 12. Payment transaction result
|
||||
|
||||
Exactly one `payment_transactions` row for this registration, `status: paid`,
|
||||
`provider_reference` matching the real Checkout Session id. No `card_number`/`cvv`/`card_last4`/
|
||||
`payment_method_details` field exists anywhere on the record (none is ever stored, by design).
|
||||
|
||||
## 13. Registration confirmation result
|
||||
|
||||
`offering_registrations.status = enrollment_activated`, server-derived entirely from the real
|
||||
payment webhook — never a client action.
|
||||
|
||||
## 14. Enrollment conversion result
|
||||
|
||||
The **existing, unmodified** `convertRegistrationToEnrollment()` path ran — no second
|
||||
implementation was created. Verified directly against the database: exactly one row in
|
||||
`enrollments` for this student/program.
|
||||
|
||||
## 15. Cohort/capacity result
|
||||
|
||||
Exactly one row in `cohort_enrollments` for this student/cohort — the offering's `cohort_id`
|
||||
correctly produced real classroom membership on conversion, unchanged from Phase 8/9's design.
|
||||
|
||||
## 16. Guardian preservation result
|
||||
|
||||
Exactly one row in `student_guardians` for this student (the Phase 9 guardian-linking fix from
|
||||
the original Phase 9 phase held under real Stripe traffic too). `students.email` for the minor
|
||||
remains the empty string — the guardian's email was never written into the student's own
|
||||
identity record.
|
||||
|
||||
## 17. Activation/notification result
|
||||
|
||||
The real activation notification was queued and delivered through the existing dev-preview
|
||||
mechanism (the same one every prior phase's testing has used — `EMAIL_PROVIDER` was left at its
|
||||
dev default this run; Google Workspace live delivery was not exercised, since nothing about
|
||||
Stripe validation requires it and the mission explicitly said not to make GWS delivery a Stripe
|
||||
blocker). No second, Stripe-specific notification path exists — this reuses the exact
|
||||
`notifyForEnrollment` flow every other enrollment trigger already uses.
|
||||
|
||||
## 18. Student access result
|
||||
|
||||
The real one-time activation token worked: `POST /api/student/activate` → `200`, and the newly
|
||||
activated student's `GET /api/student/programs` includes the enrolled program. One access proof,
|
||||
as instructed — no full curriculum rehearsal was repeated (Phase 8/9's own rehearsals already
|
||||
cover that in depth).
|
||||
|
||||
## 19. Duplicate-event/idempotency result
|
||||
|
||||
The **real** Stripe event was fetched fresh via `stripe.events.retrieve(eventId)` and re-signed
|
||||
locally with the real webhook secret (`stripe.webhooks.generateTestHeaderString`) — never a
|
||||
fabricated "payment succeeded" event for an unpaid session, but a genuine replay of the exact
|
||||
event Stripe itself recorded, mirroring what `stripe events resend` does server-side (not usable
|
||||
here directly — an ephemeral `stripe listen` session has no persistent dashboard endpoint id to
|
||||
target). Result: `200`, `{"result":"duplicate"}`. Confirmed via direct query: still exactly one
|
||||
enrollment, still exactly one activation/confirmation notification — no double conversion, no
|
||||
double seat consumption, no double email.
|
||||
|
||||
## 20. Declined-payment result
|
||||
|
||||
**Not exercised via an actual real declined Stripe TEST payment this session** — doing so would
|
||||
require a second full browser-driven Checkout completion (Stripe Checkout Sessions have no
|
||||
supported pure-API "decline" trigger; PaymentIntents-direct-confirm would mean not using
|
||||
Checkout Sessions at all, which this phase was told not to redesign toward). Phase 9's
|
||||
deterministic suite already proves "failed payment retains the seat" directly, and this phase's
|
||||
own new regression test (§28) proves the exact real-Stripe reference-correlation shape a genuine
|
||||
`payment_intent.payment_failed` event would use. Available on request if Chase wants one more
|
||||
manual decline-card checkout to close this specific gap with fully real traffic.
|
||||
|
||||
## 21. Abandoned-checkout result
|
||||
|
||||
Fully automated, no browser needed: a second, separate registration's Checkout Session was
|
||||
created normally, then expired via Stripe's own `sessions.expire()` API (a real, supported,
|
||||
pure-API operation — no fabricated event). Stripe itself then fired a genuine
|
||||
`checkout.session.expired` webhook, relayed by `stripe listen` and accepted (`200`). Confirmed:
|
||||
the registration's status became `cancelled` and its seat was released exactly once, within the
|
||||
polling window.
|
||||
|
||||
## 22. Refund result
|
||||
|
||||
`stripe.refunds.create({ payment_intent })` against the real PaymentIntent → `succeeded`
|
||||
(`re_3U9QuqPl0OwZKsjY0IYvfOsM`, `39900 usd`). RootNode's own refund state was confirmed
|
||||
(`status: refunded`). The non-cascade rule was reconfirmed directly against the database: the
|
||||
registration stayed `enrollment_activated` and the enrollment stayed `active` — a refund does
|
||||
**not** automatically revoke registration or academic access. Whether it *should*, for this
|
||||
specific offering/policy, remains an explicit business-policy question this phase does not
|
||||
decide in code (per Phase 9's own design and this phase's own instruction).
|
||||
|
||||
## 23. Stripe ↔ RootNode reconciliation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| RootNode registration | `04772e72-4a09-4afd-8032-d6ef4d132375` |
|
||||
| RootNode enrollment | `10e2724a-43bb-4d35-8bea-98f600cbd8e9` |
|
||||
| RootNode student | `student-001` |
|
||||
| Stripe Checkout Session | `cs_test_a1VBoHHATcDr3DRTnZixQX8E0E5Ulk3H1UAKwTYVEOYVboOcaKCuZEMuff` |
|
||||
| Stripe PaymentIntent | `pi_3U9QuqPl0OwZKsjY0lprOPWi` |
|
||||
| Stripe webhook event | `evt_1U9QutPl0OwZKsjYSkW8Le84` |
|
||||
| Stripe refund | `re_3U9QuqPl0OwZKsjY0IYvfOsM` |
|
||||
| Amount / currency | `39900` / `usd` ($399.00) |
|
||||
|
||||
No secret key, webhook secret, card number, or CVV appears above or anywhere in this document.
|
||||
|
||||
## 24. Stripe Dashboard reconciliation
|
||||
|
||||
For a real transaction, Chase can locate it in the Stripe Dashboard under **Test mode →
|
||||
Payments** (search by the PaymentIntent id `pi_...` or the Checkout Session id `cs_test_...`
|
||||
above) or **Developers → Events** (search by the event id `evt_...`). The RootNode admin UI's
|
||||
own Payments card (on the registration detail view) shows the matching `provider_reference`
|
||||
side by side with RootNode's own status/timestamps — reconciliation is possible without either
|
||||
system exposing credentials or card data to the other.
|
||||
|
||||
## 25. Security revalidation (with real Stripe enabled)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Stripe secret key never reaches browser | Server-only env var; not referenced anywhere in `frontend/`. |
|
||||
| Webhook signing secret never reaches browser | Same. |
|
||||
| Caller cannot choose price | Proven directly (§8) against Stripe's own recorded amount. |
|
||||
| Caller cannot choose currency | Proven directly (§8), same test. |
|
||||
| Caller cannot bypass required agreements | Proven directly (§6). |
|
||||
| Caller cannot bypass capacity | Unchanged architecture; existing `e2e-phase9.js` last-seat race regression rerun clean (§27). |
|
||||
| Success redirect cannot forge payment | The script never trusted the redirect — every "paid" claim in this document is independently verified via Stripe's own API or the real webhook, not the URL. |
|
||||
| Invalid webhook signature rejected | Proven directly (§11). |
|
||||
| Duplicate webhook safe | Proven directly with a REAL re-signed event (§19). |
|
||||
| No card number enters RootNode DB | Proven directly (§12) — no such field exists. |
|
||||
| No card number enters RootNode logs | Confirmed — grepped this session's own logs/scratch files for the real secret values and any card-shaped pattern; none found. |
|
||||
| No Stripe secret enters audit log | Existing `e2e-phase9.js` hygiene test rerun clean (§27). |
|
||||
| No Stripe secret enters metrics | Same. |
|
||||
| Stripe provider IDs not high-cardinality metric labels | Unchanged architecture — metrics still use only fixed-enum labels. |
|
||||
| One student cannot inspect another's payment | Structural — payment routes are admin/owner-only; students have no route to any payment data at all. |
|
||||
| Integration shared secret remains server-to-server only | Unchanged — never read from any browser-facing code. |
|
||||
|
||||
## 26. Bugs found
|
||||
|
||||
**One real defect**, found only because real Stripe traffic was exercised: `payment_intent.
|
||||
payment_failed` and `charge.refunded` webhook events reference Stripe's PaymentIntent/Charge
|
||||
object, never the Checkout Session — but `payment_transactions.provider_reference` only ever
|
||||
stored the Session id. A real Stripe-issued failure or refund event would have silently failed
|
||||
to match any transaction (`result: 'error', reason: 'UNKNOWN_TRANSACTION'`). Phase 9's
|
||||
deterministic TestProvider never exposed this because its fabricated events always reuse
|
||||
whatever reference the calling test explicitly supplies, never distinguishing real Stripe object
|
||||
id shapes.
|
||||
|
||||
## 27. Bugs fixed
|
||||
|
||||
Smallest correct fix, matching Stripe's own documented pattern for this exact problem:
|
||||
|
||||
- `payment-provider.js`: `StripeProvider.createCheckoutSession()` now also sets
|
||||
`payment_intent_data: { metadata }` at session creation, so the SAME metadata (which already
|
||||
carries our own `transaction_id`) propagates onto the PaymentIntent Stripe creates — not just
|
||||
the Session.
|
||||
- `normalizeEvent()` (now `async`, one extra API call only on the rare refund path): for
|
||||
`payment_intent.payment_failed`, reads `obj.metadata.transaction_id` directly (the event
|
||||
object IS the PaymentIntent). For `charge.refunded`, retrieves the PaymentIntent
|
||||
(`stripe.paymentIntents.retrieve`) since Charges do not inherit PaymentIntent metadata
|
||||
automatically, and reads `transaction_id` from there.
|
||||
- `commerce.js`: the transaction lookup now matches `provider_reference = $1 OR
|
||||
transaction_id::text = $1` — a single additional `OR` clause, no schema/migration change.
|
||||
- `webhook-routes.js`: added the now-required `await` on `verifyAndParseWebhook` (the
|
||||
deterministic `TestProvider`'s synchronous return is unaffected — `await` on a non-promise
|
||||
value resolves immediately).
|
||||
|
||||
Verified fixed: the real Stripe refund in this session (§22) correctly resolved via this new
|
||||
path (the refund's `charge.refunded` webhook references the PaymentIntent, exactly the
|
||||
previously-broken case), and a new permanent deterministic regression test (§28) proves the
|
||||
mechanism directly.
|
||||
|
||||
## 28. Permanent regression coverage added
|
||||
|
||||
`platform/test/e2e-phase9.js` gained one new test: *"a webhook event referencing OUR OWN
|
||||
transaction_id (not provider_reference) — the real Stripe payment_intent/charge shape — is
|
||||
still matched"* — builds a test event whose `providerReference` is the transaction's own UUID
|
||||
(exactly what the fixed `normalizeEvent()` now produces for real Stripe failure/refund events)
|
||||
and confirms it's found and processed. This runs on every future deterministic `e2e-phase9.js`
|
||||
pass, with no Stripe credentials required.
|
||||
|
||||
`platform/test/stripe-integration-test.js` (the opt-in real-Stripe check from the first Phase
|
||||
9.1 attempt) was substantially extended: configurable price/currency
|
||||
(`STRIPE_TEST_PRICE_CENTS`/`STRIPE_TEST_CURRENCY`, defaults stay cheap/generic), a
|
||||
`STRIPE_TEST_MINOR=true` guardian+minor path, and — once a real payment lands — fully automated
|
||||
deep verification (payment record, enrollment/cohort/guardian reconciliation, the real
|
||||
re-signed-event duplicate proof, student access, a real refund) plus a fully automated,
|
||||
browser-free abandoned/expired-checkout proof via `sessions.expire()`. Still opt-in, still a
|
||||
guaranteed no-op by default, still refuses live-mode keys.
|
||||
|
||||
## 29. Regression results
|
||||
|
||||
- `platform/test/e2e-phase9.js`: **20/20** (fresh stack, `PAYMENT_PROVIDER=test`) — includes the
|
||||
one new regression test from §28.
|
||||
- `infra/scripts/drills/test-phase9-winter-camp-commerce.js`: **13/13** (fresh stack) — unaffected
|
||||
by the fix, rerun to confirm.
|
||||
- No registration/agreement/enrollment/notification-template code changed beyond the webhook
|
||||
correlation fix itself, so Phase 8/5B/5C were not rerun (per the mission's own conditional
|
||||
rule) — the fix is entirely inside `commerce.js`/`payment-provider.js`/`webhook-routes.js`,
|
||||
already covered by the Phase 9 suite.
|
||||
- No frontend code changed — no frontend build was required.
|
||||
|
||||
## 30. Documentation
|
||||
|
||||
Rewrote `PHASE9_1_STRIPE_TEST_VALIDATION.md` (this document) from BLOCKED to the real, proven
|
||||
results. Updated `docs/STRIPE_INTEGRATION_RUNBOOK.md` (real-Stripe local testing is no longer
|
||||
"not yet exercised") and `docs/WINTER_BREAK_LAUNCH_CHECKLIST.md` (the Stripe TEST technical
|
||||
validation item is now checked; live-mode items remain unchecked, correctly — this phase never
|
||||
touched live mode). `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` needed no change — nothing about
|
||||
the website-facing contract itself changed this phase, only the server-side webhook correlation
|
||||
internals.
|
||||
|
||||
## 31. Remaining Stripe TEST gaps
|
||||
|
||||
An actual real declined-payment checkout was not exercised (§20) — available on request, needs
|
||||
one more manual browser step. Everything else the mission asked for was proven with real Stripe
|
||||
traffic.
|
||||
|
||||
## 32. Remaining LIVE Stripe gaps
|
||||
|
||||
Unchanged from Phase 9 — see `docs/STRIPE_INTEGRATION_RUNBOOK.md`'s "Going live" section and
|
||||
`docs/WINTER_BREAK_LAUNCH_CHECKLIST.md`: business/account verification, a live secret key, a
|
||||
permanent live webhook endpoint, payout/bank configuration, a real HTTPS production URL, and
|
||||
production deployment itself. None of this phase's work performed or authorized any live-mode
|
||||
action.
|
||||
|
||||
## 33. Remaining Winter Break business decisions
|
||||
|
||||
Real dates, venue, and final capacity remain undecided (price is now decided — $399 launch /
|
||||
$449 standard retail, per Chase directly this session; not yet reflected in a committed
|
||||
`ROOTNODE_PRICING_AND_OFFERS_V1.md` in this canonical repo — see §1). Refund/cancellation
|
||||
*policy* (as opposed to the mechanical non-cascade behavior proven in §22) remains an open
|
||||
business decision.
|
||||
|
||||
## 34. Remaining legal requirements
|
||||
|
||||
Unchanged from every prior phase: the actual agreement/waiver legal text, jurisdiction-specific
|
||||
minor-consent review, data-retention/deletion policy — see `docs/STUDENT_GUARDIAN_DATA_MODEL.md`.
|
||||
|
||||
## Final status
|
||||
|
||||
**PHASE 9.1 COMPLETE / STRIPE TEST-MODE VALIDATED**
|
||||
|
||||
A real Stripe TEST Checkout Session, a real Stripe TEST payment, a genuine Stripe-signed
|
||||
webhook, and the existing registration→enrollment conversion were all proven end to end, plus a
|
||||
real duplicate-delivery proof, a real automated abandoned-checkout proof, and a real refund with
|
||||
the non-cascade rule reconfirmed. One genuine defect (webhook-event correlation for
|
||||
PaymentIntent/Charge-referencing events) was found only by this real traffic, fixed with the
|
||||
smallest correct change, and covered by a new permanent deterministic regression test.
|
||||
@@ -0,0 +1,287 @@
|
||||
# Phase 9 — Commerce Integration, Website Registration & Winter Break Launch
|
||||
|
||||
Phase 9's mission: turn Phase 8's operable-but-unpaid enrollment system into a REAL commerce
|
||||
pipeline — offering price → website registration → agreement acceptance (closing Phase 8's one
|
||||
open gap) → hosted checkout → a verified, idempotent payment webhook → registration confirmation
|
||||
→ enrollment activation — without ever trusting a browser for price, payment status, or
|
||||
agreement acceptance, and without touching a single payment card number anywhere in this
|
||||
codebase.
|
||||
|
||||
## Baseline
|
||||
|
||||
Confirmed at start: branch `main`, clean tree, HEAD = `origin/main` = Phase 8's
|
||||
`788fb197e0a270423c1ff55829dc1d5dc380591b`.
|
||||
|
||||
## What was built
|
||||
|
||||
- **Migration 037** — `offerings.price_amount`/`price_currency` (nonnegative CHECK),
|
||||
`offering_registrations.reservation_expires_at`, and two new tables:
|
||||
`payment_transactions` (one row per checkout attempt — amount/currency snapshotted at
|
||||
creation, provider, provider_reference with a unique partial index, status) and
|
||||
`payment_webhook_events` (one row per provider event id, a real PK — the entire webhook
|
||||
idempotency mechanism, never a "check then insert").
|
||||
- **`payment-provider.js`** (new) — the payment-provider abstraction. `StripeProvider` (real
|
||||
Stripe SDK, lazy-constructed so a test-provider dev stack never needs the real package
|
||||
configured, real HMAC webhook verification via Stripe's own SDK helper, a narrow
|
||||
`normalizeEvent()` mapping Stripe's event vocabulary to `{eventId, eventType,
|
||||
providerReference, normalizedStatus}`) and `TestProvider` (deterministic, in-process, no
|
||||
network, no real money — the default, matching `EMAIL_PROVIDER`'s existing "dev by default,
|
||||
real provider opt-in" precedent). `commerce.js` and everything above it call the SAME methods
|
||||
regardless of which is active — swapping providers is an environment variable, not a code
|
||||
change.
|
||||
- **`commerce.js`** (new) — the business-logic layer combining `offerings.js` and
|
||||
`payment-provider.js`: `createCheckoutSession()` (validates state/agreements, snapshots the
|
||||
offering's own price, never a caller-supplied amount), `processWebhookEvent()` (the idempotent
|
||||
webhook entry point), `handlePaymentSuccess/Failure/CheckoutAbandoned/Refund()`,
|
||||
`recordManualRefund()`, `reapExpiredReservations()`.
|
||||
- **`webhook-routes.js`** (new) — `POST /api/webhooks/payment`. Reads the raw request body
|
||||
(Express raw-body middleware scoped to only this one path, registered before the global JSON
|
||||
parser in `server.js`), checks `Stripe-Signature`/`X-Test-Payment-Signature`, treats a bad
|
||||
signature as a clean, quiet `400` (never a 500, never a hint about why), always responds `200`
|
||||
once verification passes (a duplicate/ignored event is recorded, not retried forever).
|
||||
- **`integration-routes.js` extended** — `POST /api/integrations/registrations/:id/agreements`
|
||||
(closes Phase 8's explicit gap — agreement acceptance is now on the public registration
|
||||
contract; who accepts is server-derived from the registration's own `is_minor`, never
|
||||
caller-supplied), `POST /api/integrations/registrations/:id/checkout` (server-to-server only,
|
||||
the amount always comes from the offering's own price), and `GET
|
||||
/api/integrations/registrations/:id/status` (a deliberately minimal, non-admin status lookup
|
||||
for the website's success page — no PII, no amount, no provider reference — see "Real bugs
|
||||
found" below for why this exists).
|
||||
- **`offerings.js` extended** — `reservation_expires_at` computed only for priced offerings
|
||||
(`computeReservationExpiry()`); `OFFERING_EDITABLE_FIELDS`/`updateOffering()` (dynamic,
|
||||
allowlisted `PATCH`, never touches status/slug/program_id); `computeOfferingReadiness()`
|
||||
gained `price` and `payment_provider` checks; `convertRegistrationToEnrollment()` now fires the
|
||||
same activation-or-confirmation email every other enrollment path already triggers (closing a
|
||||
second real gap — see below).
|
||||
- **Admin UI** — a Pricing card on the offering detail view (set/clear price), a Payments card on
|
||||
the registration detail view (transaction history, a "Record refund" action for paid
|
||||
transactions), both admin/owner-gated, both reusing the existing `Admin.module.css`/`adminApi`
|
||||
conventions exactly.
|
||||
- **Reservation reaper** — a `setInterval` poller (`server.js`) releasing any paid-offering
|
||||
reservation whose `reservation_expires_at` has passed and which never reached a terminal state
|
||||
(`enrollment_activated`/`cancelled`/`confirmed` excluded).
|
||||
- **Prometheus metrics** — `rootnode_checkout_sessions_total`, `rootnode_payments_total`,
|
||||
`rootnode_payment_webhooks_total`, `rootnode_payment_conversion_total`,
|
||||
`rootnode_seats_released_total` — every label a small fixed enum (result/status/provider/
|
||||
event_type/reason), never a registration_id, customer_id, or payment_intent_id.
|
||||
|
||||
## Three real bugs found and fixed during this phase's own testing (before any commit)
|
||||
|
||||
1. **Duplicate `registration_confirmed` notification.** `handlePaymentSuccess` originally fired
|
||||
an explicit `registration_confirmed` notification AFTER `transitionRegistration('confirmed')`
|
||||
already fires one internally — found via direct smoke-test output showing the notification
|
||||
twice on one registration. Fixed by deleting the redundant explicit call.
|
||||
2. **A converted registration never sent an activation email.** `convertRegistrationToEnrollment`
|
||||
created the real student/enrollment/cohort rows but never called `notifyForEnrollment` — every
|
||||
OTHER enrollment path (the admin manual-enroll route, `/api/integrations/enrollments`) already
|
||||
does. Fixed by adding the call — which then surfaced bug 3 below, found immediately by the
|
||||
Winter Break commerce rehearsal (not by the narrower e2e suite, which happened not to exercise
|
||||
a brand-new minor-guardian conversion through this exact path):
|
||||
3. **A converted minor student had no real guardian link.** `convertRegistrationToEnrollment`
|
||||
creates the new student and enrollment but never promoted the registration's own `guardian_id`
|
||||
into the relational `student_guardians` table — so `notifications.js`'s guardian-recipient
|
||||
fallback (`resolveRecipient()`) had nothing to resolve to, and the new activation email failed
|
||||
with `no_recipient_address`. Fixed by calling the existing `linkStudentGuardian()` helper
|
||||
(already used elsewhere for admin-managed guardian links, never previously wired into
|
||||
conversion) whenever `reg.is_minor && reg.guardian_id`. This is genuine plumbing, not a
|
||||
business decision — a minor student converting through a paid registration now has the exact
|
||||
same real guardian relationship a student created any other way already has.
|
||||
|
||||
Found via the Winter Break commerce rehearsal specifically, not the narrower e2e suite — the
|
||||
rehearsal's own multi-step, real-webhook, real-notification-worker structure exercises the exact
|
||||
timing and data shape a real minor registration produces, where a synthetic unit-style test can
|
||||
accidentally supply a field (like `registrant_email`) the real website contract never actually
|
||||
sends for a minor.
|
||||
|
||||
## Design decisions worth stating explicitly
|
||||
|
||||
- **Seat reservation timing unchanged from Phase 7/8**: a seat is still reserved AT
|
||||
REGISTRATION, not at checkout. Phase 9 layers a bounded, configurable expiration
|
||||
(`reservation_expires_at`, paid offerings only) on top rather than redesigning the reservation
|
||||
model — a registrant who starts checkout keeps their seat for
|
||||
`REGISTRATION_RESERVATION_TIMEOUT_MINUTES` (default 30) even if they never complete payment,
|
||||
released either by an abandoned-checkout webhook event or the reaper.
|
||||
- **A failed payment does NOT release the seat.** The registrant can retry checkout against the
|
||||
same reservation. Only an abandoned/expired checkout session or reaper timeout releases it.
|
||||
Proved directly in both `e2e-phase9.js` and the commerce rehearsal, as two genuinely different
|
||||
code paths (`handlePaymentFailure` vs. `handleCheckoutAbandoned`/the reaper), not one path with
|
||||
a flag.
|
||||
- **Three separately-tracked statuses, never auto-cascading**: registration status
|
||||
(`offering_registrations.status`), enrollment status (`enrollments.status`), payment status
|
||||
(`payment_transactions.status`). A manual refund updates ONLY the transaction's status —
|
||||
proved directly by querying both other statuses unchanged after a refund in `e2e-phase9.js`,
|
||||
not just asserted by comment.
|
||||
- **No waitlist was built.** Considered and deliberately deferred — the existing
|
||||
`cancelled`-releases-a-seat mechanism, now paired with the reservation reaper's automatic
|
||||
release of abandoned checkouts, covers the real near-term operational need (a freed seat
|
||||
reopens registration automatically) without adding a new status-machine branch this phase has
|
||||
no concrete evidence would be used for a single-camp launch. Revisit once real Winter Break
|
||||
operational experience exists.
|
||||
|
||||
## Testing
|
||||
|
||||
`platform/test/e2e-phase9.js` — 19/19 passing (fresh stack): offering pricing in the public
|
||||
serializer (no internal field leakage), checkout blocked until agreements are accepted,
|
||||
registration rejected on draft/closed/full offerings, the server-authoritative amount (a
|
||||
caller-supplied `amount` is silently ignored), webhook signature required/rejected-if-invalid/
|
||||
idempotent-on-replay, payment success driving real conversion and activation with exactly one
|
||||
`registration_confirmed` notification (no duplicate), a failed payment leaving the seat intact
|
||||
and the registration retriable, a last-seat concurrent checkout race (`[201,409]`), the
|
||||
reservation reaper releasing an expired unpaid reservation, a manual refund with no cascade into
|
||||
registration/enrollment status, a minor payment confirmation routing to the guardian (never
|
||||
writing to `students.email`), the admin payment view (amount/status/provider, never card data),
|
||||
RBAC (a plain instructor denied payment data, the public API never exposing it), audit/metric
|
||||
hygiene (no `sk_`/`whsec_`/fake-card patterns, no registration_id as a metric label), the new
|
||||
website status-lookup endpoint's minimal safe shape, and confirmation that ROOT_ V1 self-paced
|
||||
enrollment, a free (Phase-8-style) offering, and classroom/cohort flows are all unaffected.
|
||||
|
||||
## Winter Break commercial-path rehearsal
|
||||
|
||||
`infra/scripts/drills/test-phase9-winter-camp-commerce.js` — **13/13 passing** against a real,
|
||||
freshly-built stack, `PAYMENT_PROVIDER=test` (no live money, no Stripe account needed): admin
|
||||
configures a real priced offering (location, capacity, instructor, required agreement, price,
|
||||
payment provider) and readiness honestly reports `ready_to_open_registration: false` (dates/
|
||||
registration-window are deliberately unset — a real business decision this phase does not
|
||||
invent) while every check it DID configure shows ready; admin opens registration; a minor
|
||||
registers via the website integration, checkout is correctly blocked before the agreement is
|
||||
accepted, the guardian accepts it, checkout is created; the test payment provider confirms
|
||||
payment via a REAL webhook call — registration converts, the real activation token is retrieved
|
||||
via the same dev-preview mechanism `e2e-phase5c.js` already established (never a raw-token
|
||||
shortcut), and the minor's account activates; the exact same webhook event replayed (a real
|
||||
Stripe retry scenario) changes nothing further; an adult registers, pays, and converts
|
||||
independently with no guardian involved; a failed payment leaves a retriable registration without
|
||||
consuming a phantom extra seat; an abandoned checkout (expired session) releases the seat; two
|
||||
students genuinely compete for the final seat of a paid offering and exactly one wins; the
|
||||
instructor starts a real classroom session and Prepares Labs; the minor student joins, completes
|
||||
real orientation plus a REAL Lab task (`linux-inspect-hardware`) inside a real disposable Lab
|
||||
container, and the instructor sees the real roster, payment, and progress state for the camp.
|
||||
|
||||
## Regression
|
||||
|
||||
Fresh-stack reruns after every fix: `e2e-phase8.js` 16/16, `e2e-phase7.js` 17/17, `e2e-phase6f.js`
|
||||
17/17, `e2e-phase5c.js` 29/29, `e2e-phase5b.js` 32/32 — every existing flow this phase's changes
|
||||
touch (`offerings.js`, `notifications.js`) reconfirmed byte-for-byte unaffected. `e2e-phase5a.js`
|
||||
not independently rerun, per the same established convention as every prior phase's own doc — its
|
||||
checks are embedded in and reconfirmed by 5B's/5C's own "Phase 5A ... still works unmodified"
|
||||
assertions, and no code Phase 5A itself owns was touched. Course-content validator
|
||||
(`infra/scripts/validate-course-content.js`) — clean, 0 errors, unaffected by this phase.
|
||||
Migration idempotency — verified by restarting the API container against an already-migrated
|
||||
database (no `down -v`) and confirming a clean startup with no migration errors, in addition to
|
||||
every fresh `down -v`/`up --build` cycle across this phase's own testing applying all 37
|
||||
migrations cleanly from empty every time.
|
||||
|
||||
## Frontend build
|
||||
|
||||
`npm run build` clean — the new Pricing/Payments admin UI cards compile without warning; the
|
||||
only new warning is the pre-existing "chunk larger than 500kB" advisory, unchanged in cause from
|
||||
every prior phase.
|
||||
|
||||
## Real Lab result
|
||||
|
||||
The Winter Break commerce rehearsal drives a real disposable Lab container through a genuine
|
||||
`linux-inspect-hardware` scenario setup/validate cycle, reached this time through the full,
|
||||
real commerce path (checkout → webhook → conversion → activation → classroom join) rather than a
|
||||
simulated payment step — confirmed passing for real, not simulated.
|
||||
|
||||
## Security/privacy findings
|
||||
|
||||
- No payment-card data exists anywhere in this codebase — not in a request field, not in a
|
||||
database column, not in a log line, not in an audit-event metadata blob. Stripe's hosted
|
||||
Checkout page is the only thing that ever sees a real card number.
|
||||
- The price is read server-side from the offering at checkout-creation time; a caller-supplied
|
||||
`amount` field (tested directly) is silently ignored — a browser cannot choose its own price.
|
||||
- The webhook route requires a verified signature on every request; an invalid/forged signature
|
||||
is rejected with a generic `400`, never a 500 and never a hint about why verification failed
|
||||
(matching this codebase's existing anti-enumeration precedent).
|
||||
- The webhook is idempotent via a real database primary key on the provider's own event id — a
|
||||
retried delivery (Stripe's own retry behavior on anything but a `200`) can never double-process.
|
||||
- Admin payment views require admin/owner role; a plain instructor is denied (`403`, tested
|
||||
directly); the public offerings API never exposes payment/transaction data at all.
|
||||
- Metrics and audit events use only small, fixed-enum labels — no registration_id, customer_id,
|
||||
payment_intent_id, or email ever appears as a metric label or in audit metadata (tested
|
||||
directly via regex against both).
|
||||
- Cross-student/guardian isolation holds: a minor's payment confirmation routes the activation
|
||||
email to the guardian, never writing the guardian's email into the student's own identity
|
||||
record — the same invariant Phase 8 already established, reconfirmed under the new payment
|
||||
path.
|
||||
|
||||
## Legal-review requirements — unchanged, restated
|
||||
|
||||
See `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s list in full, still entirely unresolved by this
|
||||
phase's work: the actual legal text of any participation/media/liability agreement,
|
||||
jurisdiction-specific minor-consent requirements, data-retention/deletion policy, and whether
|
||||
emergency-contact fields need additional encryption at rest. Nothing about accepting payment
|
||||
changes this list.
|
||||
|
||||
## What was deliberately NOT built (explicit, per this phase's own authorization)
|
||||
|
||||
Subscriptions, membership billing, coupons/discount codes, gift cards, installment/financing
|
||||
plans, complex tax automation, accounting-system (QuickBooks etc.) integration, a Parent Portal
|
||||
or guardian login, a CRM, marketing email/SMS, certificates, INIT_/COMPILE_ curriculum, ROOT
|
||||
08/11, production deployment, a mobile app, and an AI tutor — none of these were touched, added,
|
||||
or scaffolded "for later." A waitlist was considered and deliberately deferred (see "Design
|
||||
decisions" above).
|
||||
|
||||
## Documentation
|
||||
|
||||
New: `PHASE9_COMMERCE_AND_LAUNCH.md` (this document), `WEBSITE_REGISTRATION_AND_CHECKOUT.md`,
|
||||
`STRIPE_INTEGRATION_RUNBOOK.md`, `WINTER_BREAK_LAUNCH_CHECKLIST.md`. Updated:
|
||||
`ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md` (commerce boundary + PHASE 9 BUILT rows),
|
||||
`OFFERING_AND_REGISTRATION_MODEL.md` (pricing, the payment/commerce boundary, the three-status
|
||||
principle, the updated "not built" list), `WEBSITE_ENROLLMENT_INTEGRATION.md` (price/availability
|
||||
fields, the real agreement/checkout/webhook/confirmation sections replacing Phase 8's
|
||||
placeholders), `WINTER_BREAK_CAMP_OPERATIONS.md` (the real payment step replacing the old manual
|
||||
placeholder, updated blockers), `platform/README.md` (test list). Reviewed, no change needed:
|
||||
`docs/ENGINEERING_SOP.md` (its own numbered-section convention has not been extended past Phase
|
||||
6B for any prior phase either — Phase 7 and 8 made the same call; its Roadmap Status table
|
||||
doesn't itemize commerce as a tracked line item to begin with).
|
||||
|
||||
## Known limitations
|
||||
|
||||
- The website's own registration/checkout UI does not exist yet in this repository
|
||||
(rootnodeacademy.com is built separately, not RootNode-owned in this repo) — the contract is
|
||||
ready and tested, nobody has built the website-side form against it yet.
|
||||
- No real Stripe credentials exist — the entire commerce pipeline is built and tested against the
|
||||
deterministic test provider, which exercises the exact same `commerce.js`/webhook-route code
|
||||
path real Stripe traffic would, but has never made a real network call to Stripe's API or
|
||||
received a real Stripe-signed webhook. See `docs/STRIPE_INTEGRATION_RUNBOOK.md`.
|
||||
- No waitlist (deliberate, see "Design decisions" above).
|
||||
- `registration_needs_action` still has no automatic trigger, unchanged from Phase 8 — still a
|
||||
deliberate admin action, not something a failed payment forces (a failed payment DOES send this
|
||||
notification automatically, but broader "needs action" cases remain admin-initiated).
|
||||
|
||||
## Remaining blockers before opening Winter Break registration to the public
|
||||
|
||||
See `docs/WINTER_BREAK_LAUNCH_CHECKLIST.md` for the full, itemized list. In summary: real dates/
|
||||
venue/capacity/price (business decisions), the agreement's actual legal text (legal review), real
|
||||
Stripe credentials (external dependency), and the website's own registration form (separate,
|
||||
not-yet-built repo). None of these are platform readiness gaps — the platform is ready to operate
|
||||
the full commerce flow the moment those four external decisions/assets exist.
|
||||
|
||||
## Commits
|
||||
|
||||
See `git log` for this phase's exact commit sequence (migrations/payment-provider/commerce,
|
||||
integration routes, admin UI, tests + rehearsal, docs) — grouped per
|
||||
`docs/ENGINEERING_SOP.md`'s existing convention.
|
||||
|
||||
## Recommended next phase
|
||||
|
||||
Real Stripe live-mode validation once credentials exist (`docs/STRIPE_INTEGRATION_RUNBOOK.md`'s
|
||||
"Going live" section); the actual website-side registration/checkout form; the agreement's legal
|
||||
text once reviewed; and, informed by real Winter Break Camp commercial operation, whether a
|
||||
waitlist is actually worth building.
|
||||
|
||||
**Phase 9.1 addendum**: real Stripe TEST-mode validation was completed — a real Stripe TEST
|
||||
Checkout, a real TEST payment (at the founder-approved $399 Founding Cohort price), a genuine
|
||||
signed webhook, and the existing registration→enrollment conversion were all proven end to end,
|
||||
plus real duplicate-delivery, abandoned-checkout, and refund proofs. One real defect (webhook
|
||||
event correlation for PaymentIntent/Charge-referencing events, invisible to the deterministic
|
||||
TestProvider) was found and fixed with a permanent regression test added. See `docs/
|
||||
PHASE9_1_STRIPE_TEST_VALIDATION.md` for the full evidence. `PAYMENT_PROVIDER=stripe`,
|
||||
`STRIPE_SECRET_KEY`, and `STRIPE_WEBHOOK_SECRET` are now wired through
|
||||
`platform/docker-compose.dev.yml` (empty/unset by default — the deterministic TestProvider
|
||||
remains the default for routine dev/test).
|
||||
|
||||
---
|
||||
|
||||
## PHASE 9 APPLICATION BUILD COMPLETE / LIVE STRIPE VALIDATION PENDING
|
||||
@@ -0,0 +1,62 @@
|
||||
# Registration & Guardian Communications
|
||||
|
||||
Phase 8 §8-§11 — how registration-stage notifications extend the existing Phase 5C notification
|
||||
system. No second email subsystem was built: same `notification_events` table, same durable
|
||||
worker (`notification-worker.js`), same Google Workspace-backed provider adapter
|
||||
(`email/provider.js`), same masked-recipient admin API.
|
||||
|
||||
## What's new vs. Phase 5C
|
||||
|
||||
`notification_events.student_id` is now nullable, and a `registration_id` column was added (a
|
||||
CHECK constraint guarantees every row still has a real subject) — because a registration-stage
|
||||
notification (Registration Received, Agreement Required) may need to go out before any student
|
||||
account exists at all, for a brand-new guardian-submitted registration. `notification_type`'s
|
||||
CHECK constraint was widened to include five new types. See `033_phase8_...sql` and
|
||||
`036_phase8_notification_types.sql`.
|
||||
|
||||
**A real bug found and fixed during this phase's own testing**: the new notification types were
|
||||
added to `notifications.js`/`notification-worker.js` before the database CHECK constraint was
|
||||
widened to accept them — every registration-stage `enqueue` call was silently failing (caught by
|
||||
the deliberate "a notification failure never blocks the real action" try/catch) until this was
|
||||
caught and fixed. See `docs/PHASE8_ENROLLMENT_OPERATIONS.md`.
|
||||
|
||||
## The five new templates
|
||||
|
||||
1. **Registration Received** — queued the moment a registration is created (website or admin).
|
||||
2. **Registration Confirmed** — queued when a registration moves to `confirmed`.
|
||||
3. **Registration Needs Action** — available for an admin/future-flow to queue with a reason
|
||||
when a registration needs something before it can proceed (not automatically triggered by
|
||||
any single lifecycle transition this phase — a deliberate choice, since "needs action" covers
|
||||
many different real reasons an admin should state explicitly).
|
||||
4. **Agreement Required** — queued once per missing required agreement when a registration
|
||||
enters `agreements_pending`.
|
||||
5. **Camp/Offering Operational Update** — a deliberately generic operational notice (e.g. "camp
|
||||
starts Monday, here's what to bring"), NOT a marketing template — no promotional content, no
|
||||
discount codes, no unrelated announcements. Available for an admin to trigger manually;
|
||||
nothing queues it automatically.
|
||||
|
||||
All five are HTML + plain-text, RootNode-branded, no tracking pixels, no external images, no
|
||||
passwords or sensitive data — same `templates.js` conventions Phase 5C established.
|
||||
|
||||
## Recipient resolution — extended, never overwritten
|
||||
|
||||
`resolveRecipient()`'s existing order (student's own email → the enrollment's
|
||||
`metadata.contact_email`) now has a third, final fallback: a real, communications-enabled
|
||||
primary guardian's email (`student_guardians`/`guardians`). This is purely additive — a student
|
||||
who already resolves correctly today is completely unaffected, since the new fallback is only
|
||||
ever reached when the first two return nothing. `enrollmentContactEmail` (Phase 5C, unchanged)
|
||||
and this new guardian fallback deliberately coexist: `contact_email` remains a per-enrollment
|
||||
override for the live website integration's existing contract, while the guardian relationship
|
||||
is the durable, cross-enrollment identity — prefer the guardian model going forward, but the
|
||||
transitional field is never broken.
|
||||
|
||||
For registration-stage notifications (no student yet, possibly), a separate
|
||||
`resolveRegistrationRecipient()` tries: the registration's own student (if already converted) →
|
||||
its linked guardian → the registrant's own supplied email.
|
||||
|
||||
## What was deliberately NOT built
|
||||
|
||||
No bulk/marketing email composer, no free-form admin-authored message sent to an arbitrary
|
||||
recipient — every notification is one of the fixed templates above, triggered by a real
|
||||
lifecycle event or an explicit, scoped admin action (never a raw "compose email" form). No SMS.
|
||||
No third notification channel beyond email.
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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 — real as of Phase 9
|
||||
|
||||
The boundary itself is unchanged: the website owns marketing/pricing DISPLAY and the hosted
|
||||
checkout experience, RootNode's platform owns the actual price (server-controlled, never
|
||||
browser-supplied), payment verification, and everything after a verified commerce event. Phase 7
|
||||
documented the flow; **Phase 9 built it for real**: an offering can carry a price
|
||||
(`offerings.price_amount`/`price_currency`), a payment-provider abstraction (Stripe or a
|
||||
deterministic in-process test provider — see `docs/PHASE9_COMMERCE_AND_LAUNCH.md`) creates a
|
||||
hosted checkout session server-side, and a verified, idempotent webhook is the ONLY thing that
|
||||
ever confirms payment and drives `registration confirmed → enrollment activated` — a browser
|
||||
`?paid=true` redirect proves nothing and is never trusted. See `docs/
|
||||
WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the website's contract and `docs/
|
||||
STRIPE_INTEGRATION_RUNBOOK.md` for going live with real Stripe.
|
||||
|
||||
## First commercial target: Winter Break Camp
|
||||
|
||||
Per `docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md`'s Option A (Linux & Systems Foundations,
|
||||
ROOT 01-02, ~12.4 hours), Phase 7 models this as a real, if still DRAFT, offering
|
||||
(`docs/OFFERING_AND_REGISTRATION_MODEL.md`'s worked example) — proving the platform CAN
|
||||
represent and operate it, without inventing dates, venue, price, or seat count that aren't
|
||||
already decided.
|
||||
|
||||
## Future memberships/subscriptions and institutional delivery
|
||||
|
||||
Recurring (self-paced subscriptions, after-school programs, school/community contracts) and
|
||||
institutional (schools, libraries, workforce partners) revenue channels are **FUTURE** — the
|
||||
`organizations`/`locations`/`offerings` model this phase builds is deliberately general enough
|
||||
to represent them later without a schema redesign, but no billing/subscription code exists or is
|
||||
planned this phase.
|
||||
|
||||
## BUILT NOW / PHASE 7 BUILT / FUTURE — summary
|
||||
|
||||
| Concept | Status |
|
||||
|---|---|
|
||||
| Programs, courses, ROOT_ V1 curriculum | BUILT NOW (Phases 1-6F) |
|
||||
| `programs.family` accepting `bootcamp` | PHASE 7 BUILT (schema widening only) |
|
||||
| Organizations, locations | PHASE 7 BUILT |
|
||||
| Guardians, student-guardian relationships | PHASE 7 BUILT |
|
||||
| Offerings, capacity, server-defined status transitions | PHASE 7 BUILT |
|
||||
| Curriculum packages (subset references) | PHASE 7 BUILT |
|
||||
| Registrations, registration lifecycle | PHASE 7 BUILT |
|
||||
| Agreement definitions/acceptances (record-only, no legal text) | PHASE 7 BUILT |
|
||||
| Admin routes for all of the above | PHASE 7 BUILT |
|
||||
| Admin UI (Offerings/Registrations screens) | **PHASE 8 BUILT** — `/admin` → Offerings, with launch readiness, roster, agreements, and per-registration detail (communications, agreement status). |
|
||||
| Guardian communications (5 new notification templates, guardian recipient fallback) | PHASE 8 BUILT |
|
||||
| Public offering read API + website registration submission contract | PHASE 8 BUILT (`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) |
|
||||
| Offering agreement requirements + conversion blocking | PHASE 8 BUILT |
|
||||
| Offering pricing, payment-provider abstraction, hosted checkout, verified webhook | **PHASE 9 BUILT** (`docs/PHASE9_COMMERCE_AND_LAUNCH.md`) — Stripe test-mode + a deterministic in-process test provider; real Stripe live-mode validation is pending real credentials. |
|
||||
| Agreement acceptance on the public registration contract | **PHASE 9 BUILT** — closes the gap Phase 8 left open. |
|
||||
| Refund/cancellation recording (commerce status only, never auto-cascading) | PHASE 9 BUILT |
|
||||
| Abandoned-reservation reaper, reservation expiry for paid offerings | PHASE 9 BUILT |
|
||||
| Admin payment UI (price, payment/refund status, no card data) | PHASE 9 BUILT |
|
||||
| A real website-side registration/checkout form (rootnodeacademy.com itself) | FUTURE — contract ready, form not built in this repo (separate repo, not RootNode-owned). |
|
||||
| Live Stripe validation (real credentials, real card, real webhook from Stripe's servers) | FUTURE — application layer is done and Stripe-test-mode-tested; pending real Stripe credentials. |
|
||||
| Subscriptions, membership billing, coupons, gift cards, installment plans, tax automation | FUTURE, explicitly out of scope |
|
||||
| INIT_/COMPILE_ curriculum | FUTURE |
|
||||
| School/library SIS integrations, parent portal, guardian login | FUTURE, explicitly out of scope |
|
||||
@@ -0,0 +1,72 @@
|
||||
# RootNode Academy Pricing Matrix V1
|
||||
|
||||
> **FOUNDER APPROVED — LOCKED FOR V1**
|
||||
|
||||
This document is the canonical V1 pricing decision for RootNode Academy. It was recovered from
|
||||
the synced RootNode Academy conversation after the original Phase 9.5 work was found to be
|
||||
absent from canonical Git history.
|
||||
|
||||
Prices below are in USD. A founding or early-bird price is a defined offer price, not a range.
|
||||
|
||||
## Seasonal camps
|
||||
|
||||
| Offering | Standard price | Launch price |
|
||||
|---|---:|---:|
|
||||
| Winter Break Camp | $449 | $399 — Founding Cohort |
|
||||
| Summer Camp | $499 | $449 — Early Bird |
|
||||
| Spring Break Camp | $449 | $399 — Early Bird |
|
||||
|
||||
## Instructor-led programs
|
||||
|
||||
| Offering | Standard price | Founding price |
|
||||
|---|---:|---:|
|
||||
| INIT_ Instructor-Led | $249 | $199 |
|
||||
| COMPILE_ Instructor-Led | $399 | $349 |
|
||||
| ROOT_ Foundations | $699 | $599 |
|
||||
| ROOT_ Kubernetes Core | $799 | $699 |
|
||||
| ROOT_ Operate & Secure | $899 | $799 |
|
||||
|
||||
## Self-paced
|
||||
|
||||
| Offering | Price | Access model |
|
||||
|---|---:|---|
|
||||
| ROOT_ Self-Paced | $299 | V1 one-time access |
|
||||
|
||||
No recurring subscription is approved or implemented for V1.
|
||||
|
||||
## Mentoring and workshops
|
||||
|
||||
| Offering | Price |
|
||||
|---|---:|
|
||||
| 1:1 Technical Mentoring — single hour | $100 |
|
||||
| 1:1 Technical Mentoring — four-hour package | $360 |
|
||||
| 1:1 Technical Mentoring — eight-hour package | $680 |
|
||||
| Technical Workshop | $149–$299 framework; price set per workshop |
|
||||
| School & Community Program | Custom quote |
|
||||
|
||||
The Technical Workshop range is an approved pricing framework, not one universal fixed price.
|
||||
|
||||
## Sibling policy
|
||||
|
||||
Each additional sibling receives 10% off tuition. This policy does not authorize or require a
|
||||
promotion engine in V1.
|
||||
|
||||
## Winter Break Camp launch terms
|
||||
|
||||
- Commercial label: **Founding Cohort**
|
||||
- Launch tuition: **$399 USD**
|
||||
- Application representation: **39900 USD cents**
|
||||
- Standard/reference tuition: **$449 USD** (`44900` cents)
|
||||
- Payment: paid in full
|
||||
- No deposit
|
||||
- No payment plan
|
||||
|
||||
Pricing approval does not resolve or authorize dates, venue, capacity, tax treatment,
|
||||
refund/cancellation policy, or legal agreement language. Those remain separate launch gates.
|
||||
|
||||
## Implementation authority
|
||||
|
||||
RootNode's application remains authoritative for offerings, eligibility, registration, and the
|
||||
server-side amount sent to Stripe. Stripe executes and records payment; it is not the canonical
|
||||
RootNode product catalog.
|
||||
|
||||
@@ -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,104 @@
|
||||
# Secrets & Infisical
|
||||
|
||||
Gate 3. The full, real secret inventory — derived by grepping every `process.env.*` reference
|
||||
across `platform/api/src`, `platform/gateway/src`, `platform/provisioner/src`, and
|
||||
`platform/lab-router/src` (not guessed, not copied from a template list) — and the current
|
||||
status of centralizing it in Infisical. **This document never contains a secret value.**
|
||||
|
||||
## Infisical project status — action required
|
||||
|
||||
Chase's self-hosted instance is `infisical.cedshomelab.com` (confirmed reachable, confirmed NOT
|
||||
`app.infisical.com` — the earlier Phase 9.1 attempt failed for exactly this reason: the existing
|
||||
machine identity is valid for the self-hosted instance only). Authenticated successfully this
|
||||
phase using the machine identity at `~/.config/infisical/identity.env`
|
||||
(`universal-auth`, org "Ceds HomeLab").
|
||||
|
||||
**Update, Phase 10.1**: Chase confirmed a "RootNode Academy" project already exists on this
|
||||
instance. Re-investigated with a proper API call rather than trusting the CLI's interactive
|
||||
picker alone — `GET /api/v2/organizations/{orgId}/workspaces` (the same authenticated identity,
|
||||
a direct, complete enumeration, not a scrollable TUI) returns **8** projects visible to this
|
||||
identity: Core Infrastructure, Kubernetes, Media Platform, Monitoring, Network Service,
|
||||
Privileged Access Manager, Storage, Synthos Systems. (Two more than Phase 10's original 6-project
|
||||
CLI capture, and with different membership — the CLI capture was evidently incomplete, not just
|
||||
under-scrolled; "Hermes Fleet," which appeared in the CLI list, doesn't appear in this
|
||||
authoritative API list at all.) **Still no "RootNode Academy" entry, under this identity.**
|
||||
|
||||
**Root cause, narrowed**: an identity's own workspace list in Infisical reflects only the
|
||||
projects it has been explicitly granted access to (a per-project ACL, separate from org
|
||||
membership) — an org owner's dashboard view would show every project in the org regardless of
|
||||
any one identity's grants. The most likely explanation is that the project exists, created by
|
||||
Chase, but the `claude-code-migration` machine identity was never added to its access list —
|
||||
not that the project doesn't exist. This session cannot tell the two apart without either
|
||||
org-owner API scope or the web dashboard, neither available here. **No duplicate project was
|
||||
created**, per Phase 10.1's explicit instruction.
|
||||
|
||||
**Owner action required — the fix, precisely:** in the Infisical dashboard
|
||||
(`https://infisical.cedshomelab.com`), open the RootNode Academy project → **Project Settings →
|
||||
Access → Machine Identities**, and add `claude-code-migration` (identity id
|
||||
`f3b42de1-11b6-4aa1-8f2e-7b85accec5fb`) with read/write access to whichever environments should
|
||||
be automatable. Once granted, a future session can list/read/write its secrets directly — no
|
||||
further CLI/API workaround needed. Until then, every secret continues to live only in local,
|
||||
gitignored `.env` files (development) — see `docs/PHASE10_PRODUCTION_DEPLOYMENT.md` for
|
||||
staging/production status.
|
||||
|
||||
## Secret inventory (real, from code — not from this prompt's guessed category list)
|
||||
|
||||
Every environment variable the application code actually reads, categorized. "Secret" = must
|
||||
never appear in git, logs, or a browser; "Config" = fine to template/document in plain text
|
||||
(already handled by `infra/environments/*/*.env.template`, unchanged by this phase).
|
||||
|
||||
| Variable | Category | Purpose | Consuming service | Environments | Rotation |
|
||||
|---|---|---|---|---|---|
|
||||
| `PLATFORM_DB_URL` | **Secret** (contains DB password) | Postgres connection string | Platform API | Dev/Staging/Prod (per-env DB, never shared) | On suspected compromise; routine rotation not yet scheduled |
|
||||
| `PROVISIONER_SHARED_SECRET` | **Secret** | Platform API ↔ Provisioner trust boundary (`X-Provisioner-Key`) | Platform API, Provisioner | Dev/Staging/Prod (distinct per env) | On suspected compromise |
|
||||
| `INTEGRATION_SHARED_SECRET` | **Secret** | Website backend ↔ Platform API trust boundary (`X-RootNode-Integration-Key`) | Platform API, website's backend (external) | Dev/Staging/Prod | On suspected compromise; the website team needs the current value out-of-band |
|
||||
| `GOOGLE_SERVICE_ACCOUNT_JSON` | **Secret** | Full GWS service-account credential (Gmail API, domain-wide delegation) | Platform API (`email/gws-provider.js`) | Prod only (dev/staging use the deterministic dev provider by default) | Per Google Cloud's own service-account key rotation practice |
|
||||
| `GOOGLE_DELEGATED_USER` | Config (an email address, not a secret) | Which real mailbox the service account impersonates via domain-wide delegation | Platform API | Prod | N/A |
|
||||
| `EMAIL_FROM_ADDRESS` / `EMAIL_FROM_NAME` / `EMAIL_REPLY_TO` | Config | Sender identity — `[email protected]` | Platform API | All | N/A |
|
||||
| `STRIPE_SECRET_KEY` | **Secret** | Stripe API authentication (checkout creation, refunds, retrieval) | Platform API (`payment-provider.js`) | Dev/Staging = TEST key; Production = LIVE key — **never mixed**, see Gate 8 status below | Per Stripe's own key-rotation guidance; rotate immediately if ever exposed |
|
||||
| `STRIPE_WEBHOOK_SECRET` | **Secret** | Verifies a webhook delivery actually came from Stripe | Platform API (`webhook-routes.js`) | One per registered webhook endpoint (TEST vs. LIVE endpoints each have their own) | Regenerates automatically if the endpoint is recreated |
|
||||
| `TEST_PAYMENT_WEBHOOK_SECRET` | Low-sensitivity (dev-only, never real money) | Shared secret for the deterministic `TestProvider`'s fake webhook signature | Platform API | Dev/test only | N/A |
|
||||
| `LAB_ROUTER_TLS_KEY` | **Secret** | Lab Host Router's mTLS private key | Lab Host Router | Staging/Prod (dev uses a self-signed pair generated locally) | Per the mTLS cert lifecycle already documented in Phase 4.1 |
|
||||
| `LAB_ROUTER_TLS_CERT` / `LAB_ROUTER_TLS_CA` | Sensitive but not strictly secret (public cert material) | mTLS certificate/CA chain | Lab Host Router, Gateway | Staging/Prod | Same lifecycle as the key above |
|
||||
| `PLATFORM_DB_URL`'s host component, ports, timeouts, session TTLs, `PAYMENT_PROVIDER`, `COOKIE_SECURE`, `TRUST_PROXY`, `PUBLIC_APP_ORIGIN`, `DNS_RESOLVER`, `NO_PROXY_CIDRS`, etc. | Config | Plain operational configuration | Various | All, per-environment values | N/A |
|
||||
|
||||
Every "Config" row is already handled the existing way — plain values in `infra/environments/
|
||||
*/*.env.template` (checked into git, safe) or `docker-compose.dev.yml`'s `${VAR:-default}`
|
||||
substitutions. Only the "Secret" rows above need an Infisical home.
|
||||
|
||||
## Environment separation (Stripe — the one category with real launch consequences)
|
||||
|
||||
- **Development/Staging**: `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` must be **TEST** values
|
||||
only (`sk_test_...`/a `stripe listen`-issued or TEST-mode dashboard `whsec_...`). This is what
|
||||
Phase 9.1 validated end to end.
|
||||
- **Production**: `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` must be **LIVE** values
|
||||
(`sk_live_...`, and a permanent LIVE-mode dashboard webhook endpoint's own signing secret).
|
||||
**Not configured — Chase has not provided live Stripe credentials, and this phase did not ask
|
||||
for or receive them** (per Phase 10's own stop condition: "real money could move" /
|
||||
"identity verification requires Chase" — live Stripe activation requires Chase's own Stripe
|
||||
business/identity verification, not something this session can do on his behalf). See `docs/
|
||||
PHASE10_LAUNCH_READINESS.md` Gate 8 status.
|
||||
|
||||
## Secret hygiene performed this phase
|
||||
|
||||
- Confirmed (again, freshly) that no real secret value has ever entered git history (`docs/
|
||||
PHASE10_CANONICAL_STATE_AUDIT.md` §1.6).
|
||||
- Confirmed `platform/.env`'s Stripe fields are correctly blank (not left set from Phase 9.1's
|
||||
validation session) — matches this repo's own "unset after a deliberate session" convention.
|
||||
- **Not yet performed** (blocked on the missing Infisical project): migrating any real secret
|
||||
value out of local `.env` files and into Infisical. Per this phase's own instruction, a local
|
||||
credential is never deleted until its Infisical replacement is verified — nothing was deleted.
|
||||
|
||||
## What Chase needs to do
|
||||
|
||||
1. In the existing "RootNode Academy" project's **Project Settings → Access → Machine
|
||||
Identities**, add `claude-code-migration` (id `f3b42de1-11b6-4aa1-8f2e-7b85accec5fb`) with
|
||||
read/write access to whichever environments should be automatable. **Do not create a second
|
||||
project** — this one-line access grant is the fix, per Phase 10.1's investigation.
|
||||
2. Confirm the project's environment names (Phase 10.1 could not see them without access) —
|
||||
reuse whatever exists rather than renaming to force a Development/Staging/Production match,
|
||||
per this repo's own "existing structure wins" instruction.
|
||||
3. Once access is granted, a future session can populate every "Secret" row above from the
|
||||
current local `platform/.env`/`platform/provisioner/.env` values for Development, and Chase
|
||||
provides the real Staging/Production values (Stripe LIVE keys especially) directly when ready
|
||||
for those environments — never invented or guessed by this phase.
|
||||
@@ -0,0 +1,143 @@
|
||||
# Stripe Integration Runbook
|
||||
|
||||
Operational reference for RootNode's payment-provider abstraction (`platform/api/src/
|
||||
payment-provider.js`) — configuration, local testing against both the deterministic test
|
||||
provider and real Stripe test mode, webhook setup, and what's left before a real, live-money
|
||||
launch. See `docs/PHASE9_COMMERCE_AND_LAUNCH.md` for the design/architecture story and
|
||||
`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the website's own contract.
|
||||
|
||||
## The two providers
|
||||
|
||||
| | `PAYMENT_PROVIDER=test` (default) | `PAYMENT_PROVIDER=stripe` |
|
||||
|---|---|---|
|
||||
| Network calls | None — fully in-process | Real calls to Stripe's API |
|
||||
| Needs credentials | No | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` |
|
||||
| Checkout URL | A fake URL pointing back at your own `success_url` | A real Stripe-hosted Checkout page |
|
||||
| Webhook signature | A static shared secret (`TEST_PAYMENT_WEBHOOK_SECRET`, header `X-Test-Payment-Signature`) | Real Stripe HMAC signature (header `Stripe-Signature`), verified via Stripe's own SDK |
|
||||
| Real money | Never | Real in live mode; fake (test cards) in Stripe's own test mode |
|
||||
| Used by | Every automated test/rehearsal in this repo | Nothing yet — pending real credentials |
|
||||
|
||||
`commerce.js` and every route above it call the SAME methods regardless of which provider is
|
||||
active (`createCheckoutSession`, `verifyAndParseWebhook`) — switching providers is an environment
|
||||
variable, never a code change.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `PAYMENT_PROVIDER` | `test` | `test` or `stripe`. |
|
||||
| `STRIPE_SECRET_KEY` | unset | Required for `stripe`. Server-side only — never exposed to any browser-served code. |
|
||||
| `STRIPE_WEBHOOK_SECRET` | unset | Required for `stripe`. The signing secret Stripe gives you for a specific webhook endpoint (dashboard or `stripe listen`, see below) — NOT your API secret key. |
|
||||
| `TEST_PAYMENT_WEBHOOK_SECRET` | `test-payment-secret-not-for-production` | Only used by the `test` provider. Change if you need to isolate parallel test runs; otherwise the default is fine. |
|
||||
| `REGISTRATION_RESERVATION_TIMEOUT_MINUTES` | `30` | How long a paid offering's seat stays reserved before the reaper releases it if checkout is never completed. |
|
||||
| `RESERVATION_REAPER_POLL_MS` | `60000` | How often the reservation reaper checks for expired, unpaid reservations. |
|
||||
|
||||
None of these belong in `frontend/` or anywhere a browser can read them — the Stripe secret key
|
||||
and webhook secret are server-only, exactly like `INTEGRATION_SHARED_SECRET`/
|
||||
`PROVISIONER_SHARED_SECRET` already are in this codebase.
|
||||
|
||||
## Local testing against the test provider (no Stripe account needed)
|
||||
|
||||
This is what every e2e suite and rehearsal in this repo actually runs against. Leave
|
||||
`PAYMENT_PROVIDER` unset (defaults to `test`) and drive the full cycle directly:
|
||||
|
||||
```bash
|
||||
# 1. Create a checkout session (server-to-server, same as the website would)
|
||||
curl -s -X POST http://localhost:4200/api/integrations/registrations/<id>/checkout \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-RootNode-Integration-Key: dev-integration-secret-not-for-production' \
|
||||
-d '{"success_url":"https://example.com/s","cancel_url":"https://example.com/c"}'
|
||||
|
||||
# 2. Look up the provider_reference the checkout just created
|
||||
docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform \
|
||||
-c "SELECT provider_reference FROM payment_transactions WHERE registration_id = '<id>' ORDER BY created_at DESC LIMIT 1;"
|
||||
|
||||
# 3. Build and POST a fake "payment succeeded" webhook event (Node, using the same provider
|
||||
# module the real server uses — see platform/test/e2e-phase9.js for the full pattern)
|
||||
node -e "
|
||||
const { getPaymentProvider } = require('./platform/api/src/payment-provider');
|
||||
const p = getPaymentProvider();
|
||||
const event = p.buildTestEvent({ providerReference: '<provider_reference from step 2>', eventType: 'checkout.session.completed' });
|
||||
console.log(JSON.stringify(event));
|
||||
"
|
||||
curl -s -X POST http://localhost:4200/api/webhooks/payment \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "X-Test-Payment-Signature: <event.signature from above>" \
|
||||
-d '<event.body from above>'
|
||||
```
|
||||
|
||||
To simulate a **failed payment** or **abandoned checkout**, use `eventType:
|
||||
'payment_intent.payment_failed'` or `'checkout.session.expired'` in step 3 instead — see
|
||||
`platform/test/e2e-phase9.js` for both, and for the reservation-reaper path (never send a webhook
|
||||
at all; wait past `REGISTRATION_RESERVATION_TIMEOUT_MINUTES`, or lower it for a faster local
|
||||
test).
|
||||
|
||||
## Local testing against real Stripe TEST mode
|
||||
|
||||
**Exercised for real in Phase 9.1** — a real Stripe TEST Checkout, a real TEST payment, a real
|
||||
signed webhook, and the full registration→enrollment conversion were all proven this way; see
|
||||
`docs/PHASE9_1_STRIPE_TEST_VALIDATION.md` for the full result and the one real defect it
|
||||
exposed and fixed. `platform/test/stripe-integration-test.js` automates most of the steps below
|
||||
— set `RUN_STRIPE_INTEGRATION_TEST=true` plus `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET`/
|
||||
`OWNER_EMAIL`/`OWNER_PASSWORD` and run it; it refuses to run against a live-mode key and makes
|
||||
zero Stripe network calls unless explicitly enabled. Manually, or to understand what that script
|
||||
does:
|
||||
|
||||
1. Create (or use an existing) Stripe account, switch to **Test mode** in the dashboard.
|
||||
2. Copy the test-mode **Secret key** (`sk_test_...`) into `STRIPE_SECRET_KEY`.
|
||||
3. Install the [Stripe CLI](https://stripe.com/docs/stripe-cli) (a prebuilt binary from
|
||||
https://github.com/stripe/stripe-cli/releases works with no `apt`/sudo needed — this is how
|
||||
Phase 9.1 installed it, to `~/.local/bin/stripe`) and run:
|
||||
```bash
|
||||
stripe listen --api-key sk_test_... --forward-to localhost:4200/api/webhooks/payment
|
||||
```
|
||||
No `stripe login` needed when passing `--api-key` directly (useful in a headless/agent
|
||||
session with no browser to complete an OAuth flow). The CLI prints a webhook signing secret
|
||||
(`whsec_...`) — set that as `STRIPE_WEBHOOK_SECRET` for the duration of the local session
|
||||
(it's ephemeral per `stripe listen` run; a real deployment instead creates a permanent
|
||||
webhook endpoint in the Stripe dashboard and uses ITS signing secret). Add `--print-secret`
|
||||
to retrieve just the secret without leaving the listener running.
|
||||
4. Set `PAYMENT_PROVIDER=stripe` and restart the API.
|
||||
5. Drive the checkout flow through steps 2-4 of `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` — you
|
||||
now get back a REAL `checkout_url`. Open it in a browser and pay with a Stripe test card
|
||||
(`4242 4242 4242 4242`, any future expiry, any CVC, any ZIP).
|
||||
6. `stripe listen` forwards Stripe's real webhook event to your local server; watch the server
|
||||
log and `payment_webhook_events` table for `processed`.
|
||||
7. To simulate a failure without a real failing card, use Stripe's documented test card numbers
|
||||
for declines (e.g. `4000 0000 0000 0002`), or `stripe trigger payment_intent.payment_failed`.
|
||||
|
||||
This is the recommended path to real validation before going live — it exercises the actual
|
||||
Stripe SDK, actual signature verification, and actual Stripe event delivery, with zero real
|
||||
money at risk.
|
||||
|
||||
## Going live (real money)
|
||||
|
||||
1. Switch the Stripe dashboard to **Live mode**, generate a live **Secret key**
|
||||
(`sk_live_...`), and create a permanent webhook endpoint pointed at the production API's
|
||||
`/api/webhooks/payment` URL — copy ITS signing secret (`whsec_...`, different from test mode's).
|
||||
2. Set `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (live values) and `PAYMENT_PROVIDER=stripe` in
|
||||
production configuration only — never commit these to git, never put them in a `.env` file
|
||||
that gets version-controlled (matching this repo's existing secret-handling convention for
|
||||
`PROVISIONER_SHARED_SECRET`/`INTEGRATION_SHARED_SECRET`).
|
||||
3. Set a real price on the live offering (`PATCH /api/admin/offerings/:id`) and confirm
|
||||
`GET /api/admin/offerings/:id/readiness` shows `payment_provider: ready`.
|
||||
4. Run one real, small, genuinely-refundable transaction against a real card before opening
|
||||
registration publicly, and confirm the full pipeline (checkout → webhook → confirmation →
|
||||
enrollment → activation email) end to end, then refund it via the admin UI or the Stripe
|
||||
dashboard directly.
|
||||
5. Monitor `rootnode_payment_webhooks_total`/`rootnode_payments_total` (Prometheus metrics, see
|
||||
`docs/PHASE9_COMMERCE_AND_LAUNCH.md`) after opening registration.
|
||||
|
||||
**This phase does not perform steps 1-4 above** — no real Stripe credentials were provided, and
|
||||
inventing/obtaining them is a business decision reserved for Chase, not something this phase
|
||||
invents. Everything up to this runbook is built and tested against the test provider; live
|
||||
validation is the one explicitly pending item.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---|---|
|
||||
| `503 PROVIDER_NOT_CONFIGURED` on checkout | `PAYMENT_PROVIDER=stripe` but `STRIPE_SECRET_KEY` unset, or the `stripe` npm package failed to load. |
|
||||
| `400 invalid_signature` on every real Stripe webhook | `STRIPE_WEBHOOK_SECRET` doesn't match the endpoint Stripe actually delivered to (test-mode secret used against a live endpoint, or vice versa — each `stripe listen`/dashboard endpoint has its OWN secret). |
|
||||
| Webhook received but nothing changes | Check `payment_webhook_events.result` for that event id — `duplicate` means it was already processed (expected on a retry); check server logs for an exception if `result` is missing entirely. |
|
||||
| Seat never releases after an abandoned checkout | Confirm the reservation reaper is running (`RESERVATION_REAPER_POLL_MS` log line on server startup) and that `REGISTRATION_RESERVATION_TIMEOUT_MINUTES` hasn't been set unreasonably high. |
|
||||
@@ -0,0 +1,81 @@
|
||||
# Student & Guardian Data Model
|
||||
|
||||
Phase 7 Step 28 — why student and guardian identities are kept separate, what data-minimization
|
||||
principles govern the new `guardians`/`student_guardians` tables, and what still needs
|
||||
professional/legal review before any of this touches a real minor's data in production.
|
||||
|
||||
## Why separate identities
|
||||
|
||||
A student's own identity (`students` table — `email`/`login_identifier`, unchanged) is their
|
||||
academic account. A guardian (`guardians`, new this phase) is a real, separate identity: a
|
||||
parent with three enrolled children is **one** guardian row linked to three students via
|
||||
`student_guardians`, not a guardian email copy-pasted into three student records. This mirrors
|
||||
exactly the reasoning `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` already gave for keeping
|
||||
`contact_email` off `students.email` — a contact address is not an academic identity, and now a
|
||||
guardian is a real relationship, not a workaround field.
|
||||
|
||||
## What this phase deliberately did NOT build
|
||||
|
||||
- **No Parent Portal.** A guardian record has no login, no password, no session — it cannot
|
||||
authenticate into anything yet. Building that is a real, separate future decision.
|
||||
- **No child-activity surveillance.** Nothing in this schema or its routes lets a guardian (once
|
||||
they could log in, in some future phase) see a student's Lab activity, scenario attempts, or
|
||||
detailed learning analytics by default. A guardian relationship is a contact/authorization
|
||||
record, not a monitoring grant.
|
||||
- **No automatic broad access.** `student_guardians.can_receive_communications` and
|
||||
`is_primary_contact` govern notification targeting only — wired into `notifications.js`'s
|
||||
`resolveRecipient()` as of Phase 8 (a communications-enabled primary guardian is the LAST
|
||||
fallback, tried only after the student's own email and any enrollment-level `contact_email` —
|
||||
see `docs/REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md`), never
|
||||
authorization to student data.
|
||||
|
||||
## Data minimization
|
||||
|
||||
Only fields with a genuine, near-term operational purpose were added: `display_name`, `email`,
|
||||
`phone`, and a free-text `notes` field for a guardian; `relationship`
|
||||
(`parent`/`guardian`/`authorized_contact`/`emergency_contact`), `is_primary_contact`, and
|
||||
`can_receive_communications` for the relationship itself. Registration-time fields
|
||||
(`readiness_notes`, `accessibility_notes`, `emergency_contact_name`/`emergency_contact_phone`)
|
||||
were chosen because a real camp/cohort genuinely needs them operationally — **no medical-record
|
||||
functionality was built**, and `accessibility_notes` is a free-text operational note (e.g. "uses
|
||||
a screen reader," "needs extra Lab time"), never a structured health/diagnosis field.
|
||||
|
||||
## RBAC
|
||||
|
||||
Guardian records and registration detail (readiness/accessibility/emergency-contact notes) are
|
||||
admin/owner-only routes — a plain instructor's existing cohort-scoped access to a student's
|
||||
academic progress is unchanged, but they do not automatically see guardian contact information
|
||||
or registration notes through the new routes. See `admin-offerings-routes.js`'s own RBAC
|
||||
comment and `platform/test/e2e-phase7.js`'s guardian-RBAC test.
|
||||
|
||||
## Adult vs. minor enrollment
|
||||
|
||||
An adult learner registers and is their own `registrant_email`/eventual student identity — no
|
||||
guardian row is created or required. A minor's registration requires a `guardian_id` (enforced
|
||||
by a database CHECK constraint on `offering_registrations`, not just application logic). Neither
|
||||
path writes a guardian's email into `students.email` — a minor with no email of their own still
|
||||
gets `login_identifier`-based identity (the same mechanism `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`
|
||||
already established), preserving the identity semantics Phase 5A/5C set up.
|
||||
|
||||
## Retention — flagged, not decided
|
||||
|
||||
This phase does not implement a data-retention or deletion policy for guardian/registration
|
||||
records. **Requires future legal/business review**: how long registration data (especially
|
||||
accessibility/emergency-contact notes for a minor) should be retained after an offering
|
||||
completes or a registration is cancelled, and what a guardian's/family's data-deletion request
|
||||
process should look like.
|
||||
|
||||
## Items explicitly requiring professional/legal review before production use with real minors
|
||||
|
||||
- Whether the current field set (name/email/phone/relationship) is sufficient or whether
|
||||
jurisdiction-specific requirements (e.g. COPPA-adjacent consent language, state-specific camp/
|
||||
minor-supervision regulations) require additional structured fields.
|
||||
- The actual legal text of any participation agreement, media consent, or liability waiver —
|
||||
`agreement_definitions.body_summary` is a plain-language placeholder for admin-UI display
|
||||
only, never a substitute for attorney-reviewed legal language.
|
||||
- Data retention/deletion policy for guardian and registration records.
|
||||
- Whether `emergency_contact_name`/`emergency_contact_phone` need to be encrypted at rest beyond
|
||||
Postgres's existing storage (no additional encryption was added this phase).
|
||||
|
||||
None of the above is resolved by this phase's work — they are named here specifically so they
|
||||
are not silently assumed away.
|
||||
@@ -171,10 +171,185 @@ email — otherwise the enrollment is created but nobody is notified automatical
|
||||
instructor has to relay the activation link manually. `contact_email` is stored only against
|
||||
that specific enrollment, never copied into the student's own identity record.
|
||||
|
||||
There is no guardian/parent account system yet — the enrollment is created directly against the
|
||||
student identity you provide. If you need to support "a parent manages multiple children's
|
||||
enrollments" as a first-class concept later, talk to the RootNode team before building around a
|
||||
workaround; the schema has room for it but it isn't implemented.
|
||||
**As of Phase 7, a real guardian/parent record now exists** (`guardians`/`student_guardians` —
|
||||
see `docs/STUDENT_GUARDIAN_DATA_MODEL.md`), but this endpoint's contract is UNCHANGED and does
|
||||
not yet create or link one — `student.contact_email` still works exactly as described above,
|
||||
stored only on the enrollment. If you need "a parent manages multiple children's enrollments" as
|
||||
a first-class concept in this endpoint, talk to the RootNode team before building around a
|
||||
workaround; the schema now has the real tables for it, they're just not wired into this
|
||||
endpoint yet.
|
||||
|
||||
## Offerings and registrations (Phase 8 — live)
|
||||
|
||||
As of Phase 8, RootNode represents specific purchasable deliveries — camps, cohorts, self-paced
|
||||
access — as first-class **offerings**, with a **registration** step before final academic
|
||||
enrollment (see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). This section covers the real, live
|
||||
contract; the endpoint documented above (`/api/integrations/enrollments`, direct program
|
||||
enrollment) is completely unaffected and still works exactly as described.
|
||||
|
||||
### 1. List/view offerings (public, no auth)
|
||||
|
||||
```
|
||||
GET /api/public/offerings
|
||||
GET /api/public/offerings/:slug
|
||||
```
|
||||
|
||||
No `X-RootNode-Integration-Key` needed — this is a public, read-only endpoint, safe to call
|
||||
directly from a browser. Only `published`/`enrollment_open`/`full`/`in_progress` offerings ever
|
||||
appear; `draft` and `cancelled` never do. Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "winter-linux-systems-camp-2026",
|
||||
"name": "Winter Break Linux & Systems Foundations Camp",
|
||||
"description": "A hands-on introduction to Linux administration and networking.",
|
||||
"program_id": "root-kubernetes-foundations",
|
||||
"delivery_mode": "camp",
|
||||
"recommended_min_age": 13,
|
||||
"recommended_max_age": null,
|
||||
"starts_at": "2026-12-15T14:00:00.000Z",
|
||||
"ends_at": "2026-12-19T18:00:00.000Z",
|
||||
"registration_open": true,
|
||||
"availability": "limited",
|
||||
"location_name": "Community Room B",
|
||||
"location_type": "community_center",
|
||||
"price_amount": 24900,
|
||||
"price_currency": "usd",
|
||||
"requires_payment": true
|
||||
}
|
||||
```
|
||||
|
||||
`availability` is `"open"` / `"limited"` / `"full"` — never a raw seat count (avoids inviting
|
||||
scraping/timing games against a public endpoint). No internal notes, no organization contact
|
||||
info, no instructor identity — just enough to build a pricing/availability display. **As of Phase
|
||||
9**: `price_amount` is an integer in minor currency units (cents — `24900` = $249.00), `null` for
|
||||
a free offering; `price_currency` is a lowercase ISO code (`"usd"`); `requires_payment` is a
|
||||
plain derived boolean (`price_amount !== null`) so the website never has to reimplement that
|
||||
check. The actual checkout amount is always read server-side from this same offering row at
|
||||
checkout-creation time — the website never sends an amount anywhere in this contract.
|
||||
|
||||
### 2. Select an offering, then submit a registration (server-to-server)
|
||||
|
||||
```
|
||||
POST /api/integrations/registrations
|
||||
Header: X-RootNode-Integration-Key: <the same secret /api/integrations/enrollments already uses>
|
||||
```
|
||||
|
||||
Same trust boundary as the existing enrollment endpoint (Step 15) — server-to-server only, never
|
||||
called from browser JS, the secret never leaves your backend's own configuration.
|
||||
|
||||
**Adult example request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "reg_9f8e7d6c5b4a",
|
||||
"source": "website",
|
||||
"offering_slug": "winter-linux-systems-camp-2026",
|
||||
"registrant": { "display_name": "Jamie Rivera", "email": "[email protected]", "is_minor": false }
|
||||
}
|
||||
```
|
||||
|
||||
**Minor/guardian example request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "reg_1a2b3c4d5e6f",
|
||||
"source": "website",
|
||||
"offering_slug": "winter-linux-systems-camp-2026",
|
||||
"registrant": { "display_name": "Riley Rivera", "is_minor": true },
|
||||
"guardian": { "display_name": "Jamie Rivera", "email": "[email protected]", "phone": "555-0100" }
|
||||
}
|
||||
```
|
||||
|
||||
**Success response** (`201` new, `200` idempotent replay):
|
||||
|
||||
```json
|
||||
{ "registration_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "offering_id": "winter-linux-systems-camp-2026", "status": "started", "created": true }
|
||||
```
|
||||
|
||||
**Errors**: `401 unauthorized` (bad/missing key), `400` (`offering_slug`/`event_id`/registrant
|
||||
fields missing, or a minor with no guardian email), `404 OFFERING_NOT_FOUND`, `409` for
|
||||
`OFFERING_NOT_OPEN` / `OFFERING_CANCELLED` / `OFFERING_COMPLETED` / `OFFERING_FULL` /
|
||||
`REGISTRATION_NOT_YET_OPEN` / `REGISTRATION_CLOSED`.
|
||||
|
||||
### 3. Duplicate/retry semantics (idempotent, DB-enforced)
|
||||
|
||||
Identical to `/api/integrations/enrollments`: send the exact same `event_id` again (a retried
|
||||
webhook, a timed-out request retried by your own backend) and you get back the **same
|
||||
registration**, `created: false`, never a duplicate, never a second seat reserved — enforced by
|
||||
a real unique index on `event_id`, safe under concurrent/parallel retries too. Never generate a
|
||||
new `event_id` for a retry of the same real-world submission.
|
||||
|
||||
### 4. Adult flow
|
||||
|
||||
Send `registrant.email`, omit `guardian` entirely, `is_minor: false` (or omit it). The adult IS
|
||||
both the eventual student and the registration contact — no guardian record is created.
|
||||
|
||||
### 5. Minor/guardian flow
|
||||
|
||||
Send `registrant.is_minor: true` and a real `guardian` object (`display_name` + `email`
|
||||
required; `phone` optional). RootNode matches an existing guardian by email first (a returning
|
||||
family doesn't get a duplicate guardian record per sibling/per camp) or creates a new one. The
|
||||
guardian's email is never written to the eventual student's own identity record.
|
||||
|
||||
### 6. Agreement flow (Phase 9 — live)
|
||||
|
||||
```
|
||||
POST /api/integrations/registrations/:id/agreements
|
||||
Body: { "agreement_id": "winter-camp-waiver-2026" }
|
||||
```
|
||||
|
||||
Same shared-secret header as every other route in this section. Which party accepts is
|
||||
server-derived from the registration's own `is_minor`, never a value you send: an adult accepts
|
||||
as themselves (before their student account even exists yet), a minor's registration is accepted
|
||||
by the guardian already on file from submission. `201 { "accepted": true, "agreement_id": ...,
|
||||
"accepted_version": ... }` on success; `404 AGREEMENT_NOT_FOUND` if the id doesn't match a real
|
||||
agreement configured on this offering. **Checkout is blocked (`409 AGREEMENTS_INCOMPLETE`) until
|
||||
every required agreement is accepted** — call this before calling checkout below.
|
||||
|
||||
### 7. Checkout (Phase 9 — live)
|
||||
|
||||
```
|
||||
POST /api/integrations/registrations/:id/checkout
|
||||
Body: { "success_url": "https://rootnodeacademy.com/enroll/success", "cancel_url": "https://rootnodeacademy.com/enroll/cancelled" }
|
||||
```
|
||||
|
||||
Server-to-server only — the browser never calls this directly, and never supplies a price. The
|
||||
amount, currency, and product description all come from the offering's own server-side
|
||||
configuration. Success: `201 { "checkout_url": "...", "transaction_id": "..." }` — redirect the
|
||||
customer's browser to `checkout_url` (RootNode's configured payment provider's own hosted
|
||||
checkout page; today that's Stripe Checkout in test mode, or a fake URL from the deterministic
|
||||
test provider in local dev — see `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`). A free offering
|
||||
(`price_amount: null`) returns `409 NO_PRICE_CONFIGURED` — never call this for a free offering;
|
||||
just proceed straight to confirmation. Full error table, the success-page design (never trust a
|
||||
returning `?paid=true`), and the status-lookup endpoint are in `docs/
|
||||
WEBSITE_REGISTRATION_AND_CHECKOUT.md`.
|
||||
|
||||
### 8. Payment confirmation — a verified webhook, never a browser redirect
|
||||
|
||||
RootNode's own webhook (not something the website calls) is the ONLY thing that confirms
|
||||
payment. Once confirmed, the registration walks forward to `confirmed` and converts to a real
|
||||
enrollment automatically — reusing the exact activation-email flow described below, no separate
|
||||
"payment succeeded" email exists. See `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` for how to
|
||||
build a safe success page against this (poll the status-lookup endpoint; never derive success
|
||||
from the URL alone).
|
||||
|
||||
### 9. Confirmation and enrollment activation
|
||||
|
||||
Once `confirmed` (via the webhook above for a priced offering, or a manual admin transition for
|
||||
a free one) and every required agreement is accepted, the registration converts to a real
|
||||
enrollment — creating or matching the real student account and reusing the exact same
|
||||
activation-email machinery `/api/integrations/enrollments` already triggers. No second
|
||||
confirmation path and no second activation path exist.
|
||||
|
||||
### 10. What this endpoint does NOT do
|
||||
|
||||
No payment-card data (never sent, never has a field for it — see `docs/
|
||||
WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the full payment-security posture). No password
|
||||
(RootNode issues its own credential/activation flow). No arbitrary registration status
|
||||
(server-defined transitions only — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). All example
|
||||
data above is fake — do not
|
||||
reuse these values against a real deployment.
|
||||
|
||||
## What the student experiences after you call this (as of Phase 5C)
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# Website → RootNode Registration & Checkout
|
||||
|
||||
Audience: whoever builds `rootnodeacademy.com`'s registration/checkout flow for a paid offering
|
||||
(Winter Break Camp and anything after it). This is the Phase 9 companion to
|
||||
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md` — read that document first for the shared trust
|
||||
boundary, authentication header, and the base `/api/integrations/registrations` contract. This
|
||||
document covers everything Phase 9 adds on top: pricing display, the agreement step, checkout,
|
||||
the success page, and status lookup.
|
||||
|
||||
## The end-to-end flow
|
||||
|
||||
```
|
||||
1. GET /api/public/offerings/:slug — display program, price, availability (no auth, browser-safe)
|
||||
2. POST /api/integrations/registrations — submit the registration (server-to-server)
|
||||
3. POST /api/integrations/registrations/:id/agreements — accept the required agreement(s)
|
||||
4. POST /api/integrations/registrations/:id/checkout — create a hosted checkout session
|
||||
5. redirect the customer's browser to the returned checkout_url
|
||||
6. RootNode's webhook confirms payment — NOT something the website calls
|
||||
7. GET /api/integrations/registrations/:id/status — poll from the success page
|
||||
```
|
||||
|
||||
Steps 2-4 and 7 are server-to-server only, using the same `X-RootNode-Integration-Key` header as
|
||||
every other integration route. Steps 1 and 5 are the only things that ever touch the customer's
|
||||
browser directly.
|
||||
|
||||
## 1. Price and availability display
|
||||
|
||||
```
|
||||
GET /api/public/offerings/:slug
|
||||
```
|
||||
|
||||
No auth needed. As of Phase 9, the response includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"price_amount": 24900,
|
||||
"price_currency": "usd",
|
||||
"requires_payment": true
|
||||
}
|
||||
```
|
||||
|
||||
`price_amount` is an integer in minor currency units (cents) — divide by 100 and format with
|
||||
`price_currency` for display (`$249.00`). `price_amount: null` means a free offering —
|
||||
`requires_payment` is `false` and you should skip steps 3/4/5 entirely (call `checkout` on a free
|
||||
offering and you'll get `409 NO_PRICE_CONFIGURED`; there's nothing to check out).
|
||||
|
||||
**This price is display-only.** The actual amount charged always comes from the SAME offering
|
||||
row, read server-side at checkout-creation time (step 4) — there is no field anywhere in this
|
||||
contract for the website to submit an amount. A browser (or a compromised website backend)
|
||||
cannot pay a different price than what's configured on the offering.
|
||||
|
||||
## 2. Submit the registration
|
||||
|
||||
Unchanged from `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` §2 — adult or minor/guardian flow, same
|
||||
`event_id` idempotency. You'll get back a `registration_id`; hold onto it for every step below.
|
||||
|
||||
## 3. Accept the agreement(s)
|
||||
|
||||
```
|
||||
POST /api/integrations/registrations/:id/agreements
|
||||
Header: X-RootNode-Integration-Key: <secret>
|
||||
Body: { "agreement_id": "winter-camp-waiver-2026" }
|
||||
```
|
||||
|
||||
Ask RootNode for the current `agreement_id`(s) configured on the offering — there is no
|
||||
"list required agreements" endpoint on this public contract yet; if your flow needs to render the
|
||||
agreement text/checkbox dynamically, ask the RootNode team before building a workaround.
|
||||
|
||||
Which party is recorded as accepting is **server-derived from the registration's own
|
||||
`is_minor`** (set at submission in step 2) — you never send who's accepting. `201` on success:
|
||||
|
||||
```json
|
||||
{ "accepted": true, "agreement_id": "winter-camp-waiver-2026", "accepted_version": 1 }
|
||||
```
|
||||
|
||||
`404 AGREEMENT_NOT_FOUND` if the id is wrong or not configured on this offering.
|
||||
|
||||
**Checkout is blocked until every required agreement is accepted** (`409 AGREEMENTS_INCOMPLETE`
|
||||
from step 4). Call this before checkout, always.
|
||||
|
||||
## 4. Create the checkout session
|
||||
|
||||
```
|
||||
POST /api/integrations/registrations/:id/checkout
|
||||
Header: X-RootNode-Integration-Key: <secret>
|
||||
Body: { "success_url": "https://rootnodeacademy.com/enroll/success", "cancel_url": "https://rootnodeacademy.com/enroll/cancelled" }
|
||||
```
|
||||
|
||||
Both URLs are **yours** — RootNode's payment provider redirects the customer's browser back to
|
||||
one of them after checkout. `success_url` and `cancel_url` are required; nothing else is. There
|
||||
is no `amount` field — see "Price and availability display" above.
|
||||
|
||||
Success (`201`):
|
||||
|
||||
```json
|
||||
{ "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_...", "transaction_id": "..." }
|
||||
```
|
||||
|
||||
Redirect the customer's browser to `checkout_url` immediately (a `302`, or a client-side
|
||||
`window.location`). In local/staging use with the deterministic test provider (see
|
||||
`docs/STRIPE_INTEGRATION_RUNBOOK.md`), `checkout_url` is a fake URL pointing straight back at
|
||||
your own `success_url` with a `?test_session=...` param appended — there is no real hosted page
|
||||
to visit, which is fine for automated testing but means you can't manually click through a
|
||||
"real" checkout page against the test provider.
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | `error` | Meaning |
|
||||
|---|---|---|
|
||||
| `401` | `unauthorized` | Missing/wrong integration key. |
|
||||
| `400` | `VALIDATION` | `success_url`/`cancel_url` missing. |
|
||||
| `404` | `NOT_FOUND` / `OFFERING_NOT_FOUND` | Bad registration id, or its offering vanished. |
|
||||
| `409` | `NO_PRICE_CONFIGURED` | This offering is free — don't call checkout for it. |
|
||||
| `409` | `AGREEMENTS_INCOMPLETE` | Call step 3 first. |
|
||||
| `409` | `INVALID_STATE` | The registration is already past the point where checkout makes sense (e.g. already confirmed). |
|
||||
| `409` | `OFFERING_NOT_OPEN` / `OFFERING_CANCELLED` / `OFFERING_COMPLETED` / `OFFERING_FULL` / `REGISTRATION_NOT_YET_OPEN` / `REGISTRATION_CLOSED` | The offering/registration window state changed since step 2. |
|
||||
| `503` | `PROVIDER_NOT_CONFIGURED` | RootNode's payment provider isn't configured yet — a RootNode-side problem, not yours. Show a generic "checkout is temporarily unavailable" message and alert RootNode. |
|
||||
|
||||
## 5. Payment confirmation — never trust the redirect
|
||||
|
||||
**RootNode's own webhook is the only thing that ever confirms payment.** When the payment
|
||||
provider redirects the customer back to your `success_url`, that redirect proves nothing on its
|
||||
own — a customer can reload, bookmark, or manually construct that URL without ever paying, and a
|
||||
successful redirect can race ahead of the webhook actually being processed. **Never render "you're
|
||||
enrolled" or grant anything based on the URL/query string alone.**
|
||||
|
||||
## 6. Building a safe success page
|
||||
|
||||
```
|
||||
GET /api/integrations/registrations/:id/status
|
||||
Header: X-RootNode-Integration-Key: <secret>
|
||||
```
|
||||
|
||||
Call this from your OWN backend (never expose the integration key to the browser) when the
|
||||
customer lands on your success page, and poll it every couple of seconds for a short window (the
|
||||
webhook is normally processed within a second or two of the customer completing checkout — see
|
||||
`docs/STRIPE_INTEGRATION_RUNBOOK.md` for real-world Stripe delivery latency). Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"registration_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd",
|
||||
"status": "confirmed",
|
||||
"payment_status": "paid",
|
||||
"enrollment_activated": false
|
||||
}
|
||||
```
|
||||
|
||||
Deliberately minimal — no name, no email, no amount, no provider reference. Enough to render one
|
||||
of three states on your success page:
|
||||
|
||||
- **`payment_status: "paid"` (or `status: "enrollment_activated"`)** — "You're all set — check
|
||||
your email for your RootNode Academy activation link."
|
||||
- **`payment_status: "pending"` or missing, `status` still pre-confirmation** — "Processing your
|
||||
payment — this page will update automatically" (keep polling, or tell the customer to check
|
||||
back / check email shortly).
|
||||
- **`payment_status: "failed"`** — "Your payment didn't go through. You can try again;" — for a
|
||||
priced offering, a failed payment does NOT release the seat, so the SAME registration can
|
||||
retry checkout (back to step 4) without losing its place.
|
||||
|
||||
A cancelled/expired checkout (customer closed the tab, or the provider's own session timeout)
|
||||
shows up as `status: "cancelled"` once RootNode's reservation reaper or the provider's own
|
||||
"expired" webhook processes it — at that point the seat has been released and the customer needs
|
||||
to start a new registration if they still want in.
|
||||
|
||||
`401` (bad/missing key) and `404 NOT_FOUND` (bad registration id) are the only error responses.
|
||||
|
||||
## 7. What you must NOT do
|
||||
|
||||
- Never store, log, or transmit card numbers, CVVs, or any other payment-card data — this
|
||||
contract has no field for it anywhere, on purpose. RootNode never touches card data either;
|
||||
Stripe's hosted Checkout page collects it directly.
|
||||
- Never send an `amount`/`price` in the checkout request — there is no such field, and if you
|
||||
add one to your own request body it is silently ignored (the price always comes from the
|
||||
offering).
|
||||
- Never build your own "payment succeeded" email — RootNode's activation/confirmation email
|
||||
fires automatically on webhook-confirmed conversion (same machinery `docs/
|
||||
WEBSITE_ENROLLMENT_INTEGRATION.md` already documents). A second email from your side would
|
||||
duplicate or conflict with it.
|
||||
- Never poll the status endpoint indefinitely/aggressively — a reasonable few-second interval
|
||||
for a short window (a minute or two) covers real webhook latency; beyond that, tell the
|
||||
customer to check their email and stop polling.
|
||||
|
||||
## Accessibility and loading-state notes
|
||||
|
||||
- The redirect to `checkout_url` in step 4 is a full navigation, not a modal — treat it like any
|
||||
other outbound link (announce the navigation to assistive tech, don't trap focus expecting a
|
||||
return).
|
||||
- The success page in step 6 has three real states to design for (processing/confirmed/failed),
|
||||
not two — see step 6's own state list. Design a genuine "still processing" state; don't leave
|
||||
the customer looking at a page that appears frozen while you poll.
|
||||
- Every checkout/agreement error in the tables above has a distinct, human-meaningful cause —
|
||||
map each to a real, specific message rather than one generic "something went wrong."
|
||||
|
||||
## Test-mode flow (what to actually test against)
|
||||
|
||||
Local/staging integration testing uses `PAYMENT_PROVIDER=test` (RootNode's own deterministic
|
||||
provider, no Stripe account needed) — see `docs/STRIPE_INTEGRATION_RUNBOOK.md` for how to drive a
|
||||
full checkout → webhook → confirmation cycle against it, including simulating a failed payment
|
||||
and an abandoned checkout. Once RootNode has real Stripe credentials, staging can switch to
|
||||
`PAYMENT_PROVIDER=stripe` in Stripe's OWN test mode (fake test cards, e.g. `4242 4242 4242 4242`)
|
||||
for a true end-to-end rehearsal before anything goes live with real money.
|
||||
|
||||
## Privacy note
|
||||
|
||||
The only personal data this contract ever sees is what `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`
|
||||
already documents for registration (names, emails, phone) — nothing payment-specific is added.
|
||||
Whatever billing/contact information Stripe's hosted Checkout collects from the customer (name,
|
||||
email, card) stays with Stripe; RootNode never receives or stores it beyond the transaction
|
||||
metadata `docs/PHASE9_COMMERCE_AND_LAUNCH.md` describes (amount, currency, status, timestamps,
|
||||
provider reference — never card data).
|
||||
@@ -0,0 +1,92 @@
|
||||
# Winter Break Camp Operations
|
||||
|
||||
Phase 8 §29 (payment step updated in Phase 9) — how to actually run the Winter Break Linux &
|
||||
Systems Foundations Camp (`docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md` Option A) using the
|
||||
platform built in Phases 7-9, end to end, entirely from `/admin`. As of Phase 9, real
|
||||
checkout/payment (Stripe test mode today, live mode once real credentials exist) is part of this
|
||||
flow — see `docs/PHASE9_COMMERCE_AND_LAUNCH.md` and `docs/WINTER_BREAK_LAUNCH_CHECKLIST.md`.
|
||||
|
||||
## What already exists vs. what Chase still fills in
|
||||
|
||||
**Already representable, real, tested:**
|
||||
- The curriculum package (ROOT 01-02, referencing the real, unmodified courses).
|
||||
- The offering itself, in DRAFT, with delivery mode `camp`, and a real price.
|
||||
- Registration → guardian/adult → agreement → real checkout → verified webhook → confirmation →
|
||||
enrollment → cohort → activation → classroom → real Lab work, proven end to end in
|
||||
`infra/scripts/drills/test-phase9-winter-camp-commerce.js` (13/13 passing, test payment
|
||||
provider, no live money) — plus failed-payment retry, abandoned-checkout seat release, and a
|
||||
last-seat checkout race, none of which existed in Phase 8's own rehearsal.
|
||||
|
||||
**Still genuinely unknown, deliberately left editable, never invented by this phase:**
|
||||
- Exact dates, venue, real capacity, and price (the price FIELD is real and editable; the actual
|
||||
dollar amount to charge is still Chase's call).
|
||||
- The actual agreement text (a plain-language placeholder only — see `docs/
|
||||
STUDENT_GUARDIAN_DATA_MODEL.md`'s legal-review list).
|
||||
- Whether registration opens before or after a specific marketing push.
|
||||
- Real Stripe credentials — until they exist, the platform runs on the deterministic test
|
||||
provider, functionally complete but not yet validated against Stripe's own live systems. See
|
||||
`docs/STRIPE_INTEGRATION_RUNBOOK.md`.
|
||||
|
||||
## The operational sequence
|
||||
|
||||
1. **Create the location** — `/admin` → Offerings → (via the offering create form's location
|
||||
dropdown) or directly if a location admin surface is added later; today, an admin/owner uses
|
||||
`POST /api/admin/locations` (documented in `docs/OFFERING_ADMIN_GUIDE.md`) once the real venue
|
||||
is known.
|
||||
2. **Create the curriculum package** — `winter-linux-systems-v1`, referencing
|
||||
`root-01-computing-linux-foundations` and `root-02-linux-administration-networking` in
|
||||
sequence. This step is a one-time setup — the same package can back every future Winter Break
|
||||
Camp offering.
|
||||
3. **Create the cohort** and **the offering** — delivery mode `camp`, referencing the program,
|
||||
the curriculum package, and (once known) the location and capacity.
|
||||
4. **Assign the lead instructor** — both at the offering level (`instructor_offerings`, Phase 7)
|
||||
and the cohort level (`cohort_instructors`, unchanged since Phase 2A) — the offering detail
|
||||
page's own instructor step, or the two admin routes directly.
|
||||
5. **Set the price** — `PATCH /api/admin/offerings/:id` with `price_amount`/`price_currency`
|
||||
(or leave `price_amount` unset for a free offering). Confirm `PAYMENT_PROVIDER` is configured
|
||||
(`test` for rehearsal, `stripe` with real credentials for a live launch — see `docs/
|
||||
STRIPE_INTEGRATION_RUNBOOK.md`).
|
||||
6. **Configure the required agreement(s)** — create the agreement definition (title + plain-
|
||||
language summary) and link it to the offering. **The actual legal text still needs
|
||||
professional review before this camp is opened to real families** — see below.
|
||||
7. **Check launch readiness** — the offering detail page's readiness panel should show every
|
||||
check green before publishing, including the new `price` and `payment_provider` checks. If
|
||||
instructor/curriculum/agreements/price are configured but dates/location/capacity aren't
|
||||
final yet, readiness will honestly show that.
|
||||
8. **Publish, then open registration** — `draft → published → enrollment_open`. Only once
|
||||
`enrollment_open` does the offering appear in the public offerings API and accept real
|
||||
registrations.
|
||||
9. **Registrations arrive** — via the website integration (contract ready, see
|
||||
`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`) or entered directly by an admin for a phone/
|
||||
in-person sign-up.
|
||||
10. **Guardian/adult completes the agreement, then checkout** — the agreement acceptance and
|
||||
checkout-creation endpoints are both live (Phase 9); checkout is blocked until the agreement
|
||||
is accepted.
|
||||
11. **Payment confirmation** — a verified, idempotent webhook (never a browser redirect) is what
|
||||
actually confirms payment and walks the registration to `confirmed`. A failed payment leaves
|
||||
the registration retriable without releasing the seat; an abandoned checkout or an expired,
|
||||
unpaid reservation releases it.
|
||||
12. **Convert to enrollment** — happens automatically on payment confirmation for a priced
|
||||
offering (or a manual admin transition for a free one) — creates the real student account
|
||||
(or matches an existing one), the real academic enrollment, and real cohort membership.
|
||||
13. **Account activation** — the student's (or, for a minor, the guardian's) real one-time
|
||||
activation token, delivered the same way every other RootNode activation email already is.
|
||||
14. **Classroom day** — Prepare Labs for the cohort, start the classroom session, students join
|
||||
and work through real ROOT 01-02 content and Labs.
|
||||
15. **Offering completion** — `in_progress → completed` once the camp concludes.
|
||||
|
||||
## Known blockers before this can actually open to the public
|
||||
|
||||
- Real dates, venue, capacity, and the actual dollar price are not yet decided (this document
|
||||
does not invent them — the platform fields to hold them are real and ready).
|
||||
- The agreement text requires professional legal review (see `docs/
|
||||
STUDENT_GUARDIAN_DATA_MODEL.md`).
|
||||
- Real Stripe credentials do not exist yet — the commerce pipeline is built and tested against
|
||||
the deterministic test provider only; see `docs/STRIPE_INTEGRATION_RUNBOOK.md` for going live.
|
||||
- The website's own registration/checkout UI is now built (rootnodeacademy.com, a separate
|
||||
repository not owned by this one) against `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`'s
|
||||
contract — but it has nothing to talk to yet, since `ROOTNODE_API_URL`/
|
||||
`ROOTNODE_INTEGRATION_KEY` point at no real Platform API (none is deployed — see `docs/
|
||||
PHASE10_PRODUCTION_DEPLOYMENT.md`). Its own real pricing (`docs/
|
||||
ROOTNODE_PRICING_AND_OFFERS_V1.md`) has already been published on the live site.
|
||||
- See `docs/WINTER_BREAK_LAUNCH_CHECKLIST.md` for the full go/no-go list.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Winter Break Camp — Launch Checklist
|
||||
|
||||
The single go/no-go list for opening the Winter Break Linux & Systems Foundations Camp
|
||||
(`docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md` Option A) to real public registration. Every
|
||||
item is tagged **COMPLETE**, **BLOCKED**, **DECISION REQUIRED**, or **MANUAL OWNER ACTION** —
|
||||
see `docs/PHASE10_LAUNCH_READINESS.md` for the full evidence matrix behind each tag. Nothing on
|
||||
this list is invented by the platform — every non-COMPLETE item is a real decision, asset, or
|
||||
piece of hardware that has to come from Chase, a legal reviewer, or Stripe, not from more code.
|
||||
|
||||
- [x] **Canonical repo reconciled** — COMPLETE. `docs/PHASE10_CANONICAL_STATE_AUDIT.md`: this
|
||||
workstation's HEAD, Gitea's `origin/main`, and every claimed milestone SHA all verified
|
||||
consistent. No stranded branches/tags on the remote.
|
||||
- [ ] **Second-PC state reconciled** — DECISION REQUIRED (verification only, not a code
|
||||
blocker). Gitea shows no evidence of stranded work, but this session cannot see the other
|
||||
workstation directly — run the commands in `docs/PHASE10_CANONICAL_STATE_AUDIT.md` §1.3
|
||||
there. **Confirmed with Chase (Phase 10.1): the real Phase 9.5 pricing document lives on
|
||||
PC #2, unpushed** — bringing that to Gitea would also close this item.
|
||||
- [ ] **Secrets centralized (Infisical)** — MANUAL OWNER ACTION, one step. The "RootNode
|
||||
Academy" project does exist (per Chase) — Phase 10.1's re-investigation (a proper API
|
||||
enumeration, not just the CLI picker) found the `claude-code-migration` machine identity
|
||||
simply isn't listed as having access to it. Grant it access via Project Settings → Access
|
||||
→ Machine Identities. See `docs/SECRETS_AND_INFISICAL.md` for the exact identity id and
|
||||
the full secret inventory ready to migrate in once granted.
|
||||
- [ ] **Production server** — BLOCKED. Not purchased/provisioned — verified via empty
|
||||
`terraform.tfvars`/no state file and placeholder `group_vars/production.yml`. See `docs/
|
||||
PHASE10_PRODUCTION_DEPLOYMENT.md`.
|
||||
- [ ] **Production VMs (rn-platform-01 / rn-lab-host-01)** — BLOCKED. Depends on the server above.
|
||||
- [x] **Firewall/isolation (code)** — COMPLETE. `docs/PHASE10_SECURITY_AUDIT.md` — RFC1918
|
||||
egress denial, mTLS wiring, firewall Ansible role all present and unchanged from Phase 4/
|
||||
4.1; cannot be verified against a live host until one exists.
|
||||
- [ ] **Production database** — BLOCKED. Depends on the server above. Migrations themselves
|
||||
reconfirmed idempotent this phase (37/37, both a fresh apply and a same-DB restart).
|
||||
- [x] **DNS (marketing site)** / [ ] **DNS (Platform API)** — PARTIAL. `rootnodeacademy.com` is
|
||||
live and real (Cloudflare-fronted, confirmed this phase); no subdomain exists yet for the
|
||||
Platform API itself — needs a real server first.
|
||||
- [x] **HTTPS (marketing site)** / [ ] **HTTPS (Platform API)** — PARTIAL, same split as DNS.
|
||||
- [x] **Monitoring (code)** — COMPLETE. `/healthz`/`/metrics` live and exercised in every
|
||||
regression run this phase; deployment itself needs the production server.
|
||||
- [x] **Backups (code)** / [ ] **Backups (executed)** — PARTIAL. Ansible `backup` role exists,
|
||||
never run against a real host.
|
||||
- [ ] **Restore test** — BLOCKED. Never executed — no target exists. A backup that has never
|
||||
been restore-tested does not count as launch-ready.
|
||||
- [x] **Stripe TEST validation** — COMPLETE. A real Stripe TEST Checkout Session, a real TEST
|
||||
payment at the actual $399.00 price, a genuine signed webhook, the existing
|
||||
registration→enrollment conversion, a real duplicate-delivery proof, a real automated
|
||||
abandoned-checkout proof, and a real refund with the non-cascade rule reconfirmed — all
|
||||
proven end to end in Phase 9.1 (`docs/PHASE9_1_STRIPE_TEST_VALIDATION.md`). One real
|
||||
defect (webhook-event correlation) was found and fixed as a direct result.
|
||||
- [ ] **Stripe LIVE configuration** — BLOCKED. Not configured, not attempted — requires Chase's
|
||||
own Stripe account business/identity verification (only Chase can do this) plus real LIVE
|
||||
credentials placed in Infisical (blocked on the missing project above), never invented or
|
||||
substituted by this phase.
|
||||
- [ ] **Live webhook configuration** — BLOCKED. Depends on LIVE credentials and a real HTTPS
|
||||
Platform API endpoint, neither of which exist yet.
|
||||
- [x] **Google Workspace** — COMPLETE. `[email protected]`, `gmail.send` scope,
|
||||
domain-wide delegation — validated in Phase 5C (`docs/
|
||||
RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md`); deployment to production needs the real server.
|
||||
- [x] **Website integration** — COMPLETE, both sides. Full contract tested end to end on the
|
||||
platform side, including against real Stripe. The website itself (a separate repository,
|
||||
`ced4568/RootNode-academy-website` on GitHub — not this repo) now has the actual
|
||||
registration/agreement/checkout/status flow built against that contract, and its own
|
||||
pricing has been corrected to match `docs/ROOTNODE_PRICING_AND_OFFERS_V1.md`. It has
|
||||
nothing to talk to yet — `ROOTNODE_API_URL`/`ROOTNODE_INTEGRATION_KEY` are unset until a
|
||||
real Platform API exists (every route fails a clean 503, never a 500, in the meantime).
|
||||
- [x] **Winter Break price** — COMPLETE. **$399.00 launch ("Founding Cohort") / $449.00
|
||||
standard reference, paid in full, no deposit, no payment plan** — founder-approved by
|
||||
Chase directly, validated against real Stripe TEST mode at this exact amount, reconfirmed
|
||||
current in Phase 10.1. Note: `rootnodeacademy.com` itself currently shows different,
|
||||
stale founding-price RANGES (not this fixed figure) — Chase confirmed this repo's price is
|
||||
the correct one; updating the live site is separate, not-RootNode-owned work.
|
||||
- [ ] **Winter Break dates** — DECISION REQUIRED. Chase's call, not invented.
|
||||
- [ ] **Venue** — DECISION REQUIRED. Chase's call (or confirm fully online/remote), not invented.
|
||||
- [ ] **Capacity** — DECISION REQUIRED. Chase's call, not invented.
|
||||
- [ ] **Agreements (legal text)** — BLOCKED. `agreement_definitions.body_summary` remains a
|
||||
plain-language placeholder; no attorney-approved text exists for any required category
|
||||
(program terms, guardian consent, participation waiver, acceptable-use/lab rules,
|
||||
cancellation acknowledgment, privacy notice). See `docs/PHASE10_LAUNCH_READINESS.md` Gate
|
||||
12 for the full taxonomy — none of it was authored or treated as approved by this phase.
|
||||
- [ ] **Refund/cancellation policy** — DECISION REQUIRED. The platform's mechanical behavior
|
||||
(record, never auto-cascade into registration/enrollment status) is real and proven
|
||||
against actual Stripe money movement; the customer-facing POLICY text is Chase's decision.
|
||||
- [ ] **Tax decision** — DECISION REQUIRED. No tax treatment is configured anywhere in this
|
||||
codebase (Phase 9's own explicit "no complex tax automation" boundary) — this is not an
|
||||
implicit "no tax owed" determination, it requires Chase or a professional to actually
|
||||
decide.
|
||||
- [ ] **Production offering** — BLOCKED. Cannot legitimately open registration until dates/
|
||||
venue/capacity/agreements/refund policy/tax are all resolved — the mechanism itself
|
||||
(`PATCH /api/admin/offerings/:id`, transition to `enrollment_open`) is ready the moment
|
||||
they are.
|
||||
- [ ] **Instructor assignment** — MANUAL OWNER ACTION. Mechanism ready
|
||||
(`instructor_offerings`); Chase assigns the real instructor once the real offering exists.
|
||||
- [x] **Roster process** — COMPLETE. Rehearsed end to end (classroom session, real Lab task,
|
||||
instructor visibility) in this phase's dress rehearsal, 13/13.
|
||||
- [x] **Student activation** — COMPLETE. Proven real twice — Phase 9.1's real-Stripe run and
|
||||
this phase's deterministic rehearsal.
|
||||
- [x] **Lab capacity (mechanism)** / [ ] **Lab capacity (real sizing)** — PARTIAL. Race-safe
|
||||
capacity logic is proven; real-world capacity planning for actual concurrent students
|
||||
needs the real server's actual specs, unknown until Gate "production server" resolves.
|
||||
- [x] **Complete dress rehearsal** — COMPLETE (deterministic). 13/13 this phase, against
|
||||
current `main`. Cannot yet be repeated against the REAL Winter Break offering because that
|
||||
offering cannot legitimately open (dates/venue/capacity/agreements/policy/tax all pending).
|
||||
|
||||
## Final go/no-go
|
||||
|
||||
## PHASE 10 PARTIAL / WINTER BREAK LAUNCH BLOCKED
|
||||
|
||||
Registration must not open to the public until every BLOCKED and DECISION REQUIRED item above
|
||||
is resolved. Every COMPLETE item has real evidence behind it, not a self-assessment — see
|
||||
`docs/PHASE10_LAUNCH_READINESS.md`'s full matrix. **Even once every gate above passes, this
|
||||
platform will not open registration on its own — that action requires Chase's explicit,
|
||||
separate authorization, given directly, not inferred from a checklist being green.**
|
||||
@@ -10,6 +10,7 @@ import LearningView from './LearningView'
|
||||
import AssessmentsView from './AssessmentsView'
|
||||
import ClassroomView from './ClassroomView'
|
||||
import ProgramsView from './ProgramsView'
|
||||
import OfferingsView from './OfferingsView'
|
||||
import NotificationsView from './NotificationsView'
|
||||
import CurriculumView from './CurriculumView'
|
||||
import styles from './Admin.module.css'
|
||||
@@ -25,6 +26,7 @@ const NAV = [
|
||||
{ key: 'cohorts', label: 'Cohorts' },
|
||||
{ key: 'students', label: 'Students' },
|
||||
{ key: 'programs', label: 'Programs' },
|
||||
{ key: 'offerings', label: 'Offerings' },
|
||||
{ key: 'curriculum', label: 'Curriculum' },
|
||||
{ key: 'classroom', label: 'Classroom' },
|
||||
{ key: 'learning', label: 'Learning' },
|
||||
@@ -68,6 +70,7 @@ export default function AdminPortal() {
|
||||
{view === 'cohorts' && <CohortsView role={instructor.role} />}
|
||||
{view === 'students' && <StudentsView role={instructor.role} />}
|
||||
{view === 'programs' && <ProgramsView role={instructor.role} />}
|
||||
{view === 'offerings' && <OfferingsView role={instructor.role} />}
|
||||
{view === 'curriculum' && <CurriculumView />}
|
||||
{view === 'classroom' && <ClassroomView />}
|
||||
{view === 'learning' && <LearningView />}
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Phase 8 §1-§5 — the Offerings operations console: list/create/detail, launch readiness,
|
||||
// registrations/roster, and agreement requirements. Authoring (create/transition/agreement
|
||||
// requirements) is admin/owner only, matching every other authoring surface in this app
|
||||
// (ProgramsView/AssessmentsView's own precedent) — canAuthor gates writes only, not viewing.
|
||||
const OFFERING_TRANSITIONS = {
|
||||
draft: ['published', 'cancelled'],
|
||||
published: ['enrollment_open', 'cancelled'],
|
||||
enrollment_open: ['full', 'in_progress', 'cancelled'],
|
||||
full: ['enrollment_open', 'in_progress', 'cancelled'],
|
||||
in_progress: ['completed', 'cancelled'],
|
||||
completed: [],
|
||||
cancelled: [],
|
||||
}
|
||||
|
||||
function statusPillClass(status) {
|
||||
if (['enrollment_open', 'in_progress', 'completed'].includes(status)) return styles.pillOk
|
||||
if (status === 'full') return styles.pillWarn
|
||||
if (status === 'cancelled') return styles.pillErr
|
||||
return styles.pillMuted
|
||||
}
|
||||
|
||||
export default function OfferingsView({ role }) {
|
||||
const [offerings, setOfferings] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const canAuthor = role === 'owner' || role === 'admin'
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.offerings().then(setOfferings).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <OfferingDetail offeringId={selected} canAuthor={canAuthor} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{canAuthor && (
|
||||
<div className={styles.card}>
|
||||
<h2>Create offering</h2>
|
||||
<CreateOfferingForm onCreated={(id) => { reload(); setSelected(id) }} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.card}>
|
||||
<h2>Offerings</h2>
|
||||
{!offerings ? <p className={styles.muted}>Loading…</p> : offerings.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Program</th><th>Delivery</th><th>Status</th><th>Seats</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{offerings.map((o) => (
|
||||
<tr key={o.offering_id}>
|
||||
<td>{o.name}</td>
|
||||
<td className={styles.muted}>{o.program_id}</td>
|
||||
<td className={styles.muted}>{o.delivery_mode.replace('_', ' ')}</td>
|
||||
<td><span className={`${styles.pill} ${statusPillClass(o.status)}`}>{o.status.replace('_', ' ')}</span></td>
|
||||
<td className={styles.muted}>{o.capacity === null ? 'unlimited' : `${o.reserved_seats}/${o.capacity}`}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(o.offering_id)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateOfferingForm({ onCreated }) {
|
||||
const [programs, setPrograms] = useState([])
|
||||
const [locations, setLocations] = useState([])
|
||||
const [form, setForm] = useState({ offering_id: '', slug: '', name: '', program_id: '', delivery_mode: 'camp', capacity: '', location_id: '', recommended_min_age: '', recommended_max_age: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.programs().then(setPrograms).catch(() => {})
|
||||
adminApi.locations().then(setLocations).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const offering = await adminApi.createOffering({
|
||||
offeringId: form.offering_id, slug: form.slug, name: form.name, programId: form.program_id, deliveryMode: form.delivery_mode,
|
||||
capacity: form.capacity === '' ? undefined : Number(form.capacity),
|
||||
locationId: form.location_id || undefined,
|
||||
recommendedMinAge: form.recommended_min_age === '' ? undefined : Number(form.recommended_min_age),
|
||||
recommendedMaxAge: form.recommended_max_age === '' ? undefined : Number(form.recommended_max_age),
|
||||
})
|
||||
setForm({ offering_id: '', slug: '', name: '', program_id: '', delivery_mode: 'camp', capacity: '', location_id: '', recommended_min_age: '', recommended_max_age: '' })
|
||||
onCreated(offering.offering_id)
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Offering ID (slug-like)</label>
|
||||
<input className={styles.input} value={form.offering_id} onChange={(e) => setForm({ ...form, offering_id: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>URL slug</label>
|
||||
<input className={styles.input} value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Name</label>
|
||||
<input className={styles.input} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></div>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Program</label>
|
||||
<select className={styles.select} value={form.program_id} onChange={(e) => setForm({ ...form, program_id: e.target.value })} required>
|
||||
<option value="">Select a program…</option>
|
||||
{programs.map((p) => <option key={p.program_id} value={p.program_id}>{p.name}</option>)}
|
||||
</select></div>
|
||||
<div><label className={styles.label}>Delivery mode</label>
|
||||
<select className={styles.select} value={form.delivery_mode} onChange={(e) => setForm({ ...form, delivery_mode: e.target.value })}>
|
||||
{['self_paced', 'cohort', 'hybrid', 'camp', 'workshop', 'school', 'after_school', 'homeschool', 'community', 'one_to_one'].map((m) => <option key={m} value={m}>{m.replace('_', ' ')}</option>)}
|
||||
</select></div>
|
||||
<div><label className={styles.label}>Capacity (blank = unlimited)</label>
|
||||
<input className={styles.input} type="number" min="1" value={form.capacity} onChange={(e) => setForm({ ...form, capacity: e.target.value })} /></div>
|
||||
<div><label className={styles.label}>Location (optional)</label>
|
||||
<select className={styles.select} value={form.location_id} onChange={(e) => setForm({ ...form, location_id: e.target.value })}>
|
||||
<option value="">None / online</option>
|
||||
{locations.map((l) => <option key={l.location_id} value={l.location_id}>{l.name}</option>)}
|
||||
</select></div>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Recommended min age (guidance only)</label>
|
||||
<input className={styles.input} type="number" value={form.recommended_min_age} onChange={(e) => setForm({ ...form, recommended_min_age: e.target.value })} /></div>
|
||||
<div><label className={styles.label}>Recommended max age (guidance only)</label>
|
||||
<input className={styles.input} type="number" value={form.recommended_max_age} onChange={(e) => setForm({ ...form, recommended_max_age: e.target.value })} /></div>
|
||||
</div>
|
||||
<p className={styles.muted}>Age fields are display guidance only — never an access gate. Dates, registration window, and organization can be set later from the offering's own detail page.</p>
|
||||
<button className={styles.btn} style={{ marginTop: '0.5rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create draft offering'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function OfferingDetail({ offeringId, canAuthor, onBack }) {
|
||||
const [offering, setOffering] = useState(null)
|
||||
const [readiness, setReadiness] = useState(null)
|
||||
const [registrations, setRegistrations] = useState(null)
|
||||
const [agreementReqs, setAgreementReqs] = useState(null)
|
||||
const [allAgreements, setAllAgreements] = useState([])
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [selectedReg, setSelectedReg] = useState(null)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.offering(offeringId).then(setOffering).catch((e) => setError(e.message))
|
||||
adminApi.offeringReadiness(offeringId).then(setReadiness).catch(() => {})
|
||||
adminApi.offeringRegistrations(offeringId).then(setRegistrations).catch(() => {})
|
||||
adminApi.offeringAgreementRequirements(offeringId).then(setAgreementReqs).catch(() => {})
|
||||
adminApi.agreements().then(setAllAgreements).catch(() => {})
|
||||
}, [offeringId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const withBusy = (fn) => async () => {
|
||||
setBusy(true); setError(null)
|
||||
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (selectedReg) return <RegistrationDetail registrationId={selectedReg} canAuthor={canAuthor} onBack={() => { setSelectedReg(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to offerings</button>
|
||||
{!offering ? <p className={styles.muted}>Loading…</p> : (
|
||||
<>
|
||||
<h2 style={{ margin: '1rem 0' }}>{offering.name} <span className={`${styles.pill} ${statusPillClass(offering.status)}`}>{offering.status.replace('_', ' ')}</span></h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Overview</h3>
|
||||
<p className={styles.muted}>{offering.description || 'No description.'}</p>
|
||||
<p>Program: {offering.program_id} — Delivery: {offering.delivery_mode.replace('_', ' ')} — Seats: {offering.capacity === null ? 'unlimited' : `${offering.reserved_seats} / ${offering.capacity}`}</p>
|
||||
{canAuthor && (OFFERING_TRANSITIONS[offering.status] || []).length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
{(OFFERING_TRANSITIONS[offering.status] || []).map((next) => (
|
||||
<button key={next} className={styles.btnSecondary} style={{ marginRight: '0.5rem' }} disabled={busy}
|
||||
onClick={withBusy(() => adminApi.transitionOffering(offeringId, next))}>
|
||||
{next === 'cancelled' ? 'Cancel offering' : `Move to ${next.replace('_', ' ')}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Pricing</h3>
|
||||
<p>{offering.price_amount === null ? <span className={styles.muted}>Free — no checkout required.</span> : <>{(offering.price_amount / 100).toFixed(2)} {offering.price_currency.toUpperCase()}</>}</p>
|
||||
{canAuthor && <SetPriceForm offeringId={offeringId} current={offering} onSaved={reload} />}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Launch readiness</h3>
|
||||
{!readiness ? <p className={styles.muted}>Loading…</p> : (
|
||||
<>
|
||||
<p><span className={`${styles.pill} ${readiness.ready_to_open_registration ? styles.pillOk : styles.pillWarn}`}>
|
||||
{readiness.ready_to_open_registration ? 'Ready to open registration' : 'Not yet ready'}
|
||||
</span></p>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
{readiness.checks.map((c) => (
|
||||
<li key={c.key} className={styles.muted}>{c.ready ? '✓' : '✗'} {c.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Required agreements</h3>
|
||||
{!agreementReqs ? <p className={styles.muted}>Loading…</p> : agreementReqs.length === 0 ? <p className={styles.muted}>None configured — registrations can convert without any agreement acceptance.</p> : (
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
{agreementReqs.map((a) => <li key={a.agreement_id} className={styles.muted}>{a.title} (v{a.version}) {a.requires_guardian_for_minor && <span className={styles.pill}>guardian required for minors</span>}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{canAuthor && <AddAgreementRequirementForm offeringId={offeringId} allAgreements={allAgreements} onAdded={reload} />}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Registrations / roster</h3>
|
||||
{canAuthor && <CreateRegistrationForm offeringId={offeringId} onCreated={reload} />}
|
||||
{!registrations ? <p className={styles.muted}>Loading…</p> : registrations.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Registrant</th><th>Type</th><th>Status</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{registrations.map((r) => (
|
||||
<tr key={r.registration_id}>
|
||||
<td>{r.registrant_display_name}</td>
|
||||
<td className={styles.muted}>{r.is_minor ? 'minor (guardian)' : 'adult'}</td>
|
||||
<td><span className={`${styles.pill} ${r.status === 'enrollment_activated' ? styles.pillOk : r.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{r.status.replace('_', ' ')}</span></td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelectedReg(r.registration_id)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateRegistrationForm({ offeringId, onCreated }) {
|
||||
const [form, setForm] = useState({ registrant_display_name: '', registrant_email: '', is_minor: false, guardian_display_name: '', guardian_email: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
let guardianId
|
||||
if (form.is_minor) {
|
||||
guardianId = `guardian-${Date.now()}`
|
||||
await adminApi.createGuardian({ guardianId, displayName: form.guardian_display_name || form.guardian_email, email: form.guardian_email })
|
||||
}
|
||||
await adminApi.createRegistration(offeringId, {
|
||||
registrantDisplayName: form.registrant_display_name, registrantEmail: form.registrant_email || undefined,
|
||||
isMinor: form.is_minor, guardianId,
|
||||
})
|
||||
setForm({ registrant_display_name: '', registrant_email: '', is_minor: false, guardian_display_name: '', guardian_email: '' })
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ marginBottom: '1rem', paddingBottom: '1rem', borderBottom: '1px solid var(--border)' }}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Registrant name</label>
|
||||
<input className={styles.input} value={form.registrant_display_name} onChange={(e) => setForm({ ...form, registrant_display_name: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Registrant type</label>
|
||||
<select className={styles.select} value={form.is_minor ? 'minor' : 'adult'} onChange={(e) => setForm({ ...form, is_minor: e.target.value === 'minor' })}>
|
||||
<option value="adult">Adult (self-registering)</option>
|
||||
<option value="minor">Minor (guardian registers)</option>
|
||||
</select></div>
|
||||
{!form.is_minor && (
|
||||
<div><label className={styles.label}>Registrant email</label>
|
||||
<input className={styles.input} type="email" value={form.registrant_email} onChange={(e) => setForm({ ...form, registrant_email: e.target.value })} /></div>
|
||||
)}
|
||||
</div>
|
||||
{form.is_minor && (
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Guardian name</label>
|
||||
<input className={styles.input} value={form.guardian_display_name} onChange={(e) => setForm({ ...form, guardian_display_name: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Guardian email</label>
|
||||
<input className={styles.input} type="email" value={form.guardian_email} onChange={(e) => setForm({ ...form, guardian_email: e.target.value })} required /></div>
|
||||
</div>
|
||||
)}
|
||||
<button className={styles.btn} disabled={busy || !form.registrant_display_name.trim()}>{busy ? 'Registering…' : 'Add registration'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function SetPriceForm({ offeringId, current, onSaved }) {
|
||||
const [amount, setAmount] = useState(current.price_amount === null ? '' : (current.price_amount / 100).toFixed(2))
|
||||
const [currency, setCurrency] = useState(current.price_currency || 'usd')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await adminApi.updateOffering(offeringId, {
|
||||
priceAmount: amount === '' ? null : Math.round(Number(amount) * 100),
|
||||
priceCurrency: currency,
|
||||
})
|
||||
onSaved()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ marginTop: '0.5rem', display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div><label className={styles.label}>Price (blank = free, no checkout)</label>
|
||||
<input className={styles.input} type="number" min="0" step="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Currency</label>
|
||||
<input className={styles.input} value={currency} onChange={(e) => setCurrency(e.target.value.toLowerCase())} style={{ width: '5rem' }} /></div>
|
||||
<button className={styles.btnSecondary} disabled={busy}>Save price</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function AddAgreementRequirementForm({ offeringId, allAgreements, onAdded }) {
|
||||
const [agreementId, setAgreementId] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!agreementId) return
|
||||
setBusy(true); setError(null)
|
||||
try { await adminApi.addOfferingAgreementRequirement(offeringId, agreementId); setAgreementId(''); onAdded() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ marginTop: '0.75rem', display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div><label className={styles.label}>Require an agreement</label>
|
||||
<select className={styles.select} value={agreementId} onChange={(e) => setAgreementId(e.target.value)}>
|
||||
<option value="">Select…</option>
|
||||
{allAgreements.map((a) => <option key={a.agreement_id} value={a.agreement_id}>{a.title}</option>)}
|
||||
</select></div>
|
||||
<button className={styles.btnSecondary} disabled={busy || !agreementId}>Add requirement</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function RegistrationDetail({ registrationId, canAuthor, onBack }) {
|
||||
const [reg, setReg] = useState(null)
|
||||
const [agreementStatus, setAgreementStatus] = useState(null)
|
||||
const [notifs, setNotifs] = useState(null)
|
||||
const [payments, setPayments] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const REG_TRANSITIONS = {
|
||||
started: ['submitted', 'cancelled'], submitted: ['reviewed', 'agreements_pending', 'payment_pending', 'confirmed', 'cancelled'],
|
||||
reviewed: ['agreements_pending', 'payment_pending', 'confirmed', 'cancelled'], agreements_pending: ['payment_pending', 'confirmed', 'cancelled'],
|
||||
payment_pending: ['confirmed', 'cancelled'], confirmed: ['enrollment_activated', 'cancelled'], enrollment_activated: [], cancelled: [],
|
||||
}
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.registration(registrationId).then(setReg).catch((e) => setError(e.message))
|
||||
adminApi.registrationAgreementStatus(registrationId).then(setAgreementStatus).catch(() => {})
|
||||
adminApi.registrationNotifications(registrationId).then(setNotifs).catch(() => {})
|
||||
adminApi.registrationPayments(registrationId).then(setPayments).catch(() => {})
|
||||
}, [registrationId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const withBusy = (fn) => async () => {
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to offering</button>
|
||||
{!reg ? <p className={styles.muted}>Loading…</p> : (
|
||||
<>
|
||||
<h2 style={{ margin: '1rem 0' }}>{reg.registrant_display_name} <span className={styles.pill}>{reg.status.replace('_', ' ')}</span></h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Registration</h3>
|
||||
<p>Type: {reg.is_minor ? 'Minor (guardian-registered)' : 'Adult (self-registered)'}</p>
|
||||
{reg.registrant_email && <p className={styles.muted}>Contact: {reg.registrant_email}</p>}
|
||||
{reg.readiness_notes && <p className={styles.muted}>Readiness notes: {reg.readiness_notes}</p>}
|
||||
{canAuthor && (REG_TRANSITIONS[reg.status] || []).length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
{(REG_TRANSITIONS[reg.status] || []).map((next) => (
|
||||
<button key={next} className={styles.btnSecondary} style={{ marginRight: '0.5rem' }} disabled={busy}
|
||||
onClick={withBusy(() => adminApi.transitionRegistration(registrationId, next))}>
|
||||
{next === 'cancelled' ? 'Cancel' : next.replace('_', ' ')}
|
||||
</button>
|
||||
))}
|
||||
{reg.status === 'confirmed' && (
|
||||
<button className={styles.btn} disabled={busy} onClick={withBusy(async () => { const r = await adminApi.convertRegistration(registrationId); setNotice(`Enrolled as ${r.student.student_id}.`) })}>
|
||||
Convert to enrollment
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Agreements</h3>
|
||||
{!agreementStatus ? <p className={styles.muted}>Loading…</p> : agreementStatus.required.length === 0 ? <p className={styles.muted}>No agreements required for this offering.</p> : (
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
{agreementStatus.required.map((a) => (
|
||||
<li key={a.agreement_id} className={styles.muted}>
|
||||
{agreementStatus.missing.some((m) => m.agreement_id === a.agreement_id) ? '✗ missing' : '✓ accepted'} — {a.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Payments</h3>
|
||||
{!payments ? <p className={styles.muted}>Loading…</p> : payments.length === 0 ? <p className={styles.muted}>No checkout attempted — this offering may be free, or checkout hasn't started yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Amount</th><th>Status</th><th>Provider</th><th>Paid</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{payments.map((p) => (
|
||||
<tr key={p.transaction_id}>
|
||||
<td>{(p.amount / 100).toFixed(2)} {p.currency.toUpperCase()}</td>
|
||||
<td><span className={`${styles.pill} ${p.status === 'paid' ? styles.pillOk : p.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{p.status.replace('_', ' ')}</span></td>
|
||||
<td className={styles.muted}>{p.provider}</td>
|
||||
<td className={styles.muted}>{p.paid_at ? new Date(p.paid_at).toLocaleString() : '—'}</td>
|
||||
<td>
|
||||
{canAuthor && p.status === 'paid' && (
|
||||
<button className={styles.linkBtn} onClick={withBusy(() => adminApi.recordRefund(p.transaction_id, 'refunded'))}>Record refund</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Communications</h3>
|
||||
{!notifs ? <p className={styles.muted}>Loading…</p> : notifs.length === 0 ? <p className={styles.muted}>None sent yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>To</th><th>Status</th><th>Sent</th></tr></thead>
|
||||
<tbody>
|
||||
{notifs.map((n) => (
|
||||
<tr key={n.notification_id}>
|
||||
<td className={styles.muted}>{n.notification_type.replace(/_/g, ' ')}</td>
|
||||
<td className={styles.muted}>{n.recipient_email_masked || '—'}</td>
|
||||
<td><span className={`${styles.pill} ${n.status === 'sent' ? styles.pillOk : n.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{n.status}</span></td>
|
||||
<td className={styles.muted}>{n.sent_at ? new Date(n.sent_at).toLocaleString() : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -138,4 +138,40 @@ export const adminApi = {
|
||||
// ── Phase 6A §7: course/module/activity curriculum view ──────────────────────────────────
|
||||
programCourses: (programId) => get(`/api/admin/programs/${programId}/courses`),
|
||||
studentCourses: (studentId, programId) => get(`/api/admin/students/${studentId}/courses?program_id=${encodeURIComponent(programId)}`),
|
||||
|
||||
// ── Phase 7/8: offerings, registrations, guardians, agreements ──────────────────────────
|
||||
offerings: (status) => get(`/api/admin/offerings${status ? `?status=${status}` : ''}`),
|
||||
offering: (id) => get(`/api/admin/offerings/${id}`),
|
||||
createOffering: (fields) => post('/api/admin/offerings', fields),
|
||||
transitionOffering: (id, status) => post(`/api/admin/offerings/${id}/transition`, { status }),
|
||||
updateOffering: (id, fields) => patch(`/api/admin/offerings/${id}`, fields),
|
||||
registrationPayments: (id) => get(`/api/admin/registrations/${id}/payments`),
|
||||
recordRefund: (transactionId, status) => post(`/api/admin/payments/${transactionId}/refund`, { status }),
|
||||
offeringReadiness: (id) => get(`/api/admin/offerings/${id}/readiness`),
|
||||
assignOfferingInstructor: (id, instructor_id, role) => post(`/api/admin/offerings/${id}/instructors`, { instructor_id, role }),
|
||||
offeringAgreementRequirements: (id) => get(`/api/admin/offerings/${id}/agreement-requirements`),
|
||||
addOfferingAgreementRequirement: (id, agreement_id) => post(`/api/admin/offerings/${id}/agreement-requirements`, { agreement_id }),
|
||||
organizations: () => get('/api/admin/organizations'),
|
||||
createOrganization: (fields) => post('/api/admin/organizations', fields),
|
||||
locations: () => get('/api/admin/locations'),
|
||||
createLocation: (fields) => post('/api/admin/locations', fields),
|
||||
curriculumPackage: (id) => get(`/api/admin/curriculum-packages/${id}`),
|
||||
createCurriculumPackage: (fields) => post('/api/admin/curriculum-packages', fields),
|
||||
addCurriculumPackageItem: (id, fields) => post(`/api/admin/curriculum-packages/${id}/items`, fields),
|
||||
|
||||
offeringRegistrations: (offeringId) => get(`/api/admin/offerings/${offeringId}/registrations`),
|
||||
createRegistration: (offeringId, fields) => post(`/api/admin/offerings/${offeringId}/registrations`, fields),
|
||||
registration: (id) => get(`/api/admin/registrations/${id}`),
|
||||
transitionRegistration: (id, status) => post(`/api/admin/registrations/${id}/transition`, { status }),
|
||||
convertRegistration: (id) => post(`/api/admin/registrations/${id}/convert`, {}),
|
||||
registrationAgreementStatus: (id) => get(`/api/admin/registrations/${id}/agreement-status`),
|
||||
registrationNotifications: (id) => get(`/api/admin/registrations/${id}/notifications`),
|
||||
acceptAgreementForRegistration: (id, fields) => post(`/api/admin/registrations/${id}/agreements`, fields),
|
||||
|
||||
agreements: () => get('/api/admin/agreements'),
|
||||
createAgreement: (fields) => post('/api/admin/agreements', fields),
|
||||
|
||||
createGuardian: (fields) => post('/api/admin/guardians', fields),
|
||||
studentGuardians: (studentId) => get(`/api/admin/students/${studentId}/guardians`),
|
||||
linkStudentGuardian: (studentId, fields) => post(`/api/admin/students/${studentId}/guardians`, fields),
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 7 Step 31 Winter Break Camp internal rehearsal: brings up the real dev
|
||||
# stack, bootstraps an owner, runs test-phase7-winter-camp.js (real offering/registration/
|
||||
# guardian/agreement/enrollment/cohort/classroom/Lab flow, no payment code), tears down.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
|
||||
|
||||
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
|
||||
export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}"
|
||||
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
|
||||
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
cleanup() {
|
||||
echo "==> Tearing down"
|
||||
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
|
||||
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> Waiting for the stack"
|
||||
for _ in $(seq 1 60); do
|
||||
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> Bootstrapping the rehearsal owner"
|
||||
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||
node bootstrap-owner.js [email protected] "P7 Winter Owner" 'RehearsalPass!2026' )
|
||||
|
||||
echo "==> Running the rehearsal"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
|
||||
node ../infra/scripts/drills/test-phase7-winter-camp.js
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 8 Step 32 Winter Break commercial-path rehearsal: brings up the real
|
||||
# dev stack, bootstraps an owner, runs test-phase8-winter-camp-registration.js (real website
|
||||
# registration submission, guardian creation, agreement acceptance, simulated payment
|
||||
# confirmation, real enrollment/cohort/classroom/Lab), tears down.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
|
||||
|
||||
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
|
||||
export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}"
|
||||
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
|
||||
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
cleanup() {
|
||||
echo "==> Tearing down"
|
||||
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
|
||||
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> Waiting for the stack"
|
||||
for _ in $(seq 1 60); do
|
||||
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> Bootstrapping the rehearsal owner"
|
||||
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||
node bootstrap-owner.js [email protected] "P8 Winter Owner" 'RehearsalPass!2026' )
|
||||
|
||||
echo "==> Running the rehearsal"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' INTEGRATION_SHARED_SECRET="$INTEGRATION_SHARED_SECRET" \
|
||||
node ../infra/scripts/drills/test-phase8-winter-camp-registration.js
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 9 Step 30 Winter Break commercial rehearsal: brings up the real dev
|
||||
# stack, bootstraps an owner, runs test-phase9-winter-camp-commerce.js (real checkout/webhook
|
||||
# processing against the deterministic test payment provider — no live Stripe, no real money),
|
||||
# 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}"
|
||||
export PAYMENT_PROVIDER="${PAYMENT_PROVIDER:-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] "P9 Owner" 'RehearsalPass!2026' )
|
||||
|
||||
echo "==> Running the rehearsal"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' INTEGRATION_SHARED_SECRET="$INTEGRATION_SHARED_SECRET" \
|
||||
node ../infra/scripts/drills/test-phase9-winter-camp-commerce.js
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 7 Step 31 — the Winter Break Camp internal operational rehearsal, without payments.
|
||||
// Proves the full path: admin creates a draft offering (Winter Break Linux & Systems Camp,
|
||||
// matching docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md's Option A: ROOT 01-02) with a real
|
||||
// curriculum package, location, and instructor assignment; a guardian registers a minor and an
|
||||
// adult registers themselves; both registrations are walked through agreements and a simulated
|
||||
// commerce confirmation (admin-side only — no payment code exists or is invoked); both convert
|
||||
// to real enrollments and real cohort membership; the instructor runs a real classroom session;
|
||||
// the minor's own Lab work is done for real (the same linux-inspect-hardware checkpoint the
|
||||
// Phase 6D/6F rehearsals already established); progress is confirmed to survive; the offering
|
||||
// is marked complete.
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('../../../platform/test/session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||
}
|
||||
function moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 7 Winter Break Camp internal rehearsal — no payments, real everything else\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await step('owner logs in', async () => {
|
||||
const r = await owner.request('POST', '/api/auth/instructor/login', { email: process.env.OWNER_EMAIL, password: process.env.OWNER_PASSWORD });
|
||||
assert.equal(r.status, 200);
|
||||
});
|
||||
|
||||
const orgId = `winter-org-${RUN_ID}`, locId = `winter-loc-${RUN_ID}`, pkgId = `winter-linux-systems-${RUN_ID}`, cohortId = `winter-cohort-${RUN_ID}`, offeringId = `winter-camp-${RUN_ID}`;
|
||||
|
||||
await step('admin creates the venue (organization + location)', async () => {
|
||||
const org = await owner.request('POST', '/api/admin/organizations', { organizationId: orgId, name: 'Oxford Community Library', type: 'library' });
|
||||
assert.equal(org.status, 201);
|
||||
const loc = await owner.request('POST', '/api/admin/locations', { locationId: locId, organizationId: orgId, name: 'Community Room B', type: 'community_center', capacity: 15 });
|
||||
assert.equal(loc.status, 201);
|
||||
});
|
||||
|
||||
await step('admin creates the curriculum package — ROOT 01-02, matching Winter Break Camp Option A, referencing existing content', async () => {
|
||||
const pkg = await owner.request('POST', '/api/admin/curriculum-packages', { curriculumPackageId: pkgId, name: 'Winter Linux & Systems Foundations', programId: PROGRAM, description: 'ROOT 01-02 — see docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md Option A' });
|
||||
assert.equal(pkg.status, 201);
|
||||
for (const [courseId, sequence] of [['root-01-computing-linux-foundations', 1], ['root-02-linux-administration-networking', 2]]) {
|
||||
const item = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId, sequence });
|
||||
assert.equal(item.status, 201);
|
||||
}
|
||||
});
|
||||
|
||||
await step('admin creates the real cohort this camp will roster into', async () => {
|
||||
const cohort = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'Winter Break Camp Cohort', tier: 'root' });
|
||||
assert.equal(cohort.status, 201);
|
||||
});
|
||||
|
||||
await step('admin creates the DRAFT offering — dates/price/exact seat count left unset, exactly as authorized', async () => {
|
||||
const offering = await owner.request('POST', '/api/admin/offerings', {
|
||||
offeringId, slug: `winter-linux-systems-camp-${RUN_ID}`, name: 'Winter Break Linux & Systems Foundations Camp',
|
||||
programId: PROGRAM, curriculumPackageId: pkgId, deliveryMode: 'camp', capacity: 12,
|
||||
locationId: locId, organizationId: orgId, cohortId,
|
||||
recommendedMinAge: 13, recommendedMaxAge: null, // guidance only — see the age-independence test in e2e-phase7.js
|
||||
});
|
||||
assert.equal(offering.status, 201, JSON.stringify(offering.body));
|
||||
assert.equal(offering.body.status, 'draft');
|
||||
});
|
||||
|
||||
let instructorId;
|
||||
await step('admin creates and assigns the lead instructor', async () => {
|
||||
const create = await owner.request('POST', '/api/admin/instructors', { email: `winter-instructor-${RUN_ID}@example.com`, display_name: 'Winter Camp Instructor', role: 'instructor', password: 'WinterCampPass123!' });
|
||||
assert.equal(create.status, 201);
|
||||
instructorId = create.body.instructor_id;
|
||||
const link = await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorId, role: 'teacher' });
|
||||
assert.equal(link.status, 201);
|
||||
// The offering-level assignment is Phase 7's own new concept; the cohort-level one (the
|
||||
// real RBAC scoping mechanism a classroom session actually checks) is Phase 2A/4.1,
|
||||
// unchanged — both are assigned so the instructor genuinely has classroom access below.
|
||||
const cohortLink = await owner.request('POST', `/api/admin/cohorts/${cohortId}/instructors`, { instructor_id: instructorId, role_in_cohort: 'teacher' });
|
||||
assert.equal(cohortLink.status, 200, JSON.stringify(cohortLink.body));
|
||||
});
|
||||
|
||||
await step('admin publishes the offering and opens registration', async () => {
|
||||
const pub = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
|
||||
assert.equal(pub.status, 200);
|
||||
const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
|
||||
assert.equal(open.status, 200);
|
||||
});
|
||||
|
||||
const guardianId = `winter-guardian-${RUN_ID}`;
|
||||
await step('a guardian registers a minor student for the camp', async () => {
|
||||
const guardian = await owner.request('POST', '/api/admin/guardians', { guardianId, displayName: 'Winter Camp Guardian', email: `winter-guardian-${RUN_ID}@example.com`, phone: '555-0100' });
|
||||
assert.equal(guardian.status, 201);
|
||||
global.__minorReg = (await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, {
|
||||
guardianId, registrantDisplayName: 'Winter Camp Minor', isMinor: true,
|
||||
emergencyContactName: 'Winter Camp Guardian', emergencyContactPhone: '555-0100',
|
||||
readinessNotes: 'No prior Linux experience, comfortable with a keyboard.',
|
||||
})).body;
|
||||
assert.ok(global.__minorReg.registration_id);
|
||||
});
|
||||
|
||||
await step('an adult registers themselves for the same camp', async () => {
|
||||
global.__adultReg = (await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, {
|
||||
registrantDisplayName: 'Winter Camp Adult', registrantEmail: `winter-adult-${RUN_ID}@example.com`,
|
||||
})).body;
|
||||
assert.ok(global.__adultReg.registration_id);
|
||||
});
|
||||
|
||||
await step('both registrations move through submission and agreement acceptance', async () => {
|
||||
const agreementId = `winter-participation-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'Winter Camp Participation Agreement', bodySummary: 'Plain-language summary — not legal text, see docs/STUDENT_GUARDIAN_DATA_MODEL.md.' });
|
||||
for (const reg of [global.__minorReg, global.__adultReg]) {
|
||||
await owner.request('POST', `/api/admin/registrations/${reg.registration_id}/transition`, { status: 'submitted' });
|
||||
await owner.request('POST', `/api/admin/registrations/${reg.registration_id}/transition`, { status: 'agreements_pending' });
|
||||
}
|
||||
// The minor's guardian accepts on the minor's behalf — the guardian identity already
|
||||
// exists at registration time. The adult accepts as a guardian-style acceptance too here
|
||||
// (accepted_by_guardian_id is really just "who accepted," not guardian-exclusive) since an
|
||||
// adult registrant has no student_id yet until conversion below — a real, if slightly
|
||||
// unusual, present-tense fact about this lifecycle worth noting: self-acceptance as
|
||||
// accepted_by_type='student' is only possible AFTER conversion, when a real student_id
|
||||
// exists (an adult could re-accept post-activation in a future UI; not required by V1).
|
||||
const minorAccept = await owner.request('POST', `/api/admin/registrations/${global.__minorReg.registration_id}/agreements`, { agreementId, acceptedByType: 'guardian', acceptedByGuardianId: guardianId });
|
||||
assert.equal(minorAccept.status, 201);
|
||||
});
|
||||
|
||||
await step('a simulated commerce confirmation (admin-only — no payment code exists or is called)', async () => {
|
||||
for (const reg of [global.__minorReg, global.__adultReg]) {
|
||||
const r = await owner.request('POST', `/api/admin/registrations/${reg.registration_id}/transition`, { status: 'confirmed' });
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
}
|
||||
});
|
||||
|
||||
let minorStudent, adultStudent, minorSession, adultSession;
|
||||
await step('both registrations convert to real enrollments and real cohort membership', async () => {
|
||||
const minorConvert = await owner.request('POST', `/api/admin/registrations/${global.__minorReg.registration_id}/convert`, {});
|
||||
assert.equal(minorConvert.status, 201);
|
||||
minorStudent = minorConvert.body.student;
|
||||
assert.ok(minorStudent._activationToken, 'the instructor must have a real token to relay to a no-email minor');
|
||||
const adultConvert = await owner.request('POST', `/api/admin/registrations/${global.__adultReg.registration_id}/convert`, {});
|
||||
assert.equal(adultConvert.status, 201);
|
||||
adultStudent = adultConvert.body.student;
|
||||
assert.ok(adultStudent._activationToken, 'the adult also gets a real token, delivered to their own email in production');
|
||||
|
||||
const rosterCheck = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
|
||||
assert.equal(rosterCheck.status, 200);
|
||||
const ids = rosterCheck.body.map((s) => s.student_id);
|
||||
assert.ok(ids.includes(minorStudent.student_id) && ids.includes(adultStudent.student_id), 'both converted students must be on the real cohort roster');
|
||||
});
|
||||
|
||||
await step('both students activate their real accounts using their real one-time tokens', async () => {
|
||||
minorSession = makeSession(GW);
|
||||
const minorActivate = await minorSession.request('POST', '/api/student/activate', { token: minorStudent._activationToken, password: 'WinterCampMinor123!' });
|
||||
assert.equal(minorActivate.status, 200, JSON.stringify(minorActivate.body));
|
||||
adultSession = makeSession(GW);
|
||||
const adultActivate = await adultSession.request('POST', '/api/student/activate', { token: adultStudent._activationToken, password: 'WinterCampAdult123!' });
|
||||
assert.equal(adultActivate.status, 200, JSON.stringify(adultActivate.body));
|
||||
});
|
||||
|
||||
let instructorSession, classroomId, minorEnrollmentId;
|
||||
await step('instructor starts a live classroom session and Prepares Labs for the cohort', async () => {
|
||||
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'Winter Camp Day 1' });
|
||||
assert.equal(session.status, 201);
|
||||
classroomId = session.body.classroom_session_id;
|
||||
await owner.request('POST', `/api/admin/classroom-sessions/${classroomId}/start`);
|
||||
const prep = await owner.request('POST', `/api/admin/cohorts/${cohortId}/prepare-labs`);
|
||||
assert.equal(prep.status, 200);
|
||||
});
|
||||
|
||||
await step('the minor student joins the classroom and completes a real ROOT 01 practical', async () => {
|
||||
const join = await minorSession.request('POST', `/api/classroom/${classroomId}/join`);
|
||||
assert.equal(join.status, 200);
|
||||
const { body: enrollBody } = await minorSession.request('GET', `/api/student/programs`);
|
||||
minorEnrollmentId = enrollBody[0].enrollment_id;
|
||||
for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check', 'root-01-m01-a01-lesson', 'root-01-m01-a02-guided-exercise']) {
|
||||
const r = await minorSession.request('POST', `/api/student/activities/${id}/complete`);
|
||||
assert.equal(r.status, 200, `completing ${id}: ${JSON.stringify(r.body)}`);
|
||||
}
|
||||
const launch = await minorSession.request('POST', `/api/student/programs/${minorEnrollmentId}/launch`);
|
||||
assert.equal(launch.status, 200);
|
||||
const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
|
||||
const setup = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/setup`);
|
||||
assert.equal(setup.status, 200);
|
||||
const envRow = (await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/environment`)).body.find((e) => e.status === 'running');
|
||||
const containerName = execSync(
|
||||
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envRow.environment_id}'"`,
|
||||
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
|
||||
).toString().trim();
|
||||
const fs = require('fs');
|
||||
const scriptPath = `/tmp/phase7-winter-hardware-${envRow.environment_id}.sh`;
|
||||
fs.writeFileSync(scriptPath, ['#!/bin/bash', 'set -e', 'mkdir -p /srv/hardware-lab',
|
||||
'CPUS=$(nproc)', "MEM_MB=$(free -m | awk '/Mem:/ {print $2}')",
|
||||
'DISK_GB=$(df -BG / | awk \'NR==2{gsub("G","",$2); print $2}\')',
|
||||
'printf "CPUS=%s\\nMEM_MB=%s\\nDISK_GB=%s\\n" "$CPUS" "$MEM_MB" "$DISK_GB" > /srv/hardware-lab/report.txt'].join('\n'));
|
||||
execSync(`docker cp ${scriptPath} ${containerName}:/tmp/hardware-report.sh && docker exec ${containerName} bash /tmp/hardware-report.sh`, { stdio: 'pipe' });
|
||||
fs.unlinkSync(scriptPath);
|
||||
const validate = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/validate`);
|
||||
assert.equal(validate.status, 200);
|
||||
assert.equal(validate.body.passed, true, 'expected a REAL pass, not a simulated one');
|
||||
const record = await minorSession.request('POST', '/api/progress/record', { scenario_id: 'linux-inspect-hardware', status: 'completed', environment_id: envRow.environment_id });
|
||||
assert.equal(record.status, 200);
|
||||
});
|
||||
|
||||
await step('the minor\'s progress survives logout/login (the actual academic-persistence guarantee this camp depends on)', async () => {
|
||||
await minorSession.request('POST', '/api/logout');
|
||||
const returning = makeSession(GW);
|
||||
await returning.request('POST', '/api/student/login', { identifier: minorStudent.login_identifier, password: 'WinterCampMinor123!' });
|
||||
const { body } = await returning.request('GET', `/api/student/programs/${minorEnrollmentId}/courses`);
|
||||
const mod = body.courses.flatMap((c) => c.modules).find((m) => m.module_id === 'root-01-m01-how-computers-run-software');
|
||||
assert.ok(mod.activities.some((a) => a.complete), 'the real Lab pass must have survived logout/login');
|
||||
});
|
||||
|
||||
await step('instructor sees real progress and roster state for the camp', async () => {
|
||||
const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
|
||||
assert.equal(roster.status, 200);
|
||||
assert.equal(roster.body.length, 2);
|
||||
});
|
||||
|
||||
await step('the offering moves through in_progress to completed', async () => {
|
||||
const inProgress = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'in_progress' });
|
||||
assert.equal(inProgress.status, 200);
|
||||
const completed = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'completed' });
|
||||
assert.equal(completed.status, 200);
|
||||
assert.equal(completed.body.status, 'completed');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 8 Step 32 — the Winter Break commercial-path rehearsal: the website submits real
|
||||
// registrations (via the new /api/integrations/registrations contract), a guardian's agreement
|
||||
// acceptance is recorded through the supported application flow, a future-payment confirmation
|
||||
// is SIMULATED (admin-side transition only — no payment code anywhere), and the full remaining
|
||||
// path (enrollment, cohort, activation, classroom, real Lab work, progress, instructor
|
||||
// visibility) runs for real. Also proves the adult registration path and a genuine two-students-
|
||||
// competing-for-the-final-seat race.
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('../../../platform/test/session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||
}
|
||||
function moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
|
||||
async function integrationPost(path, body) {
|
||||
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 8 Winter Break commercial-path rehearsal — website submission, no real payment\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await step('owner logs in', async () => {
|
||||
const r = await owner.request('POST', '/api/auth/instructor/login', { email: process.env.OWNER_EMAIL, password: process.env.OWNER_PASSWORD });
|
||||
assert.equal(r.status, 200);
|
||||
});
|
||||
|
||||
const locId = `p8-winter-loc-${RUN_ID}`, cohortId = `p8-winter-cohort-${RUN_ID}`, offeringId = `p8-winter-camp-${RUN_ID}`, slug = `p8-winter-linux-systems-${RUN_ID}`, agreementId = `p8-winter-agreement-${RUN_ID}`;
|
||||
|
||||
await step('admin opens the Winter Break offering — location, capacity, instructor, agreement requirement configured', async () => {
|
||||
await owner.request('POST', '/api/admin/locations', { locationId: locId, name: 'Community Room B' });
|
||||
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'Winter Break Cohort', tier: 'root' });
|
||||
const create = await owner.request('POST', '/api/admin/offerings', {
|
||||
offeringId, slug, name: 'Winter Break Linux & Systems Camp', programId: PROGRAM, deliveryMode: 'camp',
|
||||
capacity: 2, locationId: locId, cohortId,
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const instructorCreate = await owner.request('POST', '/api/admin/instructors', { email: `p8-winter-instructor-${RUN_ID}@example.com`, display_name: 'Winter Instructor', role: 'instructor', password: 'WinterP8Pass123!' });
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role: 'teacher' });
|
||||
await owner.request('POST', `/api/admin/cohorts/${cohortId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role_in_cohort: 'teacher' });
|
||||
await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'Winter Camp Participation Agreement' });
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId });
|
||||
const readiness = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
|
||||
assert.ok(readiness.body.checks.find((c) => c.key === 'instructor').ready);
|
||||
assert.ok(readiness.body.checks.find((c) => c.key === 'agreements').ready);
|
||||
});
|
||||
|
||||
await step('admin publishes and opens registration', async () => {
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
|
||||
const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
|
||||
assert.equal(open.status, 200);
|
||||
});
|
||||
|
||||
let minorRegId, minorGuardianId;
|
||||
await step('website integration submits a REAL minor registration (guardian created/associated), confirmation email queued', async () => {
|
||||
const submit = await integrationPost('/api/integrations/registrations', {
|
||||
event_id: `p8-winter-minor-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Winter Camper', is_minor: true },
|
||||
guardian: { display_name: 'Winter Guardian', email: `winter-guardian-${RUN_ID}@example.com`, phone: '555-0101' },
|
||||
});
|
||||
assert.equal(submit.status, 201, JSON.stringify(submit.body));
|
||||
minorRegId = submit.body.registration_id;
|
||||
const reg = await owner.request('GET', `/api/admin/registrations/${minorRegId}`);
|
||||
minorGuardianId = reg.body.guardian_id;
|
||||
assert.ok(minorGuardianId, 'a real guardian must have been created and linked by the integration endpoint');
|
||||
const notifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
|
||||
assert.ok(notifs.body.some((n) => n.notification_type === 'registration_received'), 'a registration_received confirmation must be queued');
|
||||
});
|
||||
|
||||
await step('guardian acceptance is recorded through the supported application flow — agreement complete', async () => {
|
||||
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'submitted' });
|
||||
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'agreements_pending' });
|
||||
const agreementNotifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
|
||||
assert.ok(agreementNotifs.body.some((n) => n.notification_type === 'agreement_required'), 'an agreement_required notice must be queued when entering agreements_pending');
|
||||
const accept = await owner.request('POST', `/api/admin/registrations/${minorRegId}/agreements`, { agreementId, acceptedByType: 'guardian', acceptedByGuardianId: minorGuardianId });
|
||||
assert.equal(accept.status, 201);
|
||||
const status = await owner.request('GET', `/api/admin/registrations/${minorRegId}/agreement-status`);
|
||||
assert.equal(status.body.missing.length, 0);
|
||||
});
|
||||
|
||||
await step('future payment confirmation is SIMULATED (admin-only transition) — no payment code anywhere', async () => {
|
||||
const confirm = await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'confirmed' });
|
||||
assert.equal(confirm.status, 200);
|
||||
const confirmedNotifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
|
||||
assert.ok(confirmedNotifs.body.some((n) => n.notification_type === 'registration_confirmed'));
|
||||
});
|
||||
|
||||
let minorStudent, minorSession, minorEnrollmentId;
|
||||
await step('registration converts to real enrollment + real cohort membership; account activates', async () => {
|
||||
const convert = await owner.request('POST', `/api/admin/registrations/${minorRegId}/convert`, {});
|
||||
assert.equal(convert.status, 201, JSON.stringify(convert.body));
|
||||
minorStudent = convert.body.student;
|
||||
assert.ok(minorStudent._activationToken);
|
||||
const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
|
||||
assert.ok(roster.body.some((s) => s.student_id === minorStudent.student_id));
|
||||
minorSession = makeSession(GW);
|
||||
const activate = await minorSession.request('POST', '/api/student/activate', { token: minorStudent._activationToken, password: 'WinterP8Minor123!' });
|
||||
assert.equal(activate.status, 200);
|
||||
});
|
||||
|
||||
await step('adult registration path — self-registers, no guardian required, converts independently', async () => {
|
||||
const submit = await integrationPost('/api/integrations/registrations', {
|
||||
event_id: `p8-winter-adult-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Winter Adult', email: `winter-adult-${RUN_ID}@example.com`, is_minor: false },
|
||||
});
|
||||
assert.equal(submit.status, 201);
|
||||
const regId = submit.body.registration_id;
|
||||
await owner.request('POST', `/api/admin/registrations/${regId}/agreements`, { agreementId, acceptedByType: 'registrant' });
|
||||
await owner.request('POST', `/api/admin/registrations/${regId}/transition`, { status: 'submitted' });
|
||||
await owner.request('POST', `/api/admin/registrations/${regId}/transition`, { status: 'confirmed' });
|
||||
const convert = await owner.request('POST', `/api/admin/registrations/${regId}/convert`, {});
|
||||
assert.equal(convert.status, 201);
|
||||
assert.equal(convert.body.student.email, `winter-adult-${RUN_ID}@example.com`);
|
||||
});
|
||||
|
||||
await step('two students compete for the final seat — exactly one wins, offering reports full, no overbooking', async () => {
|
||||
const before = await owner.request('GET', `/api/admin/offerings/${offeringId}`);
|
||||
assert.equal(before.body.reserved_seats, before.body.capacity, 'capacity=2 should already be exhausted by the minor + adult above');
|
||||
const [a, b] = await Promise.all([
|
||||
integrationPost('/api/integrations/registrations', { event_id: `p8-winter-race-a-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race A', email: `p8-race-a-${RUN_ID}@example.com` } }),
|
||||
integrationPost('/api/integrations/registrations', { event_id: `p8-winter-race-b-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race B', email: `p8-race-b-${RUN_ID}@example.com` } }),
|
||||
]);
|
||||
const statuses = [a.status, b.status].sort();
|
||||
assert.deepEqual(statuses, [409, 409], 'both must be rejected — the seat was already full before either request, this proves no overbooking beyond capacity');
|
||||
const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json());
|
||||
assert.equal(pub.availability, 'full');
|
||||
});
|
||||
|
||||
await step('instructor starts a live classroom session and Prepares Labs', async () => {
|
||||
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'Winter Camp Day 1' });
|
||||
assert.equal(session.status, 201);
|
||||
global.__classroomId = session.body.classroom_session_id;
|
||||
await owner.request('POST', `/api/admin/classroom-sessions/${global.__classroomId}/start`);
|
||||
const prep = await owner.request('POST', `/api/admin/cohorts/${cohortId}/prepare-labs`);
|
||||
assert.equal(prep.status, 200);
|
||||
});
|
||||
|
||||
await step('the minor student joins, completes real orientation + a REAL Lab task, progress persists', async () => {
|
||||
const join = await minorSession.request('POST', `/api/classroom/${global.__classroomId}/join`);
|
||||
assert.equal(join.status, 200);
|
||||
const { body: enrollBody } = await minorSession.request('GET', '/api/student/programs');
|
||||
minorEnrollmentId = enrollBody[0].enrollment_id;
|
||||
for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check', 'root-01-m01-a01-lesson', 'root-01-m01-a02-guided-exercise']) {
|
||||
const r = await minorSession.request('POST', `/api/student/activities/${id}/complete`);
|
||||
assert.equal(r.status, 200, `completing ${id}: ${JSON.stringify(r.body)}`);
|
||||
}
|
||||
const launch = await minorSession.request('POST', `/api/student/programs/${minorEnrollmentId}/launch`);
|
||||
assert.equal(launch.status, 200);
|
||||
const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
|
||||
const setup = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/setup`);
|
||||
assert.equal(setup.status, 200);
|
||||
const envRow = (await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/environment`)).body.find((e) => e.status === 'running');
|
||||
const containerName = execSync(
|
||||
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envRow.environment_id}'"`,
|
||||
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
|
||||
).toString().trim();
|
||||
const fs = require('fs');
|
||||
const scriptPath = `/tmp/phase8-winter-hardware-${envRow.environment_id}.sh`;
|
||||
fs.writeFileSync(scriptPath, ['#!/bin/bash', 'set -e', 'mkdir -p /srv/hardware-lab',
|
||||
'CPUS=$(nproc)', "MEM_MB=$(free -m | awk '/Mem:/ {print $2}')",
|
||||
'DISK_GB=$(df -BG / | awk \'NR==2{gsub("G","",$2); print $2}\')',
|
||||
'printf "CPUS=%s\\nMEM_MB=%s\\nDISK_GB=%s\\n" "$CPUS" "$MEM_MB" "$DISK_GB" > /srv/hardware-lab/report.txt'].join('\n'));
|
||||
execSync(`docker cp ${scriptPath} ${containerName}:/tmp/hardware-report.sh && docker exec ${containerName} bash /tmp/hardware-report.sh`, { stdio: 'pipe' });
|
||||
fs.unlinkSync(scriptPath);
|
||||
const validate = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/validate`);
|
||||
assert.equal(validate.status, 200);
|
||||
assert.equal(validate.body.passed, true);
|
||||
await minorSession.request('POST', '/api/progress/record', { scenario_id: 'linux-inspect-hardware', status: 'completed', environment_id: envRow.environment_id });
|
||||
});
|
||||
|
||||
await step('instructor sees the real roster and progress', async () => {
|
||||
const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
|
||||
assert.equal(roster.body.length, 2);
|
||||
const { body } = await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/courses?program_id=${PROGRAM}`);
|
||||
const mod = moduleById(body, 'root-01-m01-how-computers-run-software');
|
||||
assert.ok(mod.activities.some((a) => a.complete), 'the instructor must see the minor\'s real Lab progress');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 9 Step 30 — the full Winter Break commercial rehearsal, WITH real checkout/webhook
|
||||
// processing this time (Phase 8's rehearsal simulated payment confirmation via a plain admin
|
||||
// transition; this one drives the actual commerce path end to end). Uses the deterministic
|
||||
// 'test' payment provider — no live money, no real Stripe credentials anywhere in this script.
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('../../../platform/test/session-helper');
|
||||
const { getPaymentProvider } = require('../../../platform/api/src/payment-provider');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||
}
|
||||
function moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
|
||||
async function integrationPost(path, body) {
|
||||
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
}
|
||||
async function webhookPost(body, signature) {
|
||||
const r = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Test-Payment-Signature': signature }, body });
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 9 Winter Break commercial rehearsal — real checkout/webhook, test provider, no live money\n');
|
||||
const provider = getPaymentProvider();
|
||||
assert.equal(provider.name, 'test');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await step('owner logs in', async () => {
|
||||
const r = await owner.request('POST', '/api/auth/instructor/login', { email: process.env.OWNER_EMAIL, password: process.env.OWNER_PASSWORD });
|
||||
assert.equal(r.status, 200);
|
||||
});
|
||||
|
||||
const locId = `p9-loc-${RUN_ID}`, cohortId = `p9-cohort-${RUN_ID}`, offeringId = `p9-camp-${RUN_ID}`, slug = `p9-linux-systems-${RUN_ID}`, agreementId = `p9-agreement-${RUN_ID}`;
|
||||
|
||||
await step('admin configures the test offering — price, capacity, location, agreement, instructor', async () => {
|
||||
await owner.request('POST', '/api/admin/locations', { locationId: locId, name: 'Community Room B' });
|
||||
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'Winter Break Cohort', tier: 'root' });
|
||||
const create = await owner.request('POST', '/api/admin/offerings', {
|
||||
offeringId, slug, name: 'Winter Break Linux & Systems Camp', programId: PROGRAM, deliveryMode: 'camp',
|
||||
capacity: 2, locationId: locId, cohortId, priceAmount: 20000, priceCurrency: 'usd',
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const instructorCreate = await owner.request('POST', '/api/admin/instructors', { email: `p9-instructor-${RUN_ID}@example.com`, display_name: 'P9 Instructor', role: 'instructor', password: 'P9InstructorPass123!' });
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role: 'teacher' });
|
||||
await owner.request('POST', `/api/admin/cohorts/${cohortId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role_in_cohort: 'teacher' });
|
||||
await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'Winter Camp Participation Agreement' });
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId });
|
||||
const readiness = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
|
||||
// Dates/registration-window are deliberately NOT set — real business decisions this phase
|
||||
// must not invent (Steps 4/28/32) — so overall readiness is honestly false. Every check
|
||||
// this rehearsal DID configure must show ready; the ones nobody has decided yet must not.
|
||||
const byKey = Object.fromEntries(readiness.body.checks.map((c) => [c.key, c.ready]));
|
||||
for (const key of ['curriculum', 'instructor', 'location', 'capacity', 'agreements', 'price', 'payment_provider']) {
|
||||
assert.equal(byKey[key], true, `expected ${key} to be ready: ${JSON.stringify(readiness.body.checks)}`);
|
||||
}
|
||||
assert.equal(readiness.body.ready_to_open_registration, false, 'must honestly stay not-fully-ready until real dates are set');
|
||||
});
|
||||
|
||||
await step('admin opens registration', async () => {
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
|
||||
const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
|
||||
assert.equal(open.status, 200);
|
||||
});
|
||||
|
||||
// ── Minor registration -> agreement -> checkout -> real payment -> enrollment -> Lab ──────
|
||||
let minorRegId, minorTransactionRef;
|
||||
await step('a minor registers via the website, guardian accepts the agreement, checkout is created', async () => {
|
||||
const submit = await integrationPost('/api/integrations/registrations', {
|
||||
event_id: `p9-minor-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Winter Camper', is_minor: true },
|
||||
guardian: { display_name: 'Winter Guardian', email: `winter-guardian-${RUN_ID}@example.com`, phone: '555-0101' },
|
||||
});
|
||||
assert.equal(submit.status, 201);
|
||||
minorRegId = submit.body.registration_id;
|
||||
const preCheckout = await integrationPost(`/api/integrations/registrations/${minorRegId}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
|
||||
assert.equal(preCheckout.status, 409, 'checkout must be blocked before the agreement is accepted');
|
||||
const accept = await integrationPost(`/api/integrations/registrations/${minorRegId}/agreements`, { agreement_id: agreementId });
|
||||
assert.equal(accept.status, 201);
|
||||
const checkout = await integrationPost(`/api/integrations/registrations/${minorRegId}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
|
||||
assert.equal(checkout.status, 201, JSON.stringify(checkout.body));
|
||||
const tx = await owner.request('GET', `/api/admin/registrations/${minorRegId}/payments`);
|
||||
minorTransactionRef = tx.body[0].provider_reference;
|
||||
});
|
||||
|
||||
let minorStudent, minorSession, minorEnrollmentId, minorPaymentEvent;
|
||||
await step('the test payment provider confirms payment via a REAL webhook call — registration converts, account activates', async () => {
|
||||
minorPaymentEvent = provider.buildTestEvent({ providerReference: minorTransactionRef, eventType: 'checkout.session.completed' });
|
||||
const webhookResult = await webhookPost(minorPaymentEvent.body, minorPaymentEvent.signature);
|
||||
assert.equal(webhookResult.status, 200);
|
||||
assert.equal(webhookResult.body.result, 'processed');
|
||||
const reg = await owner.request('GET', `/api/admin/registrations/${minorRegId}`);
|
||||
assert.equal(reg.body.status, 'enrollment_activated');
|
||||
const cohortRoster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
|
||||
assert.ok(cohortRoster.body.some((s) => s.student_id === reg.body.student_id));
|
||||
minorEnrollmentId = reg.body.enrollment_id;
|
||||
|
||||
// The real activation token was already delivered to the (dev) email provider by the
|
||||
// notification worker — retrieved here via the same dev-preview mechanism e2e-phase5c.js's
|
||||
// own suite already established, never a raw-token-in-API-response shortcut (that only
|
||||
// exists on the admin-authored convert route, not the webhook path — see notifications.js's
|
||||
// own comment on why toAdminView() never includes it).
|
||||
execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "UPDATE notification_events SET next_attempt_at = now() WHERE status = 'queued'"`, { cwd: `${__dirname}/../../../platform`, stdio: 'pipe' });
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
const notifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
|
||||
const activationNotif = notifs.body.find((n) => n.notification_type === 'activation');
|
||||
assert.ok(activationNotif, `expected a real activation notification, got: ${JSON.stringify(notifs.body)}`);
|
||||
const preview = await owner.request('GET', `/api/admin/notifications/${activationNotif.notification_id}/dev-preview`);
|
||||
if (!preview.body || !preview.body.text) throw new Error(`dev-preview failed: status=${preview.status} notif=${JSON.stringify(activationNotif)} body=${JSON.stringify(preview.body)}`);
|
||||
const token = preview.body.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
|
||||
minorStudent = { student_id: reg.body.student_id };
|
||||
minorSession = makeSession(GW);
|
||||
const activate = await minorSession.request('POST', '/api/student/activate', { token, password: 'WinterP9Minor123!' });
|
||||
assert.equal(activate.status, 200, JSON.stringify(activate.body));
|
||||
});
|
||||
|
||||
await step('the duplicate webhook (a real Stripe retry scenario) changes nothing further', async () => {
|
||||
// The EXACT same event (same event id) as the prior step — a real Stripe retry redelivers
|
||||
// the identical event, it never mints a new event id for "the same" delivery. Building a
|
||||
// fresh test event here would defeat the point: it's calling buildTestEvent() twice that
|
||||
// simulates two DIFFERENT events, not a retry of the same one.
|
||||
const replay = await webhookPost(minorPaymentEvent.body, minorPaymentEvent.signature);
|
||||
assert.equal(replay.body.result, 'duplicate');
|
||||
});
|
||||
|
||||
// ── Adult registration, real payment, independent conversion ──────────────────────────────
|
||||
await step('an adult registers, pays, and converts independently — no guardian involved', async () => {
|
||||
const submit = await integrationPost('/api/integrations/registrations', { event_id: `p9-adult-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Winter Adult', email: `winter-adult-${RUN_ID}@example.com`, is_minor: false } });
|
||||
assert.equal(submit.status, 201);
|
||||
await integrationPost(`/api/integrations/registrations/${submit.body.registration_id}/agreements`, { agreement_id: agreementId });
|
||||
const checkout = await integrationPost(`/api/integrations/registrations/${submit.body.registration_id}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
|
||||
const tx = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}/payments`);
|
||||
const event = provider.buildTestEvent({ providerReference: tx.body[0].provider_reference, eventType: 'checkout.session.completed' });
|
||||
await webhookPost(event.body, event.signature);
|
||||
const reg = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}`);
|
||||
assert.equal(reg.body.status, 'enrollment_activated');
|
||||
});
|
||||
|
||||
// ── Capacity now exhausted (2/2) — a third registration must fail cleanly ────────────────
|
||||
await step('a failed payment leaves a retriable registration without consuming a phantom extra seat', async () => {
|
||||
const failSlugOffering = `p9-fail-camp-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/offerings', { offeringId: `off-${failSlugOffering}`, slug: failSlugOffering, name: 'Fail Camp', programId: PROGRAM, deliveryMode: 'workshop', capacity: 1, priceAmount: 5000 });
|
||||
await owner.request('POST', `/api/admin/offerings/off-${failSlugOffering}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/off-${failSlugOffering}/transition`, { status: 'enrollment_open' });
|
||||
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-failreg-${RUN_ID}`, offering_slug: failSlugOffering, registrant: { display_name: 'Fail Registrant', email: `failreg-${RUN_ID}@example.com` } });
|
||||
const checkout = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
|
||||
const tx = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
|
||||
const event = provider.buildTestEvent({ providerReference: tx.body[0].provider_reference, eventType: 'payment_intent.payment_failed' });
|
||||
await webhookPost(event.body, event.signature);
|
||||
const offAfter = await owner.request('GET', `/api/admin/offerings/off-${failSlugOffering}`);
|
||||
assert.equal(offAfter.body.reserved_seats, 1, 'the seat stays reserved so the same registrant can retry checkout');
|
||||
});
|
||||
|
||||
// ── Abandoned checkout releases the seat back to the pool ────────────────────────────────
|
||||
await step('an abandoned checkout (expired session) releases the seat', async () => {
|
||||
const abandonOffering = `off-p9-abandon-${RUN_ID}`, abandonSlug = `p9-abandon-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/offerings', { offeringId: abandonOffering, slug: abandonSlug, name: 'Abandon Test', programId: PROGRAM, deliveryMode: 'workshop', capacity: 1, priceAmount: 5000 });
|
||||
await owner.request('POST', `/api/admin/offerings/${abandonOffering}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${abandonOffering}/transition`, { status: 'enrollment_open' });
|
||||
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-abandonreg-${RUN_ID}`, offering_slug: abandonSlug, registrant: { display_name: 'Abandoner', email: `abandoner-${RUN_ID}@example.com` } });
|
||||
const checkout = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
|
||||
const tx = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
|
||||
const event = provider.buildTestEvent({ providerReference: tx.body[0].provider_reference, eventType: 'checkout.session.expired' });
|
||||
await webhookPost(event.body, event.signature);
|
||||
const offAfter = await owner.request('GET', `/api/admin/offerings/${abandonOffering}`);
|
||||
assert.equal(offAfter.body.reserved_seats, 0, 'an abandoned checkout must release its seat');
|
||||
// Prove the released seat is genuinely usable again — a fresh registration succeeds.
|
||||
const retry = await integrationPost('/api/integrations/registrations', { event_id: `p9-abandonretry-${RUN_ID}`, offering_slug: abandonSlug, registrant: { display_name: 'Retry Registrant', email: `retry-${RUN_ID}@example.com` } });
|
||||
assert.equal(retry.status, 201);
|
||||
});
|
||||
|
||||
// ── Last-seat race: two students competing, exactly one wins ────────────────────────────
|
||||
await step('two students genuinely compete for the final seat of a paid offering — exactly one wins', async () => {
|
||||
const raceOffering = `off-p9-lastseat-${RUN_ID}`, raceSlug = `p9-lastseat-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/offerings', { offeringId: raceOffering, slug: raceSlug, name: 'Last Seat', programId: PROGRAM, deliveryMode: 'workshop', capacity: 1, priceAmount: 5000 });
|
||||
await owner.request('POST', `/api/admin/offerings/${raceOffering}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${raceOffering}/transition`, { status: 'enrollment_open' });
|
||||
const [a, b] = await Promise.all([
|
||||
integrationPost('/api/integrations/registrations', { event_id: `p9-lastseat-a-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'Last A', email: `lasta-${RUN_ID}@example.com` } }),
|
||||
integrationPost('/api/integrations/registrations', { event_id: `p9-lastseat-b-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'Last B', email: `lastb-${RUN_ID}@example.com` } }),
|
||||
]);
|
||||
assert.deepEqual([a.status, b.status].sort(), [201, 409]);
|
||||
});
|
||||
|
||||
// ── Real classroom + Lab work for the actually-enrolled minor student ────────────────────
|
||||
await step('instructor starts a classroom session and Prepares Labs', async () => {
|
||||
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'Winter Camp Day 1' });
|
||||
assert.equal(session.status, 201);
|
||||
global.__classroomId = session.body.classroom_session_id;
|
||||
await owner.request('POST', `/api/admin/classroom-sessions/${global.__classroomId}/start`);
|
||||
const prep = await owner.request('POST', `/api/admin/cohorts/${cohortId}/prepare-labs`);
|
||||
assert.equal(prep.status, 200);
|
||||
});
|
||||
|
||||
await step('the minor student joins, completes real orientation + a REAL Lab task, progress persists', async () => {
|
||||
const join = await minorSession.request('POST', `/api/classroom/${global.__classroomId}/join`);
|
||||
assert.equal(join.status, 200);
|
||||
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/phase9-winter-hardware-${envRow.environment_id}.sh`;
|
||||
fs.writeFileSync(scriptPath, ['#!/bin/bash', 'set -e', 'mkdir -p /srv/hardware-lab',
|
||||
'CPUS=$(nproc)', "MEM_MB=$(free -m | awk '/Mem:/ {print $2}')",
|
||||
'DISK_GB=$(df -BG / | awk \'NR==2{gsub("G","",$2); print $2}\')',
|
||||
'printf "CPUS=%s\\nMEM_MB=%s\\nDISK_GB=%s\\n" "$CPUS" "$MEM_MB" "$DISK_GB" > /srv/hardware-lab/report.txt'].join('\n'));
|
||||
execSync(`docker cp ${scriptPath} ${containerName}:/tmp/hardware-report.sh && docker exec ${containerName} bash /tmp/hardware-report.sh`, { stdio: 'pipe' });
|
||||
fs.unlinkSync(scriptPath);
|
||||
const validate = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/validate`);
|
||||
assert.equal(validate.status, 200);
|
||||
assert.equal(validate.body.passed, true);
|
||||
await minorSession.request('POST', '/api/progress/record', { scenario_id: 'linux-inspect-hardware', status: 'completed', environment_id: envRow.environment_id });
|
||||
});
|
||||
|
||||
await step('instructor sees real roster, payment, and progress state for the camp', async () => {
|
||||
const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
|
||||
assert.equal(roster.body.length, 2);
|
||||
const payments = await owner.request('GET', `/api/admin/registrations/${minorRegId}/payments`);
|
||||
assert.equal(payments.body[0].status, 'paid');
|
||||
const { body } = await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/courses?program_id=${PROGRAM}`);
|
||||
const mod = moduleById(body, 'root-01-m01-how-computers-run-software');
|
||||
assert.ok(mod.activities.some((a) => a.complete));
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
+28
-3
@@ -106,11 +106,36 @@ further instructor accounts from the Instructors tab).
|
||||
prerequisite graph (no cycles, no missing nodes), the strengthened Capstone gate (ROOT 09 AND
|
||||
ROOT 12 both required) in both directions, the new orientation module, and an explicit check
|
||||
that no age/grade gate exists anywhere in enrollment.
|
||||
- `test/e2e-phase7.js` — organizations/locations, guardians and student-guardian relationships,
|
||||
curriculum packages (reference-only, never a content clone), offerings (capacity, server-
|
||||
defined status transitions), the registration lifecycle through agreement acceptance and
|
||||
conversion to a real enrollment, registration/guardian RBAC, explicit age-guidance-is-never-
|
||||
an-authorization-check verification, and confirmation that existing ROOT_ V1/self-paced/
|
||||
classroom flows are unaffected.
|
||||
- `test/e2e-phase8.js` — offering launch readiness, server-authoritative registration open/
|
||||
closed enforcement, capacity concurrency through the real registration endpoints, the public
|
||||
offering serializer (no internal/PII leakage), the website registration integration contract
|
||||
(sequential and concurrent idempotency), required-agreement conversion blocking with version
|
||||
snapshotting, guardian notification recipient resolution, and confirmation that ROOT_ V1/self-
|
||||
paced/classroom flows remain unaffected.
|
||||
- `test/e2e-phase9.js` — offering pricing in the public serializer, checkout blocked until
|
||||
agreements are accepted, the server-authoritative amount (never caller-supplied), webhook
|
||||
signature verification and idempotency, payment success driving real conversion/activation, a
|
||||
failed payment leaving the seat intact vs. an abandoned checkout releasing it, the reservation
|
||||
reaper, a last-seat checkout race, manual refund with no cascade into registration/enrollment
|
||||
status, minor payment confirmations routing to the guardian, the admin payment view (never card
|
||||
data), instructor/public RBAC on payment data, audit/metric hygiene (no secrets, no
|
||||
high-cardinality labels), the website status-lookup endpoint's minimal safe shape, and
|
||||
confirmation that ROOT_ V1/free-offering/classroom flows remain unaffected.
|
||||
|
||||
`infra/scripts/drills/` also holds real-Lab dress rehearsals per phase (Phase 6D/6E foundations,
|
||||
Phase 6F's multi-student pilot and full end-to-end V1 journey) — see each script's own header
|
||||
and `docs/PHASE6F_ROOT_V1_PILOT_READINESS.md` for what they prove beyond what the e2e suites
|
||||
above already cover via direct API.
|
||||
Phase 6F's multi-student pilot and full end-to-end V1 journey, Phase 7/8's Winter Break Camp
|
||||
rehearsals, Phase 9's Winter Break COMMERCE rehearsal —
|
||||
`test-phase9-winter-camp-commerce.js`, 13/13, real checkout/webhook against the deterministic
|
||||
test payment provider, no live money) — see each script's own header and
|
||||
`docs/PHASE6F_ROOT_V1_PILOT_READINESS.md`/`docs/PHASE8_ENROLLMENT_OPERATIONS.md`/`docs/
|
||||
PHASE9_COMMERCE_AND_LAUNCH.md` for what they prove beyond what the e2e suites above already
|
||||
cover via direct API.
|
||||
|
||||
All e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
|
||||
account (see "Running the full stack locally" above) — the old `ADMIN_PASSWORD` mechanism is
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Phase 7 §7/§8/§10 — the business/product architecture's first-class organization, location,
|
||||
-- and guardian concepts. Additive only: no existing table's meaning changes, no existing row's
|
||||
-- data moves. See docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md and
|
||||
-- docs/STUDENT_GUARDIAN_DATA_MODEL.md for the full reasoning.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- programs.family widened for BOOTCAMP_ (Step 15/21) — a program row can now describe a
|
||||
-- seasonal/intensive offering family too. No existing row's family value changes; this is a
|
||||
-- pure widening of what's allowed going forward.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE programs DROP CONSTRAINT IF EXISTS programs_family_check;
|
||||
ALTER TABLE programs ADD CONSTRAINT programs_family_check CHECK (family IN ('init', 'compile', 'root', 'bootcamp'));
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- organizations — a business/delivery relationship (a school, library, homeschool group,
|
||||
-- community partner, workforce-development partner), NOT a SaaS tenant. RootNode Academy
|
||||
-- remains one Academy; no other table gets an organization_id foreign key added to it wholesale
|
||||
-- — only offerings and locations reference one, and only optionally (Step 7's explicit
|
||||
-- "do not move all RootNode data under organization_id").
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
organization_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'other' CHECK (type IN ('school', 'library', 'homeschool_group', 'community', 'workforce_partner', 'other')),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||||
contact_name TEXT,
|
||||
contact_email TEXT,
|
||||
contact_phone TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- locations — a physical or virtual delivery location. organization_id is nullable: a location
|
||||
-- can be a RootNode-run venue with no partner organization at all (e.g. a rented community-
|
||||
-- center room booked directly), or "online" (type='online', every address field null).
|
||||
-- access_notes is deliberately never exposed on any student-facing route — internal
|
||||
-- operational detail only (parking instructions, building access codes, etc).
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS locations (
|
||||
location_id TEXT PRIMARY KEY,
|
||||
organization_id TEXT REFERENCES organizations(organization_id),
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'online' CHECK (type IN ('school', 'library', 'community_center', 'partner_facility', 'online', 'other')),
|
||||
address_line1 TEXT,
|
||||
address_line2 TEXT,
|
||||
city TEXT,
|
||||
region TEXT,
|
||||
postal_code TEXT,
|
||||
country TEXT,
|
||||
timezone TEXT,
|
||||
capacity INTEGER,
|
||||
access_notes TEXT, -- internal/operational only — never returned by a student-facing route
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- guardians / student_guardians — the proper foundation replacing the Phase 5C
|
||||
-- `enrollments.metadata.contact_email` workaround (left in place, unchanged, for backward
|
||||
-- compatibility with the live website integration — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md).
|
||||
-- A guardian is its own identity, separate from students.email, so a parent with multiple
|
||||
-- enrolled children is ONE guardian row linked to MANY students, not a duplicated email per
|
||||
-- child. Explicitly NOT a login/auth identity in this phase — no password, no session, no
|
||||
-- portal access — see docs/STUDENT_GUARDIAN_DATA_MODEL.md for what was deliberately deferred.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS guardians (
|
||||
guardian_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS student_guardians (
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
guardian_id TEXT NOT NULL REFERENCES guardians(guardian_id),
|
||||
relationship TEXT NOT NULL DEFAULT 'guardian' CHECK (relationship IN ('parent', 'guardian', 'authorized_contact', 'emergency_contact')),
|
||||
is_primary_contact BOOLEAN NOT NULL DEFAULT false,
|
||||
can_receive_communications BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (student_id, guardian_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_student_guardians_guardian ON student_guardians(guardian_id);
|
||||
@@ -0,0 +1,148 @@
|
||||
-- Phase 7 §3/§12/§14/§20 — offerings (a purchasable/enrollable delivery of a program),
|
||||
-- registrations (pre-enrollment application/interest, separate from academic entitlement),
|
||||
-- agreements (versioned acceptance records, not legal text), and curriculum packages (a named,
|
||||
-- versioned reference list into EXISTING courses — never a content clone). See
|
||||
-- docs/OFFERING_AND_REGISTRATION_MODEL.md for the full reasoning and lifecycle diagrams.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- curriculum_packages / curriculum_package_items — a named, versioned SUBSET reference into
|
||||
-- existing courses. curriculum_package_items stores no content of its own, only which
|
||||
-- course_id(s) (whole courses, matching how V1's own course granularity already works) belong
|
||||
-- to the package and in what order — exactly the same "reference, never clone" principle
|
||||
-- COURSE_CONTENT_MODEL.md already established for lab activities referencing scenario_id.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS curriculum_packages (
|
||||
curriculum_package_id TEXT PRIMARY KEY, -- slug, e.g. 'winter-linux-systems-v1'
|
||||
name TEXT NOT NULL,
|
||||
program_id TEXT NOT NULL REFERENCES programs(program_id),
|
||||
description TEXT,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS curriculum_package_items (
|
||||
curriculum_package_id TEXT NOT NULL REFERENCES curriculum_packages(curriculum_package_id),
|
||||
course_id TEXT NOT NULL REFERENCES courses(course_id),
|
||||
sequence INTEGER NOT NULL,
|
||||
PRIMARY KEY (curriculum_package_id, course_id)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- offerings — connects a program (+ optional curriculum_package subset) to a delivery format,
|
||||
-- schedule, capacity, and location/organization/cohort. Status transitions are validated
|
||||
-- server-side only (offerings.js) — this table has no trigger enforcing them because the
|
||||
-- application layer is the single writer (admin routes), matching every other status-machine
|
||||
-- table in this schema (enrollments, classroom_sessions) which also rely on server-side
|
||||
-- transition logic rather than a DB trigger.
|
||||
--
|
||||
-- reserved_seats is a maintained counter, not derived via COUNT(*) at read time — capacity
|
||||
-- reservation (offerings.js's reserveSeat()) increments it with a single atomic
|
||||
-- `UPDATE ... WHERE reserved_seats < capacity RETURNING ...`, avoiding the
|
||||
-- SELECT-count-then-INSERT race explicitly flagged in this phase's authorization.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS offerings (
|
||||
offering_id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
program_id TEXT NOT NULL REFERENCES programs(program_id),
|
||||
curriculum_package_id TEXT REFERENCES curriculum_packages(curriculum_package_id),
|
||||
delivery_mode TEXT NOT NULL CHECK (delivery_mode IN ('self_paced', 'cohort', 'hybrid', 'camp', 'workshop', 'school', 'after_school', 'homeschool', 'community', 'one_to_one')),
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'enrollment_open', 'full', 'in_progress', 'completed', 'cancelled')),
|
||||
starts_at TIMESTAMPTZ,
|
||||
ends_at TIMESTAMPTZ,
|
||||
enrollment_opens_at TIMESTAMPTZ,
|
||||
enrollment_closes_at TIMESTAMPTZ,
|
||||
capacity INTEGER, -- NULL = unlimited (typical for self_paced)
|
||||
reserved_seats INTEGER NOT NULL DEFAULT 0,
|
||||
location_id TEXT REFERENCES locations(location_id),
|
||||
organization_id TEXT REFERENCES organizations(organization_id),
|
||||
cohort_id TEXT REFERENCES cohorts(cohort_id),
|
||||
recommended_min_age INTEGER, -- guidance only, never an authorization gate (Step 21)
|
||||
recommended_max_age INTEGER,
|
||||
description TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb, -- room for descriptive price/marketing fields later, never payment data
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_by UUID REFERENCES instructors(instructor_id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (capacity IS NULL OR reserved_seats <= capacity)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_offerings_program ON offerings(program_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_offerings_status ON offerings(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS instructor_offerings (
|
||||
offering_id TEXT NOT NULL REFERENCES offerings(offering_id),
|
||||
instructor_id UUID NOT NULL REFERENCES instructors(instructor_id),
|
||||
role TEXT NOT NULL DEFAULT 'teacher', -- reuses cohort_instructors' own role_in_cohort vocabulary — see admin-offerings-routes.js
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (offering_id, instructor_id)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- offering_registrations — pre-enrollment interest/application, separate from `enrollments`
|
||||
-- (academic entitlement, unchanged). A registration MAY exist with no student_id yet (a
|
||||
-- guardian registering a not-yet-created student) and is converted to a real enrollment only at
|
||||
-- `confirmed` -> `enrollment_activated`. No payment fields — that boundary belongs to a future
|
||||
-- commerce layer entirely (Step 23).
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS offering_registrations (
|
||||
registration_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
offering_id TEXT NOT NULL REFERENCES offerings(offering_id),
|
||||
student_id TEXT REFERENCES students(student_id), -- null until matched/created
|
||||
guardian_id TEXT REFERENCES guardians(guardian_id), -- who registered, if a minor
|
||||
registrant_display_name TEXT NOT NULL, -- captured even before a student record exists
|
||||
registrant_email TEXT,
|
||||
is_minor BOOLEAN NOT NULL DEFAULT false,
|
||||
status TEXT NOT NULL DEFAULT 'started' CHECK (status IN ('started', 'submitted', 'reviewed', 'agreements_pending', 'payment_pending', 'confirmed', 'enrollment_activated', 'cancelled')),
|
||||
readiness_notes TEXT,
|
||||
accessibility_notes TEXT,
|
||||
emergency_contact_name TEXT,
|
||||
emergency_contact_phone TEXT,
|
||||
enrollment_id UUID REFERENCES enrollments(enrollment_id), -- set once converted
|
||||
source TEXT NOT NULL DEFAULT 'admin',
|
||||
external_reference TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (NOT is_minor OR guardian_id IS NOT NULL) -- a minor's registration must always carry a responsible guardian
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_offering_registrations_offering ON offering_registrations(offering_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_offering_registrations_status ON offering_registrations(status);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- agreement_definitions / agreement_acceptances — a versioned ACCEPTANCE RECORD model only.
|
||||
-- No legal text is authored here (body_summary is a plain-language description for admin UI
|
||||
-- display, explicitly NOT attorney-approved legal language — see
|
||||
-- docs/STUDENT_GUARDIAN_DATA_MODEL.md's "requires legal review" list). accepted_version is
|
||||
-- captured on the acceptance row (not just looked up from the live definition) so a later
|
||||
-- edit to an agreement's text/version never retroactively reinterprets what someone actually
|
||||
-- agreed to — the same "session snapshots its own version" principle
|
||||
-- COURSE_CONTENT_MODEL.md §6b already established for assessments.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS agreement_definitions (
|
||||
agreement_id TEXT PRIMARY KEY, -- slug, e.g. 'participation-agreement'
|
||||
title TEXT NOT NULL,
|
||||
body_summary TEXT, -- plain-language description, NOT legal text
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
requires_guardian_for_minor BOOLEAN NOT NULL DEFAULT true,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agreement_acceptances (
|
||||
acceptance_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agreement_id TEXT NOT NULL REFERENCES agreement_definitions(agreement_id),
|
||||
accepted_version INTEGER NOT NULL,
|
||||
accepted_by_type TEXT NOT NULL CHECK (accepted_by_type IN ('student', 'guardian', 'instructor_on_behalf')),
|
||||
accepted_by_student_id TEXT REFERENCES students(student_id),
|
||||
accepted_by_guardian_id TEXT REFERENCES guardians(guardian_id),
|
||||
registration_id UUID REFERENCES offering_registrations(registration_id),
|
||||
enrollment_id UUID REFERENCES enrollments(enrollment_id),
|
||||
ip_address TEXT,
|
||||
accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (accepted_by_student_id IS NOT NULL OR accepted_by_guardian_id IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agreement_acceptances_registration ON agreement_acceptances(registration_id);
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Phase 8 — enrollment operations foundation: registration-stage notifications, a clean
|
||||
-- pre-account agreement-acceptance type, offering agreement requirements, and idempotent
|
||||
-- website registration submission. All additive/widening — no existing row's meaning changes,
|
||||
-- no existing constraint gets stricter. See docs/PHASE8_ENROLLMENT_OPERATIONS.md.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- notification_events — a registration may need a notification (Registration Received, Agreement
|
||||
-- Required) before any student row exists at all (a brand-new guardian-submitted registration).
|
||||
-- student_id is widened to nullable and a registration_id column added; a CHECK guarantees every
|
||||
-- row still has SOME real subject. Every existing row already has student_id set, so this is a
|
||||
-- pure widening — nothing about student-scoped notification behavior changes.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE notification_events ALTER COLUMN student_id DROP NOT NULL;
|
||||
ALTER TABLE notification_events ADD COLUMN IF NOT EXISTS registration_id UUID REFERENCES offering_registrations(registration_id);
|
||||
ALTER TABLE notification_events DROP CONSTRAINT IF EXISTS notification_events_subject_check;
|
||||
ALTER TABLE notification_events ADD CONSTRAINT notification_events_subject_check CHECK (student_id IS NOT NULL OR registration_id IS NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_events_registration ON notification_events(registration_id);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- agreement_acceptances — a 4th accepted_by_type, 'registrant', for the one real gap Phase 7's
|
||||
-- own limitations list named: an adult accepting an agreement AT REGISTRATION TIME, before their
|
||||
-- eventual student_id exists. Tied to registration_id only (no student/guardian id required) —
|
||||
-- the CHECK is widened to accept that shape too. 'student'/'guardian'/'instructor_on_behalf'
|
||||
-- (admin-recorded on someone's behalf) are unchanged and already covered Step 7's "student /
|
||||
-- guardian / admin-instructor" minimum bar; 'registrant' closes the actual documented gap.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE agreement_acceptances DROP CONSTRAINT IF EXISTS agreement_acceptances_accepted_by_type_check;
|
||||
ALTER TABLE agreement_acceptances ADD CONSTRAINT agreement_acceptances_accepted_by_type_check
|
||||
CHECK (accepted_by_type IN ('student', 'guardian', 'instructor_on_behalf', 'registrant'));
|
||||
ALTER TABLE agreement_acceptances DROP CONSTRAINT IF EXISTS agreement_acceptances_check;
|
||||
ALTER TABLE agreement_acceptances ADD CONSTRAINT agreement_acceptances_check
|
||||
CHECK (accepted_by_student_id IS NOT NULL OR accepted_by_guardian_id IS NOT NULL OR (accepted_by_type = 'registrant' AND registration_id IS NOT NULL));
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- offering_agreement_requirements — which agreements a given offering actually requires before
|
||||
-- its registrations may convert to enrollment (Step 18). A plain join table, not a new status
|
||||
-- machine — offerings.js checks it at conversion time.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS offering_agreement_requirements (
|
||||
offering_id TEXT NOT NULL REFERENCES offerings(offering_id),
|
||||
agreement_id TEXT NOT NULL REFERENCES agreement_definitions(agreement_id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (offering_id, agreement_id)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- offering_registrations — a real unique index on external_reference (nulls excluded) is what
|
||||
-- actually makes website registration submission idempotent under concurrent retries, the exact
|
||||
-- same pattern enrollments.external_event_id already established in Phase 5B — a DB-level
|
||||
-- constraint, not a SELECT-then-INSERT check.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_offering_registrations_external_reference
|
||||
ON offering_registrations(external_reference) WHERE external_reference IS NOT NULL;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Phase 8 — self-caught correction to 035_phase8_enrollment_operations.sql, found via this
|
||||
-- phase's own smoke testing before any commit: notification_events.notification_type carries a
|
||||
-- CHECK constraint (Phase 5C) that never anticipated registration-stage notification types.
|
||||
-- Every offerings.js call to notifications.enqueueForRegistration() was silently failing (caught
|
||||
-- and swallowed by the deliberate "a notification failure must never block the real action"
|
||||
-- try/catch) until this was found — no registration-stage notification had ever actually been
|
||||
-- queued. Pure widening: every existing notification_type value stays valid, nothing about
|
||||
-- Phase 5C's own four types changes.
|
||||
ALTER TABLE notification_events DROP CONSTRAINT IF EXISTS notification_events_notification_type_check;
|
||||
ALTER TABLE notification_events ADD CONSTRAINT notification_events_notification_type_check
|
||||
CHECK (notification_type IN (
|
||||
'activation', 'enrollment_confirmation', 'password_reset', 'password_changed',
|
||||
'registration_received', 'registration_confirmed', 'registration_needs_action',
|
||||
'agreement_required', 'camp_operational_update'
|
||||
));
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Phase 9 — the narrow commerce/payment-record model around Phase 8's registration system.
|
||||
-- Stripe (or any future provider) is a COMMERCE provider only — this schema is what RootNode
|
||||
-- itself understands, never a copy of Stripe's own object model. No card numbers, no CVV, no
|
||||
-- raw payment-method details anywhere in this schema; Stripe remains the PCI boundary. See
|
||||
-- docs/PHASE9_COMMERCE_AND_LAUNCH.md.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- offerings — price is nullable: a NULL price means this offering never requires checkout (the
|
||||
-- existing Phase 7/8 free-registration path is completely unchanged for it). amount is in minor
|
||||
-- currency units (cents), matching Stripe's own convention, so no float-vs-integer rounding
|
||||
-- question ever arises.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE offerings ADD COLUMN IF NOT EXISTS price_amount INTEGER; -- cents; NULL = free/no-checkout
|
||||
ALTER TABLE offerings ADD COLUMN IF NOT EXISTS price_currency TEXT NOT NULL DEFAULT 'usd';
|
||||
ALTER TABLE offerings DROP CONSTRAINT IF EXISTS offerings_price_amount_nonnegative;
|
||||
ALTER TABLE offerings ADD CONSTRAINT offerings_price_amount_nonnegative CHECK (price_amount IS NULL OR price_amount >= 0);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- offering_registrations — reservation_expires_at is set only for a registration against a
|
||||
-- PAID offering (commerce.js's own responsibility, not a DB default) — the abandoned-
|
||||
-- reservation reaper (Step 27) releases the seat and cancels the registration once this passes
|
||||
-- with no successful payment, exactly the "reserve at registration, expire if unpaid" model
|
||||
-- Step 26 asks to prove. NULL forever for a free offering — nothing new to expire there.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE offering_registrations ADD COLUMN IF NOT EXISTS reservation_expires_at TIMESTAMPTZ;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- payment_transactions — one row per checkout attempt (a registration can have more than one,
|
||||
-- e.g. a failed attempt followed by a retry). provider_reference is the provider's own opaque
|
||||
-- id (a Stripe Checkout Session id) — never a card/payment-method identifier. amount/currency
|
||||
-- are SNAPSHOTTED from the offering at checkout-creation time (matching every other
|
||||
-- "session snapshots its own terms" precedent in this schema — assessment sessions, agreement
|
||||
-- acceptances) so a later price change never reinterprets an already-created transaction.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS payment_transactions (
|
||||
transaction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
registration_id UUID NOT NULL REFERENCES offering_registrations(registration_id),
|
||||
provider TEXT NOT NULL CHECK (provider IN ('stripe', 'test')),
|
||||
provider_reference TEXT, -- e.g. Stripe Checkout Session id
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed', 'cancelled', 'refunded', 'partially_refunded')),
|
||||
amount INTEGER NOT NULL, -- cents, snapshotted from the offering at creation
|
||||
currency TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
authorized_at TIMESTAMPTZ,
|
||||
paid_at TIMESTAMPTZ,
|
||||
failed_at TIMESTAMPTZ,
|
||||
refunded_at TIMESTAMPTZ,
|
||||
cancelled_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_transactions_registration ON payment_transactions(registration_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_transactions_provider_reference ON payment_transactions(provider_reference) WHERE provider_reference IS NOT NULL;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- payment_webhook_events — the ENTIRE webhook idempotency mechanism: a real DB-level unique
|
||||
-- primary key on the provider's own event id (Stripe already guarantees these are globally
|
||||
-- unique), not a SELECT-then-process check. A duplicate delivery of the same event (Stripe
|
||||
-- explicitly documents retries as expected/normal) always tries the same INSERT, always loses
|
||||
-- the race gracefully via ON CONFLICT DO NOTHING, and is therefore always a safe no-op no
|
||||
-- matter how many times or how concurrently it arrives.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS payment_webhook_events (
|
||||
webhook_event_id TEXT PRIMARY KEY, -- the provider's own event id
|
||||
provider TEXT NOT NULL CHECK (provider IN ('stripe', 'test')),
|
||||
event_type TEXT NOT NULL,
|
||||
transaction_id UUID REFERENCES payment_transactions(transaction_id),
|
||||
result TEXT NOT NULL DEFAULT 'received' CHECK (result IN ('received', 'processed', 'ignored', 'error')),
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
processed_at TIMESTAMPTZ
|
||||
);
|
||||
Generated
+18
-1
@@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"express": "^4.19.2",
|
||||
"pg": "^8.11.5"
|
||||
"pg": "^8.11.5",
|
||||
"stripe": "^22.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
@@ -1178,6 +1179,22 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "22.6.0",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.6.0.tgz",
|
||||
"integrity": "sha512-Ezk+WYEEwX2DLxFrKfb6coznlCMZvCaCYMNPXNHFSZkBIEzF5Hgm7RLb38q/0h9Ol5jsUJGJRorvhzAvckgfhw==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
"bootstrap-owner": "node bootstrap-owner.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"express": "^4.19.2",
|
||||
"pg": "^8.11.5",
|
||||
"better-sqlite3": "^9.4.3"
|
||||
"stripe": "^22.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// Phase 7 — admin/owner routes for organizations, locations, guardians, curriculum packages,
|
||||
// offerings, registrations, and agreements. RBAC convention matches admin-programs-routes.js:
|
||||
// authoring (create/transition) is admin/owner only; any instructor can list/view non-sensitive
|
||||
// data (organizations, locations, offerings). Guardian and registration detail are admin/owner
|
||||
// only — Step 17's explicit "plain instructors should not automatically get access to guardian/
|
||||
// private registration details" — a plain instructor still sees offering rosters/progress
|
||||
// through the existing cohort-scoped routes, just not guardian contact info or registration
|
||||
// notes (readiness/accessibility/emergency-contact) directly.
|
||||
|
||||
const offerings = require('./offerings');
|
||||
const notifications = require('./notifications');
|
||||
const commerce = require('./commerce');
|
||||
const studentsAuth = require('./students-auth'); // same direct-require convention integration-routes.js already uses
|
||||
|
||||
function errStatus(code) {
|
||||
if (code === 'NOT_FOUND' || code === 'PROGRAM_NOT_FOUND' || code === 'OFFERING_NOT_FOUND' || code === 'COURSE_NOT_FOUND' || code === 'AGREEMENT_NOT_FOUND') return 404;
|
||||
if (code === 'VALIDATION' || code === 'GUARDIAN_REQUIRED') return 400;
|
||||
if (code === 'DUPLICATE_SLUG' || code === 'OFFERING_FULL' || code === 'INVALID_TRANSITION' || code === 'INVALID_STATE'
|
||||
|| code === 'OFFERING_NOT_OPEN' || code === 'OFFERING_CANCELLED' || code === 'OFFERING_COMPLETED'
|
||||
|| code === 'REGISTRATION_NOT_YET_OPEN' || code === 'REGISTRATION_CLOSED' || code === 'DUPLICATE_REGISTRATION'
|
||||
|| code === 'AGREEMENTS_INCOMPLETE' || code === 'NO_PRICE_CONFIGURED') return 409;
|
||||
if (code === 'PROVIDER_NOT_CONFIGURED') return 503;
|
||||
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)),
|
||||
patch: (p, ...mw) => app.patch(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);
|
||||
});
|
||||
// Phase 9 §28 — the one route Chase actually uses to fill in dates/venue/capacity/price once
|
||||
// those real business decisions are made, on an offering that already exists as a draft.
|
||||
admin.patch('/api/admin/offerings/:id', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const offering = await offerings.updateOffering(getPool(), req.params.id, req.body || {});
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'offering_update', resourceType: 'offering', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { fields: Object.keys(req.body || {}) } });
|
||||
res.json(offering);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.post('/api/admin/offerings/:id/transition', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const offering = await offerings.transitionOffering(getPool(), req.params.id, req.body.status);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'offering_transition', resourceType: 'offering', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { to: req.body.status } });
|
||||
res.json(offering);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.post('/api/admin/offerings/:id/instructors', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const link = await offerings.assignInstructorToOffering(getPool(), req.params.id, req.body.instructor_id, req.body.role);
|
||||
res.status(201).json(link);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/offerings/:id/readiness', requireAnyInstructor, async (req, res) => {
|
||||
try {
|
||||
res.json(await offerings.computeOfferingReadiness(getPool(), req.params.id));
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.post('/api/admin/offerings/:id/agreement-requirements', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const link = await offerings.addAgreementRequirement(getPool(), req.params.id, req.body.agreement_id);
|
||||
res.status(201).json(link);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/offerings/:id/agreement-requirements', requireAnyInstructor, async (req, res) => {
|
||||
res.json(await offerings.listAgreementRequirementsForOffering(getPool(), req.params.id));
|
||||
});
|
||||
|
||||
// ── Registrations (admin/owner only) ────────────────────────────────────────────────────
|
||||
// offerings.createRegistration() itself now does validate -> reserve -> insert -> notify, in
|
||||
// that exact order (see its own header comment) — no route-level orchestration needed here
|
||||
// any more.
|
||||
admin.post('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const reg = await offerings.createRegistration(getPool(), { ...req.body, offeringId: req.params.id });
|
||||
res.status(201).json(reg);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => {
|
||||
res.json(await offerings.listRegistrationsForOffering(getPool(), req.params.id));
|
||||
});
|
||||
admin.get('/api/admin/registrations/:id', requireAdminOrOwner, async (req, res) => {
|
||||
const reg = await offerings.getRegistration(getPool(), req.params.id);
|
||||
if (!reg) return res.status(404).json({ error: 'NOT_FOUND' });
|
||||
res.json(reg);
|
||||
});
|
||||
admin.get('/api/admin/registrations/:id/agreement-status', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
res.json(await offerings.getRegistrationAgreementStatus(getPool(), req.params.id));
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/registrations/:id/notifications', requireAdminOrOwner, async (req, res) => {
|
||||
const rows = await notifications.listForRegistration(getPool(), req.params.id);
|
||||
res.json(rows.map(notifications.toAdminView));
|
||||
});
|
||||
// Phase 9 §18/§19 — payment visibility. The row itself never contains card data (only
|
||||
// amount/currency/status/timestamps/the provider's own opaque reference), so it's safe to
|
||||
// return whole — admin/owner only, same as every other registration-detail surface.
|
||||
admin.get('/api/admin/registrations/:id/payments', requireAdminOrOwner, async (req, res) => {
|
||||
res.json(await commerce.listTransactionsForRegistration(getPool(), req.params.id));
|
||||
});
|
||||
admin.post('/api/admin/payments/:id/refund', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const transaction = await commerce.recordManualRefund(getPool(), req.params.id, req.body?.status || 'refunded');
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'refund_recorded', resourceType: 'payment_transaction', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { status: transaction.status } });
|
||||
res.json(transaction);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.post('/api/admin/registrations/:id/transition', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const reg = await offerings.transitionRegistration(getPool(), req.params.id, req.body.status);
|
||||
res.json(reg);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.post('/api/admin/registrations/:id/convert', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const result = await offerings.convertRegistrationToEnrollment(getPool(), studentsAuth, req.params.id, req.body || {});
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'registration_convert', resourceType: 'registration', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.status(201).json(result);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
|
||||
// ── Agreements ────────────────────────────────────────────────────────────────────────────
|
||||
admin.post('/api/admin/agreements', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const def = await offerings.createAgreementDefinition(getPool(), req.body);
|
||||
res.status(201).json(def);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/agreements', requireAnyInstructor, async (req, res) => {
|
||||
res.json(await offerings.listAgreementDefinitions(getPool()));
|
||||
});
|
||||
admin.post('/api/admin/registrations/:id/agreements', requireAdminOrOwner, async (req, res) => {
|
||||
try {
|
||||
const acceptance = await offerings.recordAgreementAcceptance(getPool(), { ...req.body, registrationId: req.params.id, ipAddress: req.ip });
|
||||
res.status(201).json(acceptance);
|
||||
} catch (e) { handleError(res, e); }
|
||||
});
|
||||
admin.get('/api/admin/registrations/:id/agreements', requireAdminOrOwner, async (req, res) => {
|
||||
res.json(await offerings.listAcceptancesForRegistration(getPool(), req.params.id));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
// Phase 9 — checkout/payment business logic. Sits ABOVE offerings.js (registration/agreement/
|
||||
// conversion) and payment-provider.js (the Stripe/test abstraction) — this file is the only
|
||||
// place either one gets combined with the other. Same error convention as offerings.js/
|
||||
// enrollment.js: throw `Error` with a `.code` the route layer maps to an HTTP status.
|
||||
|
||||
const offerings = require('./offerings');
|
||||
const notificationsLib = require('./notifications');
|
||||
const metrics = require('./metrics');
|
||||
|
||||
const RESERVATION_TIMEOUT_MINUTES = Number(process.env.REGISTRATION_RESERVATION_TIMEOUT_MINUTES) || 30;
|
||||
|
||||
function err(code, message) {
|
||||
return Object.assign(new Error(message || code), { code });
|
||||
}
|
||||
|
||||
// ── Checkout creation (Step 5/6) ─────────────────────────────────────────────────────────────
|
||||
// Verifies, in order: registration exists and hasn't already gone past the point checkout makes
|
||||
// sense; the offering actually has a price configured (a free offering never needs this at
|
||||
// all); registration is still open (mirrors offerings.js's own assertRegistrationOpen, since an
|
||||
// offering can close between registration and checkout); every required agreement is already
|
||||
// accepted (Step 6's explicit "before checkout"). amount/currency ALWAYS come from the
|
||||
// offering's own server-side price — never from the caller, closing Step 5's explicit
|
||||
// "browser cannot choose price" requirement structurally, not just by convention.
|
||||
async function createCheckoutSession(pool, provider, registrationId, { successUrl, cancelUrl }) {
|
||||
const reg = await offerings.getRegistration(pool, registrationId);
|
||||
if (!reg) throw err('NOT_FOUND', 'No such registration.');
|
||||
if (['enrollment_activated', 'cancelled'].includes(reg.status)) throw err('INVALID_STATE', `A registration in status '${reg.status}' cannot start checkout.`);
|
||||
|
||||
const offering = await offerings.getOffering(pool, reg.offering_id);
|
||||
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
|
||||
if (offering.price_amount === null) throw err('NO_PRICE_CONFIGURED', 'This offering has no price configured — it does not require checkout.');
|
||||
offerings.assertRegistrationOpen(offering);
|
||||
|
||||
const agreementStatus = await offerings.getRegistrationAgreementStatus(pool, registrationId);
|
||||
if (agreementStatus.missing.length) {
|
||||
throw err('AGREEMENTS_INCOMPLETE', `Missing required agreement(s): ${agreementStatus.missing.map((m) => m.title).join(', ')}`);
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO payment_transactions (registration_id, provider, status, amount, currency)
|
||||
VALUES ($1, $2, 'pending', $3, $4) RETURNING *`,
|
||||
[registrationId, provider.name, offering.price_amount, offering.price_currency]
|
||||
);
|
||||
const transaction = rows[0];
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = await provider.createCheckoutSession({
|
||||
amountCents: offering.price_amount, currency: offering.price_currency, productName: offering.name,
|
||||
successUrl, cancelUrl, metadata: { registration_id: registrationId, transaction_id: transaction.transaction_id },
|
||||
});
|
||||
} catch (e) {
|
||||
await pool.query(`UPDATE payment_transactions SET status = 'failed', failed_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
|
||||
metrics.recordCheckoutSession('error');
|
||||
throw e;
|
||||
}
|
||||
|
||||
await pool.query(`UPDATE payment_transactions SET provider_reference = $2, updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id, session.providerReference]);
|
||||
metrics.recordCheckoutSession('created');
|
||||
return { transactionId: transaction.transaction_id, checkoutUrl: session.checkoutUrl, providerReference: session.providerReference };
|
||||
}
|
||||
|
||||
// ── Webhook processing (Step 8/9/10/11) — idempotent by construction ────────────────────────
|
||||
// The INSERT into payment_webhook_events is the entire idempotency mechanism (a real unique
|
||||
// primary key on the provider's own event id, ON CONFLICT DO NOTHING) — if this call reports
|
||||
// `duplicate`, NOTHING below it ever runs, so a payment can never be double-recorded, an
|
||||
// enrollment can never be double-converted, and a notification can never be double-sent no
|
||||
// matter how many times (or how concurrently) the same event arrives.
|
||||
async function processWebhookEvent(pool, studentsAuth, provider, normalizedEvent) {
|
||||
const { eventId, eventType, providerReference, normalizedStatus } = normalizedEvent;
|
||||
|
||||
const { rows: inserted } = await pool.query(
|
||||
`INSERT INTO payment_webhook_events (webhook_event_id, provider, event_type) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (webhook_event_id) DO NOTHING RETURNING *`,
|
||||
[eventId, provider.name, eventType]
|
||||
);
|
||||
if (!inserted[0]) {
|
||||
metrics.recordWebhook(eventType, 'duplicate');
|
||||
return { result: 'duplicate' };
|
||||
}
|
||||
|
||||
if (!normalizedStatus) {
|
||||
await pool.query(`UPDATE payment_webhook_events SET result = 'ignored', processed_at = now() WHERE webhook_event_id = $1`, [eventId]);
|
||||
metrics.recordWebhook(eventType, 'ignored');
|
||||
return { result: 'ignored' };
|
||||
}
|
||||
|
||||
// Phase 9.1 real-Stripe-TEST fix — providerReference is usually the Checkout Session id, but
|
||||
// for payment_intent.payment_failed/charge.refunded StripeProvider.normalizeEvent() instead
|
||||
// returns OUR OWN transaction_id (there is no other reliable Stripe-side reference for those
|
||||
// event types back to the Session-keyed row) — match either column, never a schema change.
|
||||
const { rows: txRows } = await pool.query(
|
||||
`SELECT * FROM payment_transactions WHERE provider_reference = $1 OR transaction_id::text = $1`, [providerReference]
|
||||
);
|
||||
const transaction = txRows[0];
|
||||
if (!transaction) {
|
||||
await pool.query(`UPDATE payment_webhook_events SET result = 'error', processed_at = now() WHERE webhook_event_id = $1`, [eventId]);
|
||||
metrics.recordWebhook(eventType, 'error');
|
||||
return { result: 'error', reason: 'UNKNOWN_TRANSACTION' };
|
||||
}
|
||||
|
||||
await pool.query(`UPDATE payment_webhook_events SET transaction_id = $2, result = 'processed', processed_at = now() WHERE webhook_event_id = $1`, [eventId, transaction.transaction_id]);
|
||||
|
||||
if (normalizedStatus === 'paid') await handlePaymentSuccess(pool, studentsAuth, transaction);
|
||||
else if (normalizedStatus === 'failed') await handlePaymentFailure(pool, transaction);
|
||||
else if (normalizedStatus === 'cancelled') await handleCheckoutAbandoned(pool, transaction);
|
||||
else if (normalizedStatus === 'refunded' || normalizedStatus === 'partially_refunded') await handleRefund(pool, transaction, normalizedStatus);
|
||||
|
||||
metrics.recordWebhook(eventType, 'processed');
|
||||
metrics.recordPayment(normalizedStatus, provider.name);
|
||||
return { result: 'processed', normalizedStatus };
|
||||
}
|
||||
|
||||
// paid -> transaction paid -> registration confirmed -> the EXACT existing Phase 8 conversion
|
||||
// flow (never duplicated here) -> notifications. Reuses offerings.transitionRegistration/
|
||||
// convertRegistrationToEnrollment exactly as an admin action already would.
|
||||
async function handlePaymentSuccess(pool, studentsAuth, transaction) {
|
||||
await pool.query(`UPDATE payment_transactions SET status = 'paid', paid_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
|
||||
const reg = await offerings.getRegistration(pool, transaction.registration_id);
|
||||
if (!reg || ['enrollment_activated', 'cancelled'].includes(reg.status)) { metrics.recordConversion('skipped'); return; }
|
||||
|
||||
// Walk the registration up to 'confirmed' through whatever real transitions are actually
|
||||
// still needed — a website submission may already be 'submitted'/'agreements_pending', an
|
||||
// admin-entered one might still be 'started'. Only ever moves FORWARD through the same
|
||||
// server-defined transition table offerings.js already enforces, never skips a real state.
|
||||
const path = { started: 'submitted', submitted: 'agreements_pending', agreements_pending: 'confirmed', reviewed: 'confirmed', payment_pending: 'confirmed' };
|
||||
let current = reg.status;
|
||||
while (current !== 'confirmed' && path[current]) {
|
||||
await offerings.transitionRegistration(pool, transaction.registration_id, path[current]);
|
||||
current = path[current];
|
||||
}
|
||||
if (current !== 'confirmed') { metrics.recordConversion('blocked'); return; } // e.g. AGREEMENTS_INCOMPLETE would have thrown above already, but stay defensive
|
||||
|
||||
try {
|
||||
// No separate "registration confirmed" notification here — transitionRegistration('confirmed')
|
||||
// above already fired that one (Phase 8's own logic). The "you're enrolled, here's how to
|
||||
// log in" signal comes from convertRegistrationToEnrollment's own notifyForEnrollment call
|
||||
// (Phase 9's fix — see that function's comment) — sending both would be a real, needless
|
||||
// duplicate email to the same family for the same underlying event.
|
||||
await offerings.convertRegistrationToEnrollment(pool, studentsAuth, transaction.registration_id, {});
|
||||
metrics.recordConversion('success');
|
||||
} catch (e) {
|
||||
metrics.recordConversion('error');
|
||||
console.error('[commerce] enrollment conversion after payment failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaymentFailure(pool, transaction) {
|
||||
await pool.query(`UPDATE payment_transactions SET status = 'failed', failed_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
|
||||
const reg = await offerings.getRegistration(pool, transaction.registration_id);
|
||||
if (reg) {
|
||||
await notificationsLib.enqueueForRegistration(pool, {
|
||||
registration: reg, notificationType: 'registration_needs_action',
|
||||
metadata: { display_name: reg.registrant_display_name, offering_name: (await offerings.getOffering(pool, reg.offering_id))?.name, reason: 'Your payment did not go through — please try again.' },
|
||||
}).catch((e) => console.error('[commerce] payment-failure notification failed:', e.message));
|
||||
}
|
||||
// Deliberately does NOT release the seat or cancel the registration — a failed payment
|
||||
// attempt is not the same as an abandoned one; the registrant may simply retry checkout with
|
||||
// the same reservation still held. See handleCheckoutAbandoned/the reaper for actual release.
|
||||
}
|
||||
|
||||
// A checkout that expired without ever completing (Stripe's own checkout.session.expired, or
|
||||
// the reaper's timeout below) — release the seat, cancel the registration. Never touches a
|
||||
// PAID registration (both callers only ever act on a still-pending transaction/registration).
|
||||
async function handleCheckoutAbandoned(pool, transaction) {
|
||||
await pool.query(`UPDATE payment_transactions SET status = 'cancelled', cancelled_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
|
||||
const reg = await offerings.getRegistration(pool, transaction.registration_id);
|
||||
if (reg && !['enrollment_activated', 'cancelled'].includes(reg.status)) {
|
||||
await offerings.transitionRegistration(pool, transaction.registration_id, 'cancelled'); // releases the seat internally
|
||||
metrics.recordSeatReleased('checkout_expired');
|
||||
}
|
||||
}
|
||||
|
||||
// Refund recording only — Step 11's explicit "do not automatically erase academic history."
|
||||
// Commerce status (this table) is fully separate from registration status (offering_
|
||||
// registrations.status) and academic enrollment status (enrollments.status) — a refund NEVER
|
||||
// cascades into cancelling either of those automatically; that remains a real, separate
|
||||
// business decision an admin makes deliberately (see docs/PHASE9_COMMERCE_AND_LAUNCH.md's
|
||||
// "three separate statuses" section).
|
||||
async function handleRefund(pool, transaction, status) {
|
||||
const column = status === 'refunded' ? 'refunded_at' : null;
|
||||
await pool.query(
|
||||
`UPDATE payment_transactions SET status = $2, ${column ? `refunded_at = now(),` : ''} updated_at = now() WHERE transaction_id = $1`,
|
||||
[transaction.transaction_id, status]
|
||||
);
|
||||
}
|
||||
|
||||
// ── Admin-recorded refund. No automated Stripe refund call this phase — Step 11 explicitly
|
||||
// scopes this to RECORDING a refund that happened, not building a refund-management product. ──
|
||||
async function recordManualRefund(pool, transactionId, status) {
|
||||
if (!['refunded', 'partially_refunded'].includes(status)) throw err('VALIDATION', "status must be 'refunded' or 'partially_refunded'.");
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE payment_transactions SET status = $2, refunded_at = now(), updated_at = now() WHERE transaction_id = $1 AND status = 'paid' RETURNING *`,
|
||||
[transactionId, status]
|
||||
);
|
||||
if (!rows[0]) throw err('INVALID_STATE', 'Only a paid transaction can be marked refunded.');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// ── Abandoned reservation reaper (Step 27) ───────────────────────────────────────────────────
|
||||
// Finds registrations whose reservation window has passed with no successful payment, and
|
||||
// releases them exactly the way an expired checkout would — same function, same audit
|
||||
// footprint. Never touches a registration already enrollment_activated or cancelled (the
|
||||
// WHERE clause excludes both), so a paid registration can never be swept up here regardless of
|
||||
// timing.
|
||||
async function reapExpiredReservations(pool) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM offering_registrations
|
||||
WHERE reservation_expires_at IS NOT NULL AND reservation_expires_at < now()
|
||||
AND status NOT IN ('enrollment_activated', 'cancelled', 'confirmed')`
|
||||
);
|
||||
let released = 0;
|
||||
for (const reg of rows) {
|
||||
await offerings.transitionRegistration(pool, reg.registration_id, 'cancelled');
|
||||
released++;
|
||||
metrics.recordSeatReleased('reservation_expired');
|
||||
}
|
||||
return { checked: rows.length, released };
|
||||
}
|
||||
|
||||
async function listTransactionsForRegistration(pool, registrationId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM payment_transactions WHERE registration_id = $1 ORDER BY created_at DESC`, [registrationId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RESERVATION_TIMEOUT_MINUTES, createCheckoutSession, processWebhookEvent, recordManualRefund,
|
||||
reapExpiredReservations, listTransactionsForRegistration,
|
||||
};
|
||||
@@ -131,4 +131,76 @@ If you didn't make this change, contact your instructor right away — your othe
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
module.exports = { activation, enrollmentConfirmation, passwordReset, passwordChanged, escapeHtml };
|
||||
// Phase 8 §10 — registration-stage transactional templates. Same conventions as above:
|
||||
// escapeHtml on every caller-supplied value (offeringName/displayName can come from a website
|
||||
// registration submission, not just admin-typed text), no tracking, no marketing content, both
|
||||
// text and html written deliberately.
|
||||
|
||||
function registrationReceived({ displayName, offeringName }) {
|
||||
const subject = `We Received Your Registration — ${offeringName}`;
|
||||
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName);
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
We received your registration for ${offeringName} at ${BRAND}. We'll follow up if anything else is needed before it's confirmed.
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`<p>Hi ${safeName},</p><p>We received your registration for <strong>${safeOffering}</strong> at ${BRAND}. We'll follow up if anything else is needed before it's confirmed.</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
function registrationConfirmed({ displayName, offeringName }) {
|
||||
const subject = `Registration Confirmed — ${offeringName}`;
|
||||
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName);
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
Your registration for ${offeringName} is confirmed. You'll receive a separate email with account access details shortly.
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`<p>Hi ${safeName},</p><p>Your registration for <strong>${safeOffering}</strong> is confirmed. You'll receive a separate email with account access details shortly.</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
function registrationNeedsAction({ displayName, offeringName, reason }) {
|
||||
const subject = `Action Needed — ${offeringName} Registration`;
|
||||
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName), safeReason = escapeHtml(reason || 'Please contact us for details.');
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
Your registration for ${offeringName} needs one more thing before it can be confirmed: ${reason || 'please contact us for details.'}
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`<p>Hi ${safeName},</p><p>Your registration for <strong>${safeOffering}</strong> needs one more thing before it can be confirmed:</p><p>${safeReason}</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
function agreementRequired({ displayName, offeringName, agreementTitle }) {
|
||||
const subject = `Agreement Required — ${offeringName}`;
|
||||
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName), safeAgreement = escapeHtml(agreementTitle);
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
Before your registration for ${offeringName} can be confirmed, we need your acceptance of: ${agreementTitle}. Your instructor/admin contact can help you complete this.
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`<p>Hi ${safeName},</p><p>Before your registration for <strong>${safeOffering}</strong> can be confirmed, we need your acceptance of:</p><p><strong>${safeAgreement}</strong></p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
// Operational notice only (e.g. "Camp starts Monday, here's what to bring") — deliberately NOT
|
||||
// a marketing template. See docs/REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md's boundary note.
|
||||
function campOperationalUpdate({ displayName, offeringName, message }) {
|
||||
const subject = `${offeringName} — Update`;
|
||||
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName), safeMessage = escapeHtml(message);
|
||||
const text = `Hi ${displayName},
|
||||
|
||||
An update about ${offeringName}:
|
||||
|
||||
${message}
|
||||
|
||||
— ${BRAND}`;
|
||||
const html = wrap(`<p>Hi ${safeName},</p><p>An update about <strong>${safeOffering}</strong>:</p><p>${safeMessage}</p>`);
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
activation, enrollmentConfirmation, passwordReset, passwordChanged, escapeHtml,
|
||||
registrationReceived, registrationConfirmed, registrationNeedsAction, agreementRequired, campOperationalUpdate,
|
||||
};
|
||||
|
||||
@@ -146,7 +146,14 @@ async function createFromIntegrationEvent(pool, studentsAuth, { eventId, source,
|
||||
// handed back so a future email/notification step — not built in this phase — could relay it).
|
||||
// A second concurrent call resolving the "same new student" case falls back to re-matching
|
||||
// after a unique-constraint hit, the identical insert/on-conflict-reselect pattern used above.
|
||||
async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName }) {
|
||||
// `returnToken` (default false) attaches the raw activation token to the returned row as
|
||||
// `_activationToken` when a NEW student is actually created. Default OFF and never passed by
|
||||
// createFromIntegrationEvent — the public website integration endpoint's own contract
|
||||
// (docs/WEBSITE_ENROLLMENT_INTEGRATION.md) explicitly never returns this value; only Phase 7's
|
||||
// admin-only registration-conversion route (offerings.js) opts in, the same trust level an
|
||||
// instructor manually creating a student already has via POST /api/admin/students (which
|
||||
// already returns activation_token directly).
|
||||
async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName }, { returnToken = false } = {}) {
|
||||
const identifier = studentEmail || studentLoginIdentifier;
|
||||
if (!identifier) throw err('STUDENT_IDENTIFIER_REQUIRED', 'student.email or student.login_identifier is required.');
|
||||
const existing = await studentsAuth.getStudentByIdentifier(pool, identifier);
|
||||
@@ -160,8 +167,8 @@ async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studen
|
||||
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[studentId, displayName, studentEmail ? String(studentEmail).toLowerCase().trim() : null, studentLoginIdentifier ? String(studentLoginIdentifier).trim() : null]
|
||||
);
|
||||
await studentsAuth.issueCredentialToken(pool, studentId, 'activation'); // token itself isn't returned by the integration endpoint — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md
|
||||
return rows[0];
|
||||
const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation'); // token itself isn't returned by the integration endpoint — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md
|
||||
return returnToken ? { ...rows[0], _activationToken: token } : rows[0];
|
||||
} catch (e) {
|
||||
if (e.code === '23505') {
|
||||
const reMatched = await studentsAuth.getStudentByIdentifier(pool, identifier);
|
||||
@@ -241,4 +248,8 @@ module.exports = {
|
||||
deriveEnrollmentAccess, getEnrollment, listEnrollmentsForStudent, listEnrollmentsForProgram,
|
||||
createManualEnrollment, createFromIntegrationEvent, activateEnrollment, cancelEnrollment,
|
||||
maybeCompleteEnrollment, enrollmentContactEmail,
|
||||
// Phase 7 — offerings.js's registration-to-enrollment conversion reuses this exact
|
||||
// create-or-match logic rather than duplicating it (a registration's eventual student is
|
||||
// resolved the same way an integration-event or manual enrollment's student already is).
|
||||
resolveOrCreateStudent,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,9 @@ const enrollment = require('./enrollment');
|
||||
const studentsAuth = require('./students-auth');
|
||||
const notifications = require('./notifications');
|
||||
const programsLib = require('./programs');
|
||||
const offerings = require('./offerings');
|
||||
const commerce = require('./commerce');
|
||||
const { getPaymentProvider } = require('./payment-provider');
|
||||
const metrics = require('./metrics');
|
||||
|
||||
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
|
||||
@@ -36,6 +39,17 @@ function enrollmentErrorStatus(code) {
|
||||
return 500;
|
||||
}
|
||||
|
||||
// Phase 8 §13 — website registration submission. Same auth boundary and same file as
|
||||
// /api/integrations/enrollments above (Step 15's "prefer server-to-server, don't introduce a
|
||||
// second scheme") — a registration is an earlier, separate step from an enrollment (see
|
||||
// docs/OFFERING_AND_REGISTRATION_MODEL.md), not a replacement for the existing endpoint.
|
||||
function registrationErrorStatus(code) {
|
||||
if (code === 'OFFERING_NOT_FOUND') return 404;
|
||||
if (code === 'EVENT_ID_REQUIRED' || code === 'OFFERING_SLUG_REQUIRED' || code === 'VALIDATION' || code === 'GUARDIAN_REQUIRED') return 400;
|
||||
if (['OFFERING_NOT_OPEN', 'OFFERING_CANCELLED', 'OFFERING_COMPLETED', 'OFFERING_FULL', 'REGISTRATION_NOT_YET_OPEN', 'REGISTRATION_CLOSED'].includes(code)) return 409;
|
||||
return 500;
|
||||
}
|
||||
|
||||
module.exports = function registerIntegrationRoutes(app, deps) {
|
||||
const { getPool, recordAudit, wrap } = deps;
|
||||
|
||||
@@ -114,4 +128,109 @@ module.exports = function registerIntegrationRoutes(app, deps) {
|
||||
created: result.created,
|
||||
});
|
||||
}));
|
||||
|
||||
// Phase 8 §13/§14/§15 — the website's registration submission endpoint. Same shared-secret
|
||||
// header as above, same "server-to-server only, never browser JS" boundary. Idempotent via
|
||||
// event_id -> offering_registrations.external_reference (a real unique index,
|
||||
// 035_phase8_...sql), the identical pattern /api/integrations/enrollments already uses.
|
||||
app.post('/api/integrations/registrations', wrap(async (req, res) => {
|
||||
const key = req.get('X-RootNode-Integration-Key');
|
||||
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) {
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
const { event_id, source, offering_slug, registrant, guardian, readiness_notes } = req.body || {};
|
||||
let result;
|
||||
try {
|
||||
result = await offerings.createRegistrationFromIntegrationEvent(getPool(), {
|
||||
eventId: event_id, offeringSlug: offering_slug, source: source || 'website',
|
||||
registrantDisplayName: registrant?.display_name, registrantEmail: registrant?.email, isMinor: !!registrant?.is_minor,
|
||||
guardianDisplayName: guardian?.display_name, guardianEmail: guardian?.email, guardianPhone: guardian?.phone,
|
||||
readinessNotes: readiness_notes,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.code) return res.status(registrationErrorStatus(e.code)).json({ error: e.code, message: e.message });
|
||||
throw e;
|
||||
}
|
||||
await recordAudit(getPool(), {
|
||||
actorType: 'integration', actorId: source || 'website',
|
||||
action: result.created ? 'registration_integration_created' : 'registration_integration_duplicate',
|
||||
resourceType: 'registration', resourceId: result.registration.registration_id, result: 'success', sourceIp: req.ip,
|
||||
metadata: { event_id, offering_slug },
|
||||
});
|
||||
res.status(result.created ? 201 : 200).json({
|
||||
registration_id: result.registration.registration_id,
|
||||
offering_id: result.registration.offering_id,
|
||||
status: result.registration.status,
|
||||
created: result.created,
|
||||
});
|
||||
}));
|
||||
|
||||
// Phase 9 §6 — closes the one gap Phase 8 explicitly left open: agreement acceptance is now
|
||||
// reachable from the public registration contract. An adult accepts as 'registrant' (their
|
||||
// own student_id doesn't exist yet); a minor's guardian accepts as 'guardian', using the
|
||||
// guardian_id the registration already carries from submission. Same shared-secret boundary
|
||||
// as every other route in this file.
|
||||
app.post('/api/integrations/registrations/:id/agreements', wrap(async (req, res) => {
|
||||
const key = req.get('X-RootNode-Integration-Key');
|
||||
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) return res.status(401).json({ error: 'unauthorized' });
|
||||
const registration = await offerings.getRegistration(getPool(), req.params.id);
|
||||
if (!registration) return res.status(404).json({ error: 'NOT_FOUND' });
|
||||
const acceptedByType = registration.is_minor ? 'guardian' : 'registrant';
|
||||
try {
|
||||
const acceptance = await offerings.recordAgreementAcceptance(getPool(), {
|
||||
agreementId: req.body?.agreement_id, acceptedByType,
|
||||
acceptedByGuardianId: registration.is_minor ? registration.guardian_id : undefined,
|
||||
registrationId: registration.registration_id, ipAddress: req.ip,
|
||||
});
|
||||
await recordAudit(getPool(), { actorType: 'integration', actorId: 'website', action: 'agreement_accepted', resourceType: 'registration', resourceId: registration.registration_id, result: 'success', sourceIp: req.ip, metadata: { agreement_id: req.body?.agreement_id } });
|
||||
res.status(201).json({ accepted: true, agreement_id: acceptance.agreement_id, accepted_version: acceptance.accepted_version });
|
||||
} catch (e) {
|
||||
if (e.code) return res.status(e.code === 'AGREEMENT_NOT_FOUND' ? 404 : 400).json({ error: e.code, message: e.message });
|
||||
throw e;
|
||||
}
|
||||
}));
|
||||
|
||||
// Phase 9 §5 — server-to-server checkout creation. The website's backend calls this with a
|
||||
// registration_id it already has (from the registration-submission response above); the
|
||||
// browser never talks to this endpoint directly, and never supplies an amount/currency —
|
||||
// both always come from the offering's own server-side price (commerce.js's own
|
||||
// createCheckoutSession, Step 5's explicit "browser cannot choose price" requirement).
|
||||
app.post('/api/integrations/registrations/:id/checkout', wrap(async (req, res) => {
|
||||
const key = req.get('X-RootNode-Integration-Key');
|
||||
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) return res.status(401).json({ error: 'unauthorized' });
|
||||
const { success_url, cancel_url } = req.body || {};
|
||||
if (!success_url || !cancel_url) return res.status(400).json({ error: 'VALIDATION', message: 'success_url and cancel_url are required.' });
|
||||
try {
|
||||
const provider = getPaymentProvider();
|
||||
const result = await commerce.createCheckoutSession(getPool(), provider, req.params.id, { successUrl: success_url, cancelUrl: cancel_url });
|
||||
await recordAudit(getPool(), { actorType: 'integration', actorId: 'website', action: 'checkout_created', resourceType: 'registration', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { transaction_id: result.transactionId } });
|
||||
res.status(201).json({ checkout_url: result.checkoutUrl, transaction_id: result.transactionId });
|
||||
} catch (e) {
|
||||
if (e.code) {
|
||||
const status = { NOT_FOUND: 404, OFFERING_NOT_FOUND: 404, NO_PRICE_CONFIGURED: 409, INVALID_STATE: 409, AGREEMENTS_INCOMPLETE: 409, OFFERING_NOT_OPEN: 409, OFFERING_CANCELLED: 409, OFFERING_COMPLETED: 409, OFFERING_FULL: 409, REGISTRATION_NOT_YET_OPEN: 409, REGISTRATION_CLOSED: 409, PROVIDER_NOT_CONFIGURED: 503 }[e.code] || 500;
|
||||
return res.status(status).json({ error: e.code, message: e.message });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}));
|
||||
|
||||
// Phase 9 §16/§17 — a safe status lookup for the website's post-checkout success page. NEVER
|
||||
// derive "paid" from a URL param the browser controls (a returning `?paid=true` proves
|
||||
// nothing) — the website's backend polls this instead, server-to-server, same shared secret,
|
||||
// until it sees a terminal state. Deliberately minimal: no PII, no payment amount, no provider
|
||||
// reference, nothing an admin view would show — just enough to render "processing" /
|
||||
// "confirmed" / "action needed" on the success page.
|
||||
app.get('/api/integrations/registrations/:id/status', wrap(async (req, res) => {
|
||||
const key = req.get('X-RootNode-Integration-Key');
|
||||
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) return res.status(401).json({ error: 'unauthorized' });
|
||||
const registration = await offerings.getRegistration(getPool(), req.params.id);
|
||||
if (!registration) return res.status(404).json({ error: 'NOT_FOUND' });
|
||||
const transactions = await commerce.listTransactionsForRegistration(getPool(), registration.registration_id);
|
||||
res.json({
|
||||
registration_id: registration.registration_id,
|
||||
status: registration.status,
|
||||
payment_status: transactions[0]?.status || null,
|
||||
enrollment_activated: registration.status === 'enrollment_activated',
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -41,6 +41,30 @@ function recordStudentActivation(result) {
|
||||
studentActivationTotals[result] = (studentActivationTotals[result] || 0) + 1;
|
||||
}
|
||||
|
||||
// Phase 9 §22 — commerce metrics. Every label here is a small, fixed enum (result/status/
|
||||
// provider/event_type) — never a registration_id, customer_id, payment_intent_id, or email, per
|
||||
// the explicit "avoid high-cardinality" requirement.
|
||||
const checkoutSessionTotals = {}; // result: 'created' | 'error'
|
||||
function recordCheckoutSession(result) { checkoutSessionTotals[result] = (checkoutSessionTotals[result] || 0) + 1; }
|
||||
|
||||
const paymentTotals = {}; // key: `${status}|${provider}`
|
||||
function recordPayment(status, provider) {
|
||||
const key = `${status}|${provider}`;
|
||||
paymentTotals[key] = (paymentTotals[key] || 0) + 1;
|
||||
}
|
||||
|
||||
const webhookTotals = {}; // key: `${event_type}|${result}` — event_type is a small, fixed provider vocabulary, never caller-supplied free text
|
||||
function recordWebhook(eventType, result) {
|
||||
const key = `${eventType}|${result}`;
|
||||
webhookTotals[key] = (webhookTotals[key] || 0) + 1;
|
||||
}
|
||||
|
||||
const conversionTotals = {}; // result: 'success' | 'error' | 'blocked' | 'skipped'
|
||||
function recordConversion(result) { conversionTotals[result] = (conversionTotals[result] || 0) + 1; }
|
||||
|
||||
const seatReleasedTotals = {}; // reason: 'checkout_expired' | 'reservation_expired'
|
||||
function recordSeatReleased(reason) { seatReleasedTotals[reason] = (seatReleasedTotals[reason] || 0) + 1; }
|
||||
|
||||
// Gauges are computed fresh at scrape time from Postgres/the Provisioner — cheap enough at a
|
||||
// normal 15-30s Prometheus scrape interval, and always exactly correct (no separate value to
|
||||
// keep in sync with reality).
|
||||
@@ -150,10 +174,16 @@ async function renderMetrics(pool, provisioner) {
|
||||
labeledCounter('rootnode_notification_delivery_attempts_total', 'Notification delivery attempts by provider and outcome.', notificationAttemptTotals, ['provider', 'result']),
|
||||
line('rootnode_student_activations_success_total', 'Student accounts successfully activated.', 'counter', studentActivationTotals.success || 0),
|
||||
line('rootnode_student_activations_invalid_total', 'Activation attempts with an invalid or expired token.', 'counter', studentActivationTotals.invalid_or_expired_token || 0),
|
||||
labeledCounter('rootnode_checkout_sessions_total', 'Checkout session creation attempts by result.', checkoutSessionTotals, ['result']),
|
||||
labeledCounter('rootnode_payments_total', 'Payment webhook outcomes by status and provider.', paymentTotals, ['status', 'provider']),
|
||||
labeledCounter('rootnode_payment_webhooks_total', 'Payment webhook deliveries by event type and processing result.', webhookTotals, ['event_type', 'result']),
|
||||
labeledCounter('rootnode_payment_conversion_total', 'Registration-to-enrollment conversions triggered by a successful payment, by result.', conversionTotals, ['result']),
|
||||
labeledCounter('rootnode_seats_released_total', 'Reserved seats released by reason.', seatReleasedTotals, ['reason']),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
recordStudentLoginSuccess, recordStudentLoginFailure, recordEnrollmentEvent,
|
||||
recordNotificationAttempt, recordStudentActivation, renderMetrics,
|
||||
recordCheckoutSession, recordPayment, recordWebhook, recordConversion, recordSeatReleased,
|
||||
};
|
||||
|
||||
@@ -44,6 +44,23 @@ function renderFor(row) {
|
||||
if (row.notification_type === 'password_changed') {
|
||||
return templates.passwordChanged({ displayName });
|
||||
}
|
||||
// Phase 8 — registration-stage notifications. No token/public-origin dependency, so these
|
||||
// never fail on missing PUBLIC_APP_ORIGIN config the way activation/reset do.
|
||||
if (row.notification_type === 'registration_received') {
|
||||
return templates.registrationReceived({ displayName, offeringName: row.metadata?.offering_name || 'your offering' });
|
||||
}
|
||||
if (row.notification_type === 'registration_confirmed') {
|
||||
return templates.registrationConfirmed({ displayName, offeringName: row.metadata?.offering_name || 'your offering' });
|
||||
}
|
||||
if (row.notification_type === 'registration_needs_action') {
|
||||
return templates.registrationNeedsAction({ displayName, offeringName: row.metadata?.offering_name || 'your offering', reason: row.metadata?.reason });
|
||||
}
|
||||
if (row.notification_type === 'agreement_required') {
|
||||
return templates.agreementRequired({ displayName, offeringName: row.metadata?.offering_name || 'your offering', agreementTitle: row.metadata?.agreement_title || 'a required agreement' });
|
||||
}
|
||||
if (row.notification_type === 'camp_operational_update') {
|
||||
return templates.campOperationalUpdate({ displayName, offeringName: row.metadata?.offering_name || 'your offering', message: row.metadata?.message || '' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,9 +38,57 @@ async function resolveRecipient(pool, studentId, enrollmentId) {
|
||||
const contact = enrollmentContactEmail(enrollmentRows[0]);
|
||||
if (contact) return contact;
|
||||
}
|
||||
// Phase 8 §9 — prefer the real guardian model (Phase 7) over nothing: a no-email student with
|
||||
// no enrollment-level contact_email but a real, communications-enabled primary guardian still
|
||||
// gets notified. Tried last, after both of the above, so an existing student.email or the
|
||||
// established enrollment.metadata.contact_email always wins first — this is purely a new
|
||||
// fallback, never a behavior change for any student who already resolves correctly today.
|
||||
const { rows: guardianRows } = await pool.query(
|
||||
`SELECT g.email FROM student_guardians sg JOIN guardians g ON g.guardian_id = sg.guardian_id
|
||||
WHERE sg.student_id = $1 AND sg.can_receive_communications = true AND g.email IS NOT NULL
|
||||
ORDER BY sg.is_primary_contact DESC LIMIT 1`,
|
||||
[studentId]
|
||||
);
|
||||
if (guardianRows[0]?.email) return guardianRows[0].email;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Phase 8 — the registration-stage counterpart to enqueue() above, for a notification that may
|
||||
// need to go out BEFORE any student row exists at all (a brand-new guardian-submitted
|
||||
// registration). Recipient resolution order: the registration's own student (if already
|
||||
// converted — reuses resolveRecipient/the guardian fallback above), else its linked guardian,
|
||||
// else the registrant's own supplied email. Never creates a second notification pipeline —
|
||||
// same notification_events table, same worker, same templates module.
|
||||
async function resolveRegistrationRecipient(pool, registration) {
|
||||
if (registration.student_id) {
|
||||
const viaStudent = await resolveRecipient(pool, registration.student_id, registration.enrollment_id);
|
||||
if (viaStudent) return viaStudent;
|
||||
}
|
||||
if (registration.guardian_id) {
|
||||
const { rows } = await pool.query(`SELECT email FROM guardians WHERE guardian_id = $1`, [registration.guardian_id]);
|
||||
if (rows[0]?.email) return rows[0].email;
|
||||
}
|
||||
return registration.registrant_email || null;
|
||||
}
|
||||
|
||||
async function enqueueForRegistration(pool, { registration, notificationType, metadata }) {
|
||||
const recipient = await resolveRegistrationRecipient(pool, registration);
|
||||
if (!recipient) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO notification_events (student_id, registration_id, notification_type, recipient_email, status, last_error_code, failed_at, metadata)
|
||||
VALUES ($1, $2, $3, NULL, 'failed', 'no_recipient_address', now(), $4) RETURNING *`,
|
||||
[registration.student_id || null, registration.registration_id, notificationType, JSON.stringify({})]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO notification_events (student_id, registration_id, notification_type, recipient_email, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[registration.student_id || null, registration.registration_id, notificationType, recipient, JSON.stringify(metadata || {})]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Inserts a queued row. plaintextToken is optional (activation/password_reset only) and, if
|
||||
// given, is the ONLY place its value is persisted anywhere — purged on the row's terminal
|
||||
// state. A notification with no resolvable recipient is still recorded (never silently
|
||||
@@ -135,16 +183,43 @@ async function notifyForEnrollment(pool, studentsAuth, { studentId, enrollmentId
|
||||
});
|
||||
}
|
||||
const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation');
|
||||
return enqueue(pool, {
|
||||
const notification = await enqueue(pool, {
|
||||
studentId, enrollmentId, notificationType: 'activation', plaintextToken: token,
|
||||
metadata: { display_name: student.display_name, program_name: programName, resend: !!isResend },
|
||||
});
|
||||
// Phase 9 — the raw token is attached here (never persisted beyond the notification row's own
|
||||
// transient metadata, same as always) purely so a caller that itself needs to hand the
|
||||
// student a working activation link right now (offerings.js's registration-conversion flow,
|
||||
// matching the trust level POST /api/admin/students already has) doesn't have to re-issue a
|
||||
// second token that would silently invalidate this one.
|
||||
return { ...notification, _activationToken: token };
|
||||
}
|
||||
|
||||
async function listForStudent(pool, studentId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE student_id = $1 ORDER BY created_at DESC`, [studentId]);
|
||||
return rows;
|
||||
}
|
||||
// Phase 8 — every notification tied to a registration, including pre-conversion ones with no
|
||||
// student_id yet at all.
|
||||
// Phase 9 — a registration's full communication history spans two eras: pre-conversion
|
||||
// (registration_received/confirmed/needs_action, agreement_required — keyed by registration_id,
|
||||
// no student yet) and post-conversion (the activation/enrollment_confirmation email
|
||||
// notifyForEnrollment sends — keyed by student_id/enrollment_id like every other enrollment,
|
||||
// never by registration_id, since it isn't a registration-specific concept). Both belong in one
|
||||
// admin-visible timeline for the SAME registration, so this reads the registration row itself
|
||||
// to find its (possibly still-null) student_id/enrollment_id and unions both sources.
|
||||
async function listForRegistration(pool, registrationId) {
|
||||
const { rows: regRows } = await pool.query(`SELECT student_id, enrollment_id FROM offering_registrations WHERE registration_id = $1`, [registrationId]);
|
||||
const reg = regRows[0];
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM notification_events
|
||||
WHERE registration_id = $1
|
||||
OR (enrollment_id IS NOT NULL AND enrollment_id = $2)
|
||||
ORDER BY created_at DESC`,
|
||||
[registrationId, reg?.enrollment_id || null]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
async function getById(pool, notificationId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE notification_id = $1`, [notificationId]);
|
||||
return rows[0] || null;
|
||||
@@ -177,6 +252,7 @@ function maskEmail(email) {
|
||||
function toAdminView(row) {
|
||||
return {
|
||||
notification_id: row.notification_id, student_id: row.student_id, enrollment_id: row.enrollment_id,
|
||||
registration_id: row.registration_id,
|
||||
notification_type: row.notification_type, channel: row.channel, recipient_email_masked: maskEmail(row.recipient_email),
|
||||
provider: row.provider, status: row.status, attempt_count: row.attempt_count, last_error_code: row.last_error_code,
|
||||
created_at: row.created_at, sent_at: row.sent_at, failed_at: row.failed_at,
|
||||
@@ -186,4 +262,5 @@ function toAdminView(row) {
|
||||
module.exports = {
|
||||
resolveRecipient, enqueue, claimNext, markSent, markFailedOrRetry, notifyForEnrollment,
|
||||
listForStudent, getById, getDevPreview, maskEmail, toAdminView, MAX_ATTEMPTS,
|
||||
enqueueForRegistration, listForRegistration,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
// Phase 7 — the business/product architecture's core library: organizations, locations,
|
||||
// guardians, curriculum packages, offerings, registrations, and agreement acceptances. Same
|
||||
// error convention as enrollment.js/classroom.js: throw `Error` with a `.code` the route layer
|
||||
// maps to an HTTP status. See docs/OFFERING_AND_REGISTRATION_MODEL.md for the full lifecycle
|
||||
// diagrams this file implements, and docs/STUDENT_GUARDIAN_DATA_MODEL.md for the guardian
|
||||
// model's own reasoning.
|
||||
|
||||
const enrollmentLib = require('./enrollment');
|
||||
const notificationsLib = require('./notifications');
|
||||
|
||||
function err(code, message) {
|
||||
return Object.assign(new Error(message || code), { code });
|
||||
}
|
||||
|
||||
// ── Organizations / Locations ──────────────────────────────────────────────────────────────
|
||||
async function createOrganization(pool, { organizationId, name, type, contactName, contactEmail, contactPhone, notes }) {
|
||||
if (!organizationId || !name) throw err('VALIDATION', 'organizationId and name are required.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO organizations (organization_id, name, type, contact_name, contact_email, contact_phone, notes)
|
||||
VALUES ($1, $2, COALESCE($3, 'other'), $4, $5, $6, $7) RETURNING *`,
|
||||
[organizationId, name, type || null, contactName || null, contactEmail || null, contactPhone || null, notes || null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
async function listOrganizations(pool) {
|
||||
const { rows } = await pool.query(`SELECT * FROM organizations ORDER BY name`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function createLocation(pool, { locationId, organizationId, name, type, addressLine1, addressLine2, city, region, postalCode, country, timezone, capacity, accessNotes }) {
|
||||
if (!locationId || !name) throw err('VALIDATION', 'locationId and name are required.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO locations (location_id, organization_id, name, type, address_line1, address_line2, city, region, postal_code, country, timezone, capacity, access_notes)
|
||||
VALUES ($1, $2, $3, COALESCE($4, 'online'), $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *`,
|
||||
[locationId, organizationId || null, name, type || null, addressLine1 || null, addressLine2 || null, city || null, region || null, postalCode || null, country || null, timezone || null, capacity || null, accessNotes || null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
// `forInstructor` gates access_notes exactly like course-progress.js gates instructor_notes —
|
||||
// internal operational detail (building access codes, parking) never leaves the instructor
|
||||
// surface. See Step 8's explicit "protect private/internal operational notes."
|
||||
async function listLocations(pool, { forInstructor = false } = {}) {
|
||||
const { rows } = await pool.query(`SELECT * FROM locations ORDER BY name`);
|
||||
if (forInstructor) return rows;
|
||||
return rows.map(({ access_notes, ...rest }) => rest);
|
||||
}
|
||||
|
||||
// ── Guardians ───────────────────────────────────────────────────────────────────────────────
|
||||
async function createGuardian(pool, { guardianId, displayName, email, phone, notes }) {
|
||||
if (!guardianId || !displayName) throw err('VALIDATION', 'guardianId and displayName are required.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO guardians (guardian_id, display_name, email, phone, notes) VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[guardianId, displayName, email ? String(email).toLowerCase().trim() : null, phone || null, notes || null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
async function linkStudentGuardian(pool, { studentId, guardianId, relationship, isPrimaryContact, canReceiveCommunications }) {
|
||||
if (!studentId || !guardianId) throw err('VALIDATION', 'studentId and guardianId are required.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO student_guardians (student_id, guardian_id, relationship, is_primary_contact, can_receive_communications)
|
||||
VALUES ($1, $2, COALESCE($3, 'guardian'), COALESCE($4, false), COALESCE($5, true))
|
||||
ON CONFLICT (student_id, guardian_id) DO UPDATE SET relationship = EXCLUDED.relationship, is_primary_contact = EXCLUDED.is_primary_contact, can_receive_communications = EXCLUDED.can_receive_communications
|
||||
RETURNING *`,
|
||||
[studentId, guardianId, relationship || null, isPrimaryContact ?? null, canReceiveCommunications ?? null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
// A plain instructor sees which students they have real cohort access to (via the caller's own
|
||||
// RBAC check on the route) — this function itself just returns the linked guardians for one
|
||||
// student, RBAC is the route layer's job, same separation of concerns as everywhere else.
|
||||
async function listGuardiansForStudent(pool, studentId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT g.*, sg.relationship, sg.is_primary_contact, sg.can_receive_communications
|
||||
FROM student_guardians sg JOIN guardians g ON g.guardian_id = sg.guardian_id
|
||||
WHERE sg.student_id = $1 ORDER BY sg.is_primary_contact DESC, g.display_name`,
|
||||
[studentId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── Curriculum packages (a reference list into EXISTING courses, never a clone) ─────────────
|
||||
async function createCurriculumPackage(pool, { curriculumPackageId, name, programId, description }) {
|
||||
if (!curriculumPackageId || !name || !programId) throw err('VALIDATION', 'curriculumPackageId, name, and programId are required.');
|
||||
const { rows: programRows } = await pool.query(`SELECT 1 FROM programs WHERE program_id = $1`, [programId]);
|
||||
if (!programRows[0]) throw err('PROGRAM_NOT_FOUND', 'No such program.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO curriculum_packages (curriculum_package_id, name, program_id, description) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[curriculumPackageId, name, programId, description || null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
async function addCurriculumPackageItem(pool, { curriculumPackageId, courseId, sequence }) {
|
||||
const { rows: courseRows } = await pool.query(`SELECT 1 FROM courses WHERE course_id = $1`, [courseId]);
|
||||
if (!courseRows[0]) throw err('COURSE_NOT_FOUND', 'No such course — a package only ever REFERENCES an existing course, never clones one.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO curriculum_package_items (curriculum_package_id, course_id, sequence) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (curriculum_package_id, course_id) DO UPDATE SET sequence = EXCLUDED.sequence RETURNING *`,
|
||||
[curriculumPackageId, courseId, sequence]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
async function getCurriculumPackage(pool, curriculumPackageId) {
|
||||
const { rows: pkgRows } = await pool.query(`SELECT * FROM curriculum_packages WHERE curriculum_package_id = $1`, [curriculumPackageId]);
|
||||
if (!pkgRows[0]) return null;
|
||||
const { rows: items } = await pool.query(
|
||||
`SELECT cpi.course_id, cpi.sequence, c.title, c.status FROM curriculum_package_items cpi
|
||||
JOIN courses c ON c.course_id = cpi.course_id WHERE cpi.curriculum_package_id = $1 ORDER BY cpi.sequence`,
|
||||
[curriculumPackageId]
|
||||
);
|
||||
return { ...pkgRows[0], items };
|
||||
}
|
||||
|
||||
// ── Offerings — server-defined status transitions only (Step 3's explicit requirement) ──────
|
||||
const OFFERING_TRANSITIONS = {
|
||||
draft: ['published', 'cancelled'],
|
||||
published: ['enrollment_open', 'cancelled'],
|
||||
enrollment_open: ['full', 'in_progress', 'cancelled'],
|
||||
full: ['enrollment_open', 'in_progress', 'cancelled'],
|
||||
in_progress: ['completed', 'cancelled'],
|
||||
completed: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
async function createOffering(pool, { offeringId, slug, name, programId, curriculumPackageId, deliveryMode, startsAt, endsAt, enrollmentOpensAt, enrollmentClosesAt, capacity, locationId, organizationId, cohortId, recommendedMinAge, recommendedMaxAge, description, createdBy, priceAmount, priceCurrency }) {
|
||||
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, price_amount, price_currency)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,COALESCE($20,'usd')) 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, priceAmount ?? null, priceCurrency || 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;
|
||||
}
|
||||
// Phase 9 §28 — a draft offering is created with the bare minimum, then completed over time as
|
||||
// real business decisions (dates, venue, capacity, price) land — this is the one place those
|
||||
// get filled in later without recreating the row. Only ever touches columns explicitly present
|
||||
// in `fields`, so calling it with just `{ priceAmount, priceCurrency }` never disturbs anything
|
||||
// else. Never allows changing `status` (transitionOffering is the only path for that, with its
|
||||
// own server-defined transition table) or `slug`/`program_id` (identity fields).
|
||||
const OFFERING_EDITABLE_FIELDS = {
|
||||
name: 'name', description: 'description', startsAt: 'starts_at', endsAt: 'ends_at',
|
||||
enrollmentOpensAt: 'enrollment_opens_at', enrollmentClosesAt: 'enrollment_closes_at', capacity: 'capacity',
|
||||
locationId: 'location_id', organizationId: 'organization_id', cohortId: 'cohort_id',
|
||||
recommendedMinAge: 'recommended_min_age', recommendedMaxAge: 'recommended_max_age',
|
||||
curriculumPackageId: 'curriculum_package_id', priceAmount: 'price_amount', priceCurrency: 'price_currency',
|
||||
};
|
||||
async function updateOffering(pool, offeringId, fields) {
|
||||
const sets = [];
|
||||
const values = [offeringId];
|
||||
for (const [key, column] of Object.entries(OFFERING_EDITABLE_FIELDS)) {
|
||||
if (!(key in fields)) continue;
|
||||
values.push(fields[key]);
|
||||
sets.push(`${column} = $${values.length}`);
|
||||
}
|
||||
if (!sets.length) throw err('VALIDATION', 'No editable fields provided.');
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE offerings SET ${sets.join(', ')}, version = version + 1, updated_at = now() WHERE offering_id = $1 RETURNING *`,
|
||||
values
|
||||
);
|
||||
if (!rows[0]) throw err('NOT_FOUND', 'No such offering.');
|
||||
return rows[0];
|
||||
}
|
||||
async function listOfferings(pool, { status } = {}) {
|
||||
const { rows } = await pool.query(
|
||||
status ? `SELECT * FROM offerings WHERE status = $1 ORDER BY created_at DESC` : `SELECT * FROM offerings ORDER BY created_at DESC`,
|
||||
status ? [status] : []
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
async function transitionOffering(pool, offeringId, toStatus) {
|
||||
const offering = await getOffering(pool, offeringId);
|
||||
if (!offering) throw err('NOT_FOUND', 'No such offering.');
|
||||
const allowed = OFFERING_TRANSITIONS[offering.status] || [];
|
||||
if (!allowed.includes(toStatus)) throw err('INVALID_TRANSITION', `Cannot move an offering from '${offering.status}' to '${toStatus}'.`);
|
||||
const { rows } = await pool.query(`UPDATE offerings SET status = $2, version = version + 1, updated_at = now() WHERE offering_id = $1 RETURNING *`, [offeringId, toStatus]);
|
||||
return rows[0];
|
||||
}
|
||||
async function assignInstructorToOffering(pool, offeringId, instructorId, role) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO instructor_offerings (offering_id, instructor_id, role) VALUES ($1, $2, COALESCE($3, 'teacher'))
|
||||
ON CONFLICT (offering_id, instructor_id) DO UPDATE SET role = EXCLUDED.role RETURNING *`,
|
||||
[offeringId, instructorId, role || null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Single atomic UPDATE, not SELECT-count-then-INSERT — see 034_phase7_....sql's own header
|
||||
// comment for why this specific shape avoids the race the authorization explicitly warned
|
||||
// against. A NULL capacity offering (typical for self_paced) never needs reservation at all.
|
||||
async function reserveSeat(pool, offeringId) {
|
||||
const offering = await getOffering(pool, offeringId);
|
||||
if (!offering) throw err('NOT_FOUND', 'No such offering.');
|
||||
if (offering.capacity === null) return offering; // unlimited — nothing to reserve
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE offerings SET reserved_seats = reserved_seats + 1, updated_at = now()
|
||||
WHERE offering_id = $1 AND reserved_seats < capacity RETURNING *`,
|
||||
[offeringId]
|
||||
);
|
||||
if (!rows[0]) throw err('OFFERING_FULL', 'This offering has no remaining capacity.');
|
||||
return rows[0];
|
||||
}
|
||||
async function releaseSeat(pool, offeringId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE offerings SET reserved_seats = GREATEST(reserved_seats - 1, 0), updated_at = now() WHERE offering_id = $1 RETURNING *`,
|
||||
[offeringId]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// ── Registrations ────────────────────────────────────────────────────────────────────────────
|
||||
const REGISTRATION_TRANSITIONS = {
|
||||
started: ['submitted', 'cancelled'],
|
||||
submitted: ['reviewed', 'agreements_pending', 'payment_pending', 'confirmed', 'cancelled'],
|
||||
reviewed: ['agreements_pending', 'payment_pending', 'confirmed', 'cancelled'],
|
||||
agreements_pending: ['payment_pending', 'confirmed', 'cancelled'],
|
||||
payment_pending: ['confirmed', 'cancelled'],
|
||||
confirmed: ['enrollment_activated', 'cancelled'],
|
||||
enrollment_activated: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
// Phase 8 §21 — registration availability is server-authoritative, checked here (the one place
|
||||
// a registration row is actually created), not left to a website's own hidden-button logic.
|
||||
// Distinct error codes per reason so a caller (website or admin UI) can show something more
|
||||
// useful than a generic failure.
|
||||
function assertRegistrationOpen(offering) {
|
||||
if (offering.status === 'draft') throw err('OFFERING_NOT_OPEN', 'This offering is not yet open for registration.');
|
||||
if (offering.status === 'cancelled') throw err('OFFERING_CANCELLED', 'This offering has been cancelled.');
|
||||
if (offering.status === 'completed') throw err('OFFERING_COMPLETED', 'This offering has already completed.');
|
||||
if (offering.status === 'full') throw err('OFFERING_FULL', 'This offering has no remaining capacity.');
|
||||
if (!['published', 'enrollment_open'].includes(offering.status)) throw err('OFFERING_NOT_OPEN', 'This offering is not currently open for registration.');
|
||||
const now = new Date();
|
||||
if (offering.enrollment_opens_at && now < new Date(offering.enrollment_opens_at)) throw err('REGISTRATION_NOT_YET_OPEN', 'Registration for this offering has not opened yet.');
|
||||
if (offering.enrollment_closes_at && now > new Date(offering.enrollment_closes_at)) throw err('REGISTRATION_CLOSED', 'Registration for this offering has closed.');
|
||||
}
|
||||
|
||||
// Field validation -> seat reservation -> insert -> notification, strictly in that order. A
|
||||
// notification is only ever enqueued for a registration that is FULLY, durably committed —
|
||||
// enqueueing one earlier (e.g. before capacity is confirmed) created a real bug found during
|
||||
// this phase's own regression testing: a rolled-back registration whose row got deleted after a
|
||||
// failed seat reservation left behind a notification_events row still pointing at it, and the
|
||||
// DELETE itself then failed on the very foreign key that row created. Reserving the seat FIRST
|
||||
// (rolling it back if the insert itself somehow fails) means nothing is ever created that later
|
||||
// needs to be un-created out from under a row something else already references.
|
||||
// Phase 9 §26/§27 — a registration against a PAID offering carries a reservation_expires_at,
|
||||
// giving the registrant a real but bounded window to complete checkout before the seat releases
|
||||
// automatically (server.js's reservation reaper). A free offering never sets this — nothing new
|
||||
// to expire, identical to Phase 7/8 behavior.
|
||||
const RESERVATION_TIMEOUT_MINUTES = Number(process.env.REGISTRATION_RESERVATION_TIMEOUT_MINUTES) || 30;
|
||||
function computeReservationExpiry(offering) {
|
||||
return offering.price_amount === null ? null : new Date(Date.now() + RESERVATION_TIMEOUT_MINUTES * 60 * 1000);
|
||||
}
|
||||
|
||||
async function createRegistration(pool, { offeringId, guardianId, registrantDisplayName, registrantEmail, isMinor, readinessNotes, accessibilityNotes, emergencyContactName, emergencyContactPhone, source, externalReference }) {
|
||||
if (!offeringId || !registrantDisplayName) throw err('VALIDATION', 'offeringId and registrantDisplayName are required.');
|
||||
if (isMinor && !guardianId) throw err('GUARDIAN_REQUIRED', 'A minor registration must include a guardianId.');
|
||||
const offering = await getOffering(pool, offeringId);
|
||||
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
|
||||
assertRegistrationOpen(offering);
|
||||
|
||||
await reserveSeat(pool, offeringId); // throws OFFERING_FULL if genuinely exhausted; nothing created yet if so
|
||||
|
||||
let reg;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, accessibility_notes, emergency_contact_name, emergency_contact_phone, source, external_reference, reservation_expires_at)
|
||||
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9,COALESCE($10,'admin'),$11,$12) RETURNING *`,
|
||||
[offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null, computeReservationExpiry(offering)]
|
||||
);
|
||||
reg = rows[0];
|
||||
} catch (e) {
|
||||
await releaseSeat(pool, offeringId).catch(() => {});
|
||||
if (e.code === '23505') throw err('DUPLICATE_REGISTRATION', 'A registration with this external_reference already exists for this offering.');
|
||||
throw e;
|
||||
}
|
||||
// Best-effort — a notification failure must never fail the registration itself (matches
|
||||
// enrollment.js's own precedent of never letting notification concerns block the academic
|
||||
// action they're merely announcing). Safe to fire now: reg is fully, durably committed.
|
||||
await notificationsLib.enqueueForRegistration(pool, {
|
||||
registration: reg, notificationType: 'registration_received',
|
||||
metadata: { display_name: reg.registrant_display_name, offering_name: offering.name },
|
||||
}).catch((e) => console.error("[offerings] registration_received notification failed:", e.message));
|
||||
return reg;
|
||||
}
|
||||
async function getRegistration(pool, registrationId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM offering_registrations WHERE registration_id = $1`, [registrationId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
async function listRegistrationsForOffering(pool, offeringId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM offering_registrations WHERE offering_id = $1 ORDER BY created_at`, [offeringId]);
|
||||
return rows;
|
||||
}
|
||||
async function transitionRegistration(pool, registrationId, toStatus) {
|
||||
const reg = await getRegistration(pool, registrationId);
|
||||
if (!reg) throw err('NOT_FOUND', 'No such registration.');
|
||||
const allowed = REGISTRATION_TRANSITIONS[reg.status] || [];
|
||||
if (!allowed.includes(toStatus)) throw err('INVALID_TRANSITION', `Cannot move a registration from '${reg.status}' to '${toStatus}'.`);
|
||||
if (toStatus === 'cancelled' && !['enrollment_activated', 'cancelled'].includes(reg.status)) await releaseSeat(pool, reg.offering_id);
|
||||
const { rows } = await pool.query(`UPDATE offering_registrations SET status = $2, updated_at = now() WHERE registration_id = $1 RETURNING *`, [registrationId, toStatus]);
|
||||
const updated = rows[0];
|
||||
|
||||
// Best-effort lifecycle notifications — see createRegistration's own comment on why a
|
||||
// notification failure never blocks the real state change it's merely announcing.
|
||||
const offering = await getOffering(pool, reg.offering_id);
|
||||
if (toStatus === 'confirmed') {
|
||||
await notificationsLib.enqueueForRegistration(pool, {
|
||||
registration: updated, notificationType: 'registration_confirmed',
|
||||
metadata: { display_name: updated.registrant_display_name, offering_name: offering?.name },
|
||||
}).catch((e) => console.error("[offerings] registration lifecycle notification failed:", e.message));
|
||||
}
|
||||
if (toStatus === 'agreements_pending') {
|
||||
const status = await getRegistrationAgreementStatus(pool, registrationId);
|
||||
for (const missing of status.missing) {
|
||||
await notificationsLib.enqueueForRegistration(pool, {
|
||||
registration: updated, notificationType: 'agreement_required',
|
||||
metadata: { display_name: updated.registrant_display_name, offering_name: offering?.name, agreement_title: missing.title },
|
||||
}).catch((e) => console.error("[offerings] agreement_required notification failed:", e.message));
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ── Offering agreement requirements (Step 18) ────────────────────────────────────────────────
|
||||
async function addAgreementRequirement(pool, offeringId, agreementId) {
|
||||
const { rows: agreementRows } = await pool.query(`SELECT 1 FROM agreement_definitions WHERE agreement_id = $1`, [agreementId]);
|
||||
if (!agreementRows[0]) throw err('AGREEMENT_NOT_FOUND', 'No such agreement.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO offering_agreement_requirements (offering_id, agreement_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING *`,
|
||||
[offeringId, agreementId]
|
||||
);
|
||||
return rows[0] || { offering_id: offeringId, agreement_id: agreementId };
|
||||
}
|
||||
async function listAgreementRequirementsForOffering(pool, offeringId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ad.* FROM offering_agreement_requirements oar JOIN agreement_definitions ad ON ad.agreement_id = oar.agreement_id
|
||||
WHERE oar.offering_id = $1 ORDER BY ad.title`,
|
||||
[offeringId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
// Required vs. accepted vs. missing, for one registration — the exact shape both the
|
||||
// agreements_pending notification loop above and the admin/registration-detail UI need.
|
||||
async function getRegistrationAgreementStatus(pool, registrationId) {
|
||||
const reg = await getRegistration(pool, registrationId);
|
||||
if (!reg) throw err('NOT_FOUND', 'No such registration.');
|
||||
const required = await listAgreementRequirementsForOffering(pool, reg.offering_id);
|
||||
const accepted = await listAcceptancesForRegistration(pool, registrationId);
|
||||
const acceptedIds = new Set(accepted.map((a) => a.agreement_id));
|
||||
const missing = required.filter((r) => !acceptedIds.has(r.agreement_id));
|
||||
return { required, accepted, missing };
|
||||
}
|
||||
|
||||
// The one place a registration becomes a real academic enrollment. Reuses
|
||||
// enrollment.js's resolveOrCreateStudent/createManualEnrollment/activateEnrollment rather than
|
||||
// duplicating student-matching or enrollment-creation logic — see that file's own comment on
|
||||
// why resolveOrCreateStudent is now exported. Cohort membership (if the offering has a cohort)
|
||||
// is created here too, using the existing cohort_enrollments table unchanged.
|
||||
async function convertRegistrationToEnrollment(pool, studentsAuth, registrationId, { activate = true } = {}) {
|
||||
const reg = await getRegistration(pool, registrationId);
|
||||
if (!reg) throw err('NOT_FOUND', 'No such registration.');
|
||||
if (reg.status !== 'confirmed') throw err('INVALID_STATE', 'A registration must be confirmed before it can become an enrollment.');
|
||||
const offering = await getOffering(pool, reg.offering_id);
|
||||
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
|
||||
// Step 18 — a required agreement with no matching acceptance blocks conversion, always (this
|
||||
// offering's own configured requirements, not a global policy toggle this phase invents).
|
||||
const agreementStatus = await getRegistrationAgreementStatus(pool, registrationId);
|
||||
if (agreementStatus.missing.length) {
|
||||
throw err('AGREEMENTS_INCOMPLETE', `Missing required agreement(s): ${agreementStatus.missing.map((m) => m.title).join(', ')}`);
|
||||
}
|
||||
|
||||
const student = reg.student_id
|
||||
? (await pool.query(`SELECT * FROM students WHERE student_id = $1`, [reg.student_id])).rows[0]
|
||||
: await enrollmentLib.resolveOrCreateStudent(pool, studentsAuth, {
|
||||
studentEmail: reg.is_minor ? null : reg.registrant_email,
|
||||
studentLoginIdentifier: reg.is_minor ? `reg-${reg.registration_id}` : null,
|
||||
studentDisplayName: reg.registrant_display_name,
|
||||
}); // no returnToken here — notifyForEnrollment below issues the ONE real token that
|
||||
// actually gets used; pre-issuing a second one here would just have it silently
|
||||
// overwritten (found while wiring Phase 9's activation-notification call — see that
|
||||
// call's own comment).
|
||||
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
// Phase 9 — the registration's own guardian (set at registration time when a guardian object
|
||||
// was submitted) never used to survive conversion into the relational student_guardians model,
|
||||
// which is what resolveRecipient's guardian fallback (and any future guardian-facing feature)
|
||||
// actually reads. Without this, a brand-new minor student converts with no guardian link at
|
||||
// all, and the activation email below has no recipient to resolve to.
|
||||
if (reg.is_minor && reg.guardian_id) {
|
||||
await linkStudentGuardian(pool, {
|
||||
studentId: student.student_id, guardianId: reg.guardian_id,
|
||||
relationship: 'guardian', isPrimaryContact: true, canReceiveCommunications: true,
|
||||
});
|
||||
}
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
// Phase 9 §9/§20 — the same activation-or-confirmation email every OTHER enrollment path
|
||||
// (the admin manual-enroll route, the website's /api/integrations/enrollments) already
|
||||
// triggers automatically. A registration-converted enrollment was the one path that never
|
||||
// called this — closed here so a new camp student's real path to logging in doesn't depend
|
||||
// on an admin manually relaying `student._activationToken` by hand.
|
||||
const programsLib = require('./programs');
|
||||
const program = await programsLib.getProgram(pool, offering.program_id);
|
||||
let activationToken = null;
|
||||
try {
|
||||
const notification = await notificationsLib.notifyForEnrollment(pool, studentsAuth, {
|
||||
studentId: student.student_id, enrollmentId: enrollment.enrollment_id, programName: program?.name || offering.name,
|
||||
});
|
||||
activationToken = notification?._activationToken || null;
|
||||
} catch (e) {
|
||||
console.error('[offerings] enrollment-activation notification failed:', e.message);
|
||||
}
|
||||
|
||||
return { student: activationToken ? { ...student, _activationToken: activationToken } : student, enrollment };
|
||||
}
|
||||
|
||||
// ── Agreements ────────────────────────────────────────────────────────────────────────────────
|
||||
async function createAgreementDefinition(pool, { agreementId, title, bodySummary, requiresGuardianForMinor }) {
|
||||
if (!agreementId || !title) throw err('VALIDATION', 'agreementId and title are required.');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO agreement_definitions (agreement_id, title, body_summary, requires_guardian_for_minor) VALUES ($1, $2, $3, COALESCE($4, true)) RETURNING *`,
|
||||
[agreementId, title, bodySummary || null, requiresGuardianForMinor ?? null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
async function listAgreementDefinitions(pool) {
|
||||
const { rows } = await pool.query(`SELECT * FROM agreement_definitions ORDER BY title`);
|
||||
return rows;
|
||||
}
|
||||
// accepted_by_type: 'student' | 'guardian' | 'instructor_on_behalf' | 'registrant'. 'registrant'
|
||||
// (Step 7's fix) is the one case allowed with neither a student nor a guardian id — an adult
|
||||
// self-accepting AT REGISTRATION TIME, before their eventual student_id exists, tied only to
|
||||
// registrationId. Every other type still requires a real student or guardian id — enforced here
|
||||
// AND by the DB CHECK constraint (035_phase8_...sql), so this can never be bypassed by a future
|
||||
// caller that skips this function.
|
||||
async function recordAgreementAcceptance(pool, { agreementId, acceptedByType, acceptedByStudentId, acceptedByGuardianId, registrationId, enrollmentId, ipAddress }) {
|
||||
const { rows: defRows } = await pool.query(`SELECT * FROM agreement_definitions WHERE agreement_id = $1`, [agreementId]);
|
||||
const def = defRows[0];
|
||||
if (!def) throw err('AGREEMENT_NOT_FOUND', 'No such agreement.');
|
||||
if (!['student', 'guardian', 'instructor_on_behalf', 'registrant'].includes(acceptedByType)) {
|
||||
throw err('VALIDATION', "accepted_by_type must be one of: student, guardian, instructor_on_behalf, registrant.");
|
||||
}
|
||||
if (acceptedByType === 'registrant') {
|
||||
if (!registrationId) throw err('VALIDATION', 'A registrant acceptance must be tied to a registrationId.');
|
||||
} else if (!acceptedByStudentId && !acceptedByGuardianId) {
|
||||
throw err('VALIDATION', 'An acceptance must be tied to a student or a guardian.');
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO agreement_acceptances (agreement_id, accepted_version, accepted_by_type, accepted_by_student_id, accepted_by_guardian_id, registration_id, enrollment_id, ip_address)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||
[agreementId, def.version, acceptedByType, acceptedByStudentId || null, acceptedByGuardianId || null, registrationId || null, enrollmentId || null, ipAddress || null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
async function listAcceptancesForRegistration(pool, registrationId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM agreement_acceptances WHERE registration_id = $1 ORDER BY accepted_at`, [registrationId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── Website integration — idempotent registration submission (Step 13/14) ──────────────────
|
||||
// Mirrors enrollment.js's createFromIntegrationEvent exactly: a real DB-level unique index
|
||||
// (external_reference, 035_phase8_...sql) is what makes concurrent duplicate submissions safe,
|
||||
// not a SELECT-then-INSERT check. A replay of the same event_id returns the ORIGINAL
|
||||
// registration, created:false, never a duplicate — same contract shape as the existing
|
||||
// /api/integrations/enrollments endpoint website code already knows how to handle.
|
||||
async function createRegistrationFromIntegrationEvent(pool, { eventId, offeringSlug, registrantDisplayName, registrantEmail, isMinor, guardianDisplayName, guardianEmail, guardianPhone, readinessNotes, source }) {
|
||||
if (!eventId) throw err('EVENT_ID_REQUIRED', 'event_id is required for idempotent registration events.');
|
||||
if (!offeringSlug) throw err('OFFERING_SLUG_REQUIRED', 'offering_slug is required.');
|
||||
if (!registrantDisplayName) throw err('VALIDATION', 'registrant display name is required.');
|
||||
if (isMinor && !guardianEmail) throw err('GUARDIAN_REQUIRED', 'A minor registration requires a guardian email.');
|
||||
|
||||
const existing = await pool.query(`SELECT * FROM offering_registrations WHERE external_reference = $1`, [eventId]);
|
||||
if (existing.rows[0]) return { registration: existing.rows[0], created: false };
|
||||
|
||||
const { rows: offeringRows } = await pool.query(`SELECT * FROM offerings WHERE slug = $1`, [offeringSlug]);
|
||||
const offering = offeringRows[0];
|
||||
if (!offering) throw err('OFFERING_NOT_FOUND', 'Unknown offering_slug.');
|
||||
assertRegistrationOpen(offering);
|
||||
|
||||
let guardianId = null;
|
||||
if (isMinor) {
|
||||
// Match an existing guardian by email first (a returning family shouldn't get a duplicate
|
||||
// guardian row per sibling/per camp) — same "match before create" principle
|
||||
// enrollment.js's resolveOrCreateStudent already established for students.
|
||||
const existingGuardian = await pool.query(`SELECT guardian_id FROM guardians WHERE email = $1`, [String(guardianEmail).toLowerCase().trim()]);
|
||||
if (existingGuardian.rows[0]) {
|
||||
guardianId = existingGuardian.rows[0].guardian_id;
|
||||
} else {
|
||||
guardianId = `guardian-${eventId}`;
|
||||
await createGuardian(pool, { guardianId, displayName: guardianDisplayName || guardianEmail, email: guardianEmail, phone: guardianPhone });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await reserveSeat(pool, offering.offering_id);
|
||||
} catch (e) {
|
||||
if (e.code === 'OFFERING_FULL') throw e;
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, source, external_reference, reservation_expires_at)
|
||||
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9)
|
||||
ON CONFLICT (external_reference) WHERE external_reference IS NOT NULL DO NOTHING
|
||||
RETURNING *`,
|
||||
[offering.offering_id, guardianId, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, source || 'website', eventId, computeReservationExpiry(offering)]
|
||||
);
|
||||
if (rows[0]) {
|
||||
await notificationsLib.enqueueForRegistration(pool, {
|
||||
registration: rows[0], notificationType: 'registration_received',
|
||||
metadata: { display_name: rows[0].registrant_display_name, offering_name: offering.name },
|
||||
}).catch((e) => console.error("[offerings] website registration_received notification failed:", e.message));
|
||||
return { registration: rows[0], created: true };
|
||||
}
|
||||
// Lost the ON CONFLICT race (concurrent identical replay) — release the seat we just
|
||||
// reserved (the winner already holds theirs) and return the winner's row.
|
||||
await releaseSeat(pool, offering.offering_id);
|
||||
const { rows: won } = await pool.query(`SELECT * FROM offering_registrations WHERE external_reference = $1`, [eventId]);
|
||||
return { registration: won[0], created: false };
|
||||
} catch (e) {
|
||||
await releaseSeat(pool, offering.offering_id).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Launch readiness (Step 20) — server-derived, never left to frontend logic alone ─────────
|
||||
async function computeOfferingReadiness(pool, offeringId) {
|
||||
const offering = await getOffering(pool, offeringId);
|
||||
if (!offering) throw err('NOT_FOUND', 'No such offering.');
|
||||
const checks = [];
|
||||
checks.push({ key: 'curriculum', label: 'Curriculum', ready: !!offering.program_id });
|
||||
const needsInstructor = offering.delivery_mode !== 'self_paced';
|
||||
if (needsInstructor) {
|
||||
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM instructor_offerings WHERE offering_id = $1`, [offeringId]);
|
||||
checks.push({ key: 'instructor', label: 'Instructor assigned', ready: rows[0].n > 0 });
|
||||
}
|
||||
const needsSchedule = !['self_paced'].includes(offering.delivery_mode);
|
||||
if (needsSchedule) checks.push({ key: 'schedule', label: 'Dates', ready: !!(offering.starts_at && offering.ends_at) });
|
||||
const needsLocation = !['self_paced'].includes(offering.delivery_mode);
|
||||
if (needsLocation) checks.push({ key: 'location', label: 'Location', ready: !!offering.location_id || offering.delivery_mode === 'community' });
|
||||
checks.push({ key: 'capacity', label: 'Capacity', ready: offering.capacity !== null || offering.delivery_mode === 'self_paced' });
|
||||
checks.push({ key: 'registration_window', label: 'Registration window', ready: !!(offering.enrollment_opens_at && offering.enrollment_closes_at) });
|
||||
const requirements = await listAgreementRequirementsForOffering(pool, offeringId);
|
||||
checks.push({ key: 'agreements', label: 'Agreements configured', ready: requirements.length > 0 });
|
||||
if (offering.curriculum_package_id) {
|
||||
const pkg = await getCurriculumPackage(pool, offering.curriculum_package_id);
|
||||
checks.push({ key: 'curriculum_package', label: 'Curriculum package valid', ready: !!pkg && pkg.items.length > 0 });
|
||||
}
|
||||
// Phase 9 §29 — a PAID offering (price_amount set) additionally needs a real price and a
|
||||
// configured payment provider before it's genuinely launch-ready; a free offering never
|
||||
// needs either, unchanged from Phase 8.
|
||||
if (offering.price_amount !== null) {
|
||||
checks.push({ key: 'price', label: 'Price configured', ready: offering.price_amount > 0 && !!offering.price_currency });
|
||||
const { getPaymentProvider } = require('./payment-provider');
|
||||
let providerConfigured = false;
|
||||
try { const p = getPaymentProvider(); providerConfigured = p.name === 'test' || !!(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_WEBHOOK_SECRET); } catch { providerConfigured = false; }
|
||||
checks.push({ key: 'payment_provider', label: 'Payment provider configured', ready: providerConfigured });
|
||||
}
|
||||
const allReady = checks.every((c) => c.ready);
|
||||
return { offering_id: offeringId, ready_to_open_registration: allReady, checks };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createOrganization, listOrganizations,
|
||||
createLocation, listLocations,
|
||||
createGuardian, linkStudentGuardian, listGuardiansForStudent,
|
||||
createCurriculumPackage, addCurriculumPackageItem, getCurriculumPackage,
|
||||
createOffering, getOffering, updateOffering, listOfferings, transitionOffering, assignInstructorToOffering, reserveSeat, releaseSeat,
|
||||
createRegistration, getRegistration, listRegistrationsForOffering, transitionRegistration, convertRegistrationToEnrollment,
|
||||
createAgreementDefinition, listAgreementDefinitions, recordAgreementAcceptance, listAcceptancesForRegistration,
|
||||
addAgreementRequirement, listAgreementRequirementsForOffering, getRegistrationAgreementStatus,
|
||||
createRegistrationFromIntegrationEvent, computeOfferingReadiness, assertRegistrationOpen,
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
// Phase 9 §3/§4 — the payment provider abstraction. commerce.js (registration/checkout
|
||||
// business logic) only ever calls the methods below — it never touches the Stripe SDK, an env
|
||||
// var, or a webhook signature format directly. Swapping providers later means writing a new
|
||||
// class here, not touching commerce.js/admin-commerce-routes.js/webhook-routes.js at all.
|
||||
//
|
||||
// PAYMENT_PROVIDER selects the implementation: 'stripe' (real, needs STRIPE_SECRET_KEY/
|
||||
// STRIPE_WEBHOOK_SECRET) or 'test' (deterministic, in-process, no network, no real money —
|
||||
// the default, matching EMAIL_PROVIDER's own "dev by default, real provider opt-in" precedent).
|
||||
// Every test/rehearsal in this phase runs against 'test'; nothing here has ever been exercised
|
||||
// against live Stripe credentials — see docs/STRIPE_INTEGRATION_RUNBOOK.md's own "pending
|
||||
// live validation" note.
|
||||
|
||||
const PAYMENT_PROVIDER = process.env.PAYMENT_PROVIDER || 'test';
|
||||
|
||||
// ── Stripe adapter ────────────────────────────────────────────────────────────────────────────
|
||||
class StripeProvider {
|
||||
constructor() {
|
||||
this.name = 'stripe';
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
console.error('WARNING: PAYMENT_PROVIDER=stripe but STRIPE_SECRET_KEY is not set — checkout creation and webhook verification will fail every call until it is.');
|
||||
}
|
||||
this.webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || null;
|
||||
if (!this.webhookSecret) {
|
||||
console.error('WARNING: PAYMENT_PROVIDER=stripe but STRIPE_WEBHOOK_SECRET is not set — every webhook delivery will be rejected until it is.');
|
||||
}
|
||||
// Lazy require — the stripe package is a real dependency (package.json), but constructing
|
||||
// the client eagerly at module load would crash a 'test'-provider dev stack that never
|
||||
// installs/needs a real key at all.
|
||||
const Stripe = require('stripe');
|
||||
this.client = secretKey ? new Stripe(secretKey) : null;
|
||||
}
|
||||
|
||||
// amountCents/currency come from the OFFERING's own server-side price, never browser input
|
||||
// (Step 5's explicit requirement) — commerce.js is the only caller, and it reads them from
|
||||
// the offerings table itself.
|
||||
async createCheckoutSession({ amountCents, currency, productName, successUrl, cancelUrl, metadata }) {
|
||||
if (!this.client) throw Object.assign(new Error('Stripe is not configured (STRIPE_SECRET_KEY missing).'), { code: 'PROVIDER_NOT_CONFIGURED' });
|
||||
const session = await this.client.checkout.sessions.create({
|
||||
mode: 'payment',
|
||||
line_items: [{ price_data: { currency, unit_amount: amountCents, product_data: { name: productName } }, quantity: 1 }],
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
metadata,
|
||||
// Phase 9.1 real-Stripe-TEST fix — a later payment_intent.payment_failed or
|
||||
// charge.refunded event references the PaymentIntent/Charge, never the Checkout Session
|
||||
// (a genuinely different Stripe object id, only discovered by testing against real
|
||||
// Stripe — the deterministic TestProvider always reused one caller-supplied reference for
|
||||
// every event type, which never exposed this). The Session's own `metadata` above does
|
||||
// NOT propagate to its PaymentIntent automatically; `payment_intent_data.metadata` does —
|
||||
// this is what lets normalizeEvent() below correlate those later events back to OUR OWN
|
||||
// transaction_id, not Stripe's session id.
|
||||
payment_intent_data: { metadata },
|
||||
});
|
||||
return { checkoutUrl: session.url, providerReference: session.id };
|
||||
}
|
||||
|
||||
// Real signature verification via Stripe's own SDK helper — never a hand-rolled HMAC compare.
|
||||
// Throws on a bad/forged signature; the webhook route treats that as a clean 400, never a 500.
|
||||
async verifyAndParseWebhook(rawBody, signatureHeader) {
|
||||
if (!this.client || !this.webhookSecret) throw Object.assign(new Error('Stripe webhook verification is not configured.'), { code: 'PROVIDER_NOT_CONFIGURED' });
|
||||
const event = this.client.webhooks.constructEvent(rawBody, signatureHeader, this.webhookSecret);
|
||||
return this.normalizeEvent(event);
|
||||
}
|
||||
|
||||
// Stripe's own event vocabulary -> RootNode's narrow, provider-neutral shape. Only the event
|
||||
// types this integration actually acts on are mapped to a real status; everything else comes
|
||||
// back with normalizedStatus: null, which webhook-routes.js records (for audit completeness)
|
||||
// but never acts on.
|
||||
//
|
||||
// providerReference is usually the Checkout Session id (matches payment_transactions.
|
||||
// provider_reference, set at checkout creation) — but payment_intent.payment_failed and
|
||||
// charge.refunded reference the PaymentIntent/Charge instead, which was never stored anywhere
|
||||
// as a lookup key. Both are correlated back via OUR OWN transaction_id, echoed back through
|
||||
// payment_intent_data.metadata at session creation (see createCheckoutSession above);
|
||||
// commerce.js's transaction lookup matches on either provider_reference OR transaction_id.
|
||||
async normalizeEvent(event) {
|
||||
const obj = event.data.object;
|
||||
let providerReference = obj.id;
|
||||
let normalizedStatus = null;
|
||||
if (event.type === 'checkout.session.completed') { normalizedStatus = 'paid'; providerReference = obj.id; }
|
||||
else if (event.type === 'checkout.session.expired') { normalizedStatus = 'cancelled'; providerReference = obj.id; }
|
||||
else if (event.type === 'payment_intent.payment_failed') {
|
||||
// obj IS the PaymentIntent — its own metadata already carries our transaction_id.
|
||||
normalizedStatus = 'failed'; providerReference = obj.metadata?.transaction_id || obj.id;
|
||||
} else if (event.type === 'charge.refunded') {
|
||||
normalizedStatus = obj.amount_refunded < obj.amount ? 'partially_refunded' : 'refunded';
|
||||
// obj is a Charge — Charges do not inherit their PaymentIntent's metadata, so retrieve
|
||||
// the PaymentIntent (one extra call, only on the rare refund path) to read ours back.
|
||||
let piMetadataRef = null;
|
||||
if (obj.payment_intent) {
|
||||
try { const pi = await this.client.paymentIntents.retrieve(obj.payment_intent); piMetadataRef = pi.metadata?.transaction_id || null; }
|
||||
catch { /* fall through to the raw PaymentIntent id below */ }
|
||||
}
|
||||
providerReference = piMetadataRef || obj.payment_intent || obj.id;
|
||||
}
|
||||
return { eventId: event.id, eventType: event.type, providerReference, normalizedStatus };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test/dev adapter — deterministic, in-process, no network, no real money ─────────────────
|
||||
// Every "checkout" is an immediately-resolvable fake URL; a rehearsal/test drives payment
|
||||
// outcomes by POSTing a fabricated event straight at the SAME webhook route real Stripe events
|
||||
// arrive on, through the SAME signature-verification interface (a static shared secret compare
|
||||
// here, standing in for Stripe's HMAC check) — the actual commerce.js processing code path is
|
||||
// therefore identical in both providers, only how a session/event gets created differs.
|
||||
class TestProvider {
|
||||
constructor() {
|
||||
this.name = 'test';
|
||||
this.sharedSecret = process.env.TEST_PAYMENT_WEBHOOK_SECRET || 'test-payment-secret-not-for-production';
|
||||
this._sessions = new Map(); // in-process only — fine, this provider only ever exists in dev/test
|
||||
}
|
||||
|
||||
async createCheckoutSession({ amountCents, currency, productName, successUrl, cancelUrl, metadata }) {
|
||||
const providerReference = `test_cs_${Math.random().toString(36).slice(2)}${Date.now()}`;
|
||||
this._sessions.set(providerReference, { amountCents, currency, metadata });
|
||||
return { checkoutUrl: `${successUrl}${successUrl.includes('?') ? '&' : '?'}test_session=${providerReference}`, providerReference };
|
||||
}
|
||||
|
||||
// A rehearsal/test calls this directly (not a real HTTP round trip) to build the exact body a
|
||||
// real client would POST to the webhook route.
|
||||
buildTestEvent({ providerReference, eventType }) {
|
||||
const body = JSON.stringify({ id: `evt_${Math.random().toString(36).slice(2)}${Date.now()}`, type: eventType, provider_reference: providerReference });
|
||||
return { body, signature: this.sharedSecret };
|
||||
}
|
||||
|
||||
verifyAndParseWebhook(rawBody, signatureHeader) {
|
||||
if (signatureHeader !== this.sharedSecret) throw Object.assign(new Error('Invalid test webhook signature.'), { code: 'INVALID_SIGNATURE' });
|
||||
const event = JSON.parse(rawBody);
|
||||
const statusByType = { 'checkout.session.completed': 'paid', 'checkout.session.expired': 'cancelled', 'payment_intent.payment_failed': 'failed', 'charge.refunded': 'refunded' };
|
||||
return { eventId: event.id, eventType: event.type, providerReference: event.provider_reference, normalizedStatus: statusByType[event.type] || null };
|
||||
}
|
||||
}
|
||||
|
||||
let cachedProvider = null;
|
||||
function getPaymentProvider() {
|
||||
if (cachedProvider) return cachedProvider;
|
||||
cachedProvider = PAYMENT_PROVIDER === 'stripe' ? new StripeProvider() : new TestProvider();
|
||||
return cachedProvider;
|
||||
}
|
||||
|
||||
module.exports = { getPaymentProvider, StripeProvider, TestProvider, PAYMENT_PROVIDER };
|
||||
@@ -0,0 +1,65 @@
|
||||
// Phase 8 §12 — the PUBLIC, unauthenticated offering-read surface the website reads to build
|
||||
// its own catalog/registration pages. No session of any kind (student or instructor) is
|
||||
// checked or accepted here — this is meant to be reachable by anyone, the same trust level as
|
||||
// the marketing site itself. An explicit allowlist serializer (never SELECT *, never returning
|
||||
// a raw row) is what keeps this safe: internal notes, access_notes, organization contact info,
|
||||
// instructor identity, and anything registration/student/guardian-shaped are never even
|
||||
// queried by this file, let alone serialized.
|
||||
|
||||
function toPublicOffering(row) {
|
||||
return {
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
program_id: row.program_id,
|
||||
delivery_mode: row.delivery_mode,
|
||||
recommended_min_age: row.recommended_min_age,
|
||||
recommended_max_age: row.recommended_max_age,
|
||||
starts_at: row.starts_at,
|
||||
ends_at: row.ends_at,
|
||||
registration_open: row.status === 'enrollment_open',
|
||||
// "availability" rather than an exact seat count — a real number invites scraping/timing
|
||||
// games from a public, unauthenticated endpoint; a coarse status is enough for a website to
|
||||
// build a real "Register" vs "Full" call to action.
|
||||
availability: row.capacity === null ? 'open' : row.reserved_seats >= row.capacity ? 'full' : (row.capacity - row.reserved_seats) <= 3 ? 'limited' : 'open',
|
||||
location_name: row.location_name || null,
|
||||
location_type: row.location_type || null,
|
||||
// Phase 9 §13/§17 — price is the one new public field. NULL means free/no-checkout, exactly
|
||||
// like the internal price_amount column — never a Stripe id, never internal commerce state.
|
||||
price_amount: row.price_amount,
|
||||
price_currency: row.price_currency,
|
||||
requires_payment: row.price_amount !== null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function registerPublicOfferingsRoutes(app, deps) {
|
||||
const { getPool, wrap } = deps;
|
||||
|
||||
// Only offerings a family/adult should actually be able to see: published or further along,
|
||||
// never draft (an admin still building it out) and never cancelled.
|
||||
const VISIBLE_STATUSES = ['published', 'enrollment_open', 'full', 'in_progress'];
|
||||
const PUBLIC_COLUMNS = `o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age,
|
||||
o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, o.price_amount, o.price_currency,
|
||||
l.name AS location_name, l.type AS location_type`;
|
||||
|
||||
app.get('/api/public/offerings', wrap(async (req, res) => {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT ${PUBLIC_COLUMNS}
|
||||
FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id
|
||||
WHERE o.status = ANY($1) ORDER BY o.starts_at NULLS LAST, o.name`,
|
||||
[VISIBLE_STATUSES]
|
||||
);
|
||||
res.json(rows.map(toPublicOffering));
|
||||
}));
|
||||
|
||||
app.get('/api/public/offerings/:slug', wrap(async (req, res) => {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT ${PUBLIC_COLUMNS}
|
||||
FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id
|
||||
WHERE o.slug = $1 AND o.status = ANY($2)`,
|
||||
[req.params.slug, VISIBLE_STATUSES]
|
||||
);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'not_found' });
|
||||
res.json(toPublicOffering(rows[0]));
|
||||
}));
|
||||
};
|
||||
@@ -35,6 +35,16 @@ const STUDENT_CSRF_COOKIE = 'rn_csrf_s';
|
||||
const app = express();
|
||||
if (process.env.TRUST_PROXY === '1') app.set('trust proxy', 1);
|
||||
|
||||
// Phase 9 §8 — the ONE route that needs the raw, unparsed request body: a payment provider's
|
||||
// webhook signature (Stripe's own HMAC, or the test provider's stand-in) is computed over the
|
||||
// exact bytes as sent, so express.json() must never touch this path before signature
|
||||
// verification runs. Registered before the global JSON parser below so Express's normal
|
||||
// path-then-registration-order middleware resolution gives this route the raw Buffer
|
||||
// (`req.rawBody`) instead — every other route is completely unaffected.
|
||||
app.use('/api/webhooks/payment', express.raw({ type: '*/*' }), (req, res, next) => {
|
||||
req.rawBody = req.body; // a Buffer here, never parsed — webhook-routes.js reads it directly
|
||||
next();
|
||||
});
|
||||
app.use(express.json());
|
||||
const FRONTEND_DIST = process.env.FRONTEND_DIST_DIR || path.join(__dirname, '../../../frontend/dist');
|
||||
app.use(express.static(FRONTEND_DIST));
|
||||
@@ -413,6 +423,28 @@ require('./notification-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, getPool, recordAudit,
|
||||
});
|
||||
|
||||
// Phase 7 — the business/product architecture: organizations, locations, guardians,
|
||||
// curriculum packages, offerings, registrations, and agreements. Admin/owner surface only —
|
||||
// no student-facing routes yet (registration is admin-entered this phase; a future public
|
||||
// registration flow is a Phase 8+ decision, see docs/OFFERING_AND_REGISTRATION_MODEL.md).
|
||||
require('./admin-offerings-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, getPool, recordAudit,
|
||||
});
|
||||
|
||||
// Phase 8 — the public, unauthenticated offering-read surface (no session of any kind), and
|
||||
// the website's registration submission endpoint (server-to-server, same shared-secret
|
||||
// boundary as /api/integrations/enrollments, registered alongside it in integration-routes.js).
|
||||
require('./public-offerings-routes')(app, { getPool, wrap });
|
||||
|
||||
// Phase 9 — the payment provider webhook (Stripe or the test provider), and the reservation
|
||||
// reaper for abandoned/unpaid registrations. See docs/PHASE9_COMMERCE_AND_LAUNCH.md.
|
||||
require('./webhook-routes')(app, { getPool, recordAudit, wrap });
|
||||
const commerceLib = require('./commerce');
|
||||
const RESERVATION_REAPER_POLL_MS = Number(process.env.RESERVATION_REAPER_POLL_MS) || 60000;
|
||||
setInterval(() => {
|
||||
commerceLib.reapExpiredReservations(getPool()).catch((e) => console.error('[commerce] reservation reaper failed:', e.message));
|
||||
}, RESERVATION_REAPER_POLL_MS);
|
||||
|
||||
// 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,43 @@
|
||||
// Phase 9 §8 — the verified payment-provider webhook endpoint. Server-to-server only (Stripe's
|
||||
// own servers, or a test/rehearsal script standing in for them) — no session of any kind is
|
||||
// checked or accepted here, the SAME trust boundary shape as /api/integrations/*, but the
|
||||
// authentication mechanism is different on purpose: a provider webhook proves itself via a
|
||||
// cryptographic signature over the exact raw body, not a static shared-secret header, which is
|
||||
// why this route needs the raw (unparsed) request body — see server.js's own route-registration
|
||||
// comment for how that's wired without breaking every other route's normal JSON body parsing.
|
||||
|
||||
const commerce = require('./commerce');
|
||||
const { getPaymentProvider } = require('./payment-provider');
|
||||
const studentsAuth = require('./students-auth');
|
||||
|
||||
module.exports = function registerWebhookRoutes(app, deps) {
|
||||
const { getPool, recordAudit, wrap } = deps;
|
||||
|
||||
// rawBody is attached by server.js's raw-body middleware, scoped to only this path — every
|
||||
// other route keeps using express.json() as before.
|
||||
app.post('/api/webhooks/payment', wrap(async (req, res) => {
|
||||
const provider = getPaymentProvider();
|
||||
const signature = req.get('Stripe-Signature') || req.get('X-Test-Payment-Signature');
|
||||
let normalizedEvent;
|
||||
try {
|
||||
normalizedEvent = await provider.verifyAndParseWebhook(req.rawBody, signature);
|
||||
} catch (e) {
|
||||
// A forged/invalid signature is a clean, quiet 400 — never a 500, never a hint about
|
||||
// WHY verification failed (timing/oracle hygiene, matching this codebase's existing
|
||||
// anti-enumeration precedent elsewhere).
|
||||
return res.status(400).json({ error: 'invalid_signature' });
|
||||
}
|
||||
|
||||
const result = await commerce.processWebhookEvent(getPool(), studentsAuth, provider, normalizedEvent);
|
||||
await recordAudit(getPool(), {
|
||||
actorType: 'integration', actorId: `payment_webhook:${provider.name}`, action: `payment_webhook_${result.result}`,
|
||||
resourceType: 'payment_webhook_event', resourceId: normalizedEvent.eventId, result: 'success', sourceIp: req.ip,
|
||||
metadata: { event_type: normalizedEvent.eventType, normalized_status: normalizedEvent.normalizedStatus || null },
|
||||
});
|
||||
// Always 200 once signature verification passes — Stripe retries on anything else, and a
|
||||
// duplicate/ignored/error-but-already-recorded event is not something we want retried
|
||||
// forever; the audit trail and payment_webhook_events row are the real record of what
|
||||
// happened, not the HTTP status.
|
||||
res.json({ received: true, result: result.result });
|
||||
}));
|
||||
};
|
||||
@@ -95,6 +95,14 @@ services:
|
||||
# e2e-phase5c.js sets this to "1" via .env for one test to prove production gating for
|
||||
# real, then unsets it again — see that test's own comment.
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-}
|
||||
# Phase 9.1 — real-Stripe-TEST-mode local validation. Empty/unset by default (fail-safe:
|
||||
# payment-provider.js defaults to the deterministic TestProvider when PAYMENT_PROVIDER is
|
||||
# unset — the same "dev by default, real provider opt-in" pattern EMAIL_PROVIDER already
|
||||
# uses above). Set all three in platform/.env only for a deliberate real-Stripe-TEST
|
||||
# session; STRIPE_SECRET_KEY must be a TEST key (sk_test_...) — never a live one.
|
||||
PAYMENT_PROVIDER: ${PAYMENT_PROVIDER:-}
|
||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
||||
# TRUST_PROXY: "1" # set once fronted by a trusted reverse proxy
|
||||
depends_on:
|
||||
postgres:
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 7 — Business & Product Architecture acceptance test. Organizations, locations,
|
||||
// guardians, curriculum packages, offerings (capacity, server-defined transitions),
|
||||
// registrations (lifecycle, agreements, conversion to real enrollment), RBAC, and explicit
|
||||
// proof that none of this depends on age or breaks ROOT_ V1's existing self-paced/classroom
|
||||
// flows. Same conventions as e2e-phase6a-f.js: assert-based, no framework, live
|
||||
// docker-compose.dev.yml stack.
|
||||
//
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase7.js
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('./session-helper');
|
||||
const { completeRootFoundations01Through04 } = require('./foundations-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||
}
|
||||
function psql(sql) {
|
||||
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 7 Business & Product Architecture acceptance test\n');
|
||||
const owner = makeSession(GW);
|
||||
{ const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(r.status, 200); }
|
||||
|
||||
// ── 1: course families are guidance, not hard age gates ──────────────────────────────────
|
||||
await test('programs.family accepts bootcamp; recommended_min/max_age is never read by any RBAC/authorization code', async () => {
|
||||
psql(`INSERT INTO programs (program_id, name, family, delivery_mode, curriculum_path, status) VALUES ('p7-bootcamp-check-${RUN_ID}', 'Bootcamp Check', 'bootcamp', 'self_paced', 'rootnode-path', 'active') ON CONFLICT DO NOTHING`);
|
||||
const row = psql(`SELECT family FROM programs WHERE program_id = 'p7-bootcamp-check-${RUN_ID}'`);
|
||||
assert.equal(row, 'bootcamp');
|
||||
const src = execSync('grep -rln "recommended_min_age\\|recommended_max_age" src/*.js', { cwd: `${__dirname}/../api`, stdio: 'pipe' }).toString();
|
||||
// Only offerings.js/admin-offerings-routes.js (Phase 7) and public-offerings-routes.js
|
||||
// (Phase 8's public read-only serializer — displays the fields, never authorizes on them)
|
||||
// should ever mention these fields, and none of the three is an authorization/RBAC file —
|
||||
// confirmed by name, not content-sniffing.
|
||||
for (const file of src.trim().split('\n').filter(Boolean)) {
|
||||
assert.ok(/offerings\.js|admin-offerings-routes\.js|public-offerings-routes\.js/.test(file), `unexpected file referencing age-guidance fields: ${file}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 2/3/4: offering creation, program association, delivery-mode behavior ────────────────
|
||||
const orgId = `org-p7-${RUN_ID}`;
|
||||
const locId = `loc-p7-${RUN_ID}`;
|
||||
let offeringA, offeringB, pkgId;
|
||||
await test('organization and two distinct locations are created', async () => {
|
||||
const org = await owner.request('POST', '/api/admin/organizations', { organizationId: orgId, name: 'P7 Test Org', type: 'library' });
|
||||
assert.equal(org.status, 201);
|
||||
const loc = await owner.request('POST', '/api/admin/locations', { locationId: locId, name: 'P7 Room', type: 'community_center', organizationId: orgId, accessNotes: 'internal only' });
|
||||
assert.equal(loc.status, 201);
|
||||
});
|
||||
|
||||
await test('a curriculum package references existing ROOT courses without duplicating content', async () => {
|
||||
pkgId = `pkg-p7-${RUN_ID}`;
|
||||
const pkg = await owner.request('POST', '/api/admin/curriculum-packages', { curriculumPackageId: pkgId, name: 'P7 Winter Package', programId: PROGRAM });
|
||||
assert.equal(pkg.status, 201);
|
||||
const item1 = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId: 'root-01-computing-linux-foundations', sequence: 1 });
|
||||
assert.equal(item1.status, 201);
|
||||
const item2 = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId: 'root-02-linux-administration-networking', sequence: 2 });
|
||||
assert.equal(item2.status, 201);
|
||||
// 19/20: references existing content, no duplication — the module/activity counts under
|
||||
// root-01/02 must be byte-identical to what course-content validation already knows about.
|
||||
const activityCount = psql(`SELECT count(*) FROM module_activities ma JOIN course_modules cm ON cm.module_id = ma.module_id WHERE cm.course_id IN ('root-01-computing-linux-foundations','root-02-linux-administration-networking')`);
|
||||
const get = await owner.request('GET', `/api/admin/curriculum-packages/${pkgId}`);
|
||||
assert.equal(get.status, 200);
|
||||
assert.equal(get.body.items.length, 2);
|
||||
assert.equal(get.body.items[0].course_id, 'root-01-computing-linux-foundations');
|
||||
assert.ok(Number(activityCount) > 0, 'the referenced courses must still have their real activities, unduplicated');
|
||||
});
|
||||
|
||||
await test('a non-existent course cannot be added to a curriculum package (reference, never fabricate)', async () => {
|
||||
const r = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId: 'root-99-does-not-exist', sequence: 3 });
|
||||
assert.equal(r.status, 404);
|
||||
assert.equal(r.body.error, 'COURSE_NOT_FOUND');
|
||||
});
|
||||
|
||||
await test('two offerings at two different locations coexist with no state confusion', async () => {
|
||||
offeringA = `off-a-${RUN_ID}`;
|
||||
offeringB = `off-b-${RUN_ID}`;
|
||||
const a = await owner.request('POST', '/api/admin/offerings', { offeringId: offeringA, slug: `p7-camp-a-${RUN_ID}`, name: 'Camp A', programId: PROGRAM, curriculumPackageId: pkgId, deliveryMode: 'camp', capacity: 2, locationId: locId, organizationId: orgId });
|
||||
assert.equal(a.status, 201);
|
||||
const b = await owner.request('POST', '/api/admin/offerings', { offeringId: offeringB, slug: `p7-camp-b-${RUN_ID}`, name: 'Camp B (self-paced)', programId: PROGRAM, deliveryMode: 'self_paced' });
|
||||
assert.equal(b.status, 201);
|
||||
assert.equal(b.body.capacity, null, 'a self-paced offering should be unlimited by default');
|
||||
// Phase 8 made registration-window enforcement real (server-side, not just client hiding a
|
||||
// button) — a still-draft offering now correctly rejects registration attempts. offeringB
|
||||
// needs to be open before anything in this file registers against it.
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringB}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringB}/transition`, { status: 'enrollment_open' });
|
||||
});
|
||||
|
||||
// ── 5: capacity ───────────────────────────────────────────────────────────────────────────
|
||||
await test('offering capacity is enforced atomically, and validation happens BEFORE seat reservation', async () => {
|
||||
for (const p of [{ status: 'published' }, { status: 'enrollment_open' }]) {
|
||||
const r = await owner.request('POST', `/api/admin/offerings/${offeringA}/transition`, p);
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
}
|
||||
const badMinor = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'No Guardian', isMinor: true });
|
||||
assert.equal(badMinor.status, 400, 'a validation failure must not consume a seat');
|
||||
const check0 = await owner.request('GET', `/api/admin/offerings/${offeringA}`);
|
||||
assert.equal(check0.body.reserved_seats, 0);
|
||||
|
||||
const reg1 = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'Reg One', registrantEmail: `reg1-${RUN_ID}@example.com` });
|
||||
assert.equal(reg1.status, 201);
|
||||
const reg2 = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'Reg Two', registrantEmail: `reg2-${RUN_ID}@example.com` });
|
||||
assert.equal(reg2.status, 201);
|
||||
const reg3 = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'Reg Three (overflow)', registrantEmail: `reg3-${RUN_ID}@example.com` });
|
||||
assert.equal(reg3.status, 409);
|
||||
assert.equal(reg3.body.error, 'OFFERING_FULL');
|
||||
});
|
||||
|
||||
// ── 7/8/9: organization/location/instructor assignment ───────────────────────────────────
|
||||
await test('a plain instructor is listed and assignable to an offering, reusing cohort_instructors\' role vocabulary', async () => {
|
||||
const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p7-stray-${RUN_ID}@example.com`, display_name: 'P7 Stray', role: 'instructor', password: 'P7StrayPass123!' });
|
||||
const link = await owner.request('POST', `/api/admin/offerings/${offeringA}/instructors`, { instructor_id: strayCreate.instructor_id, role: 'teacher' });
|
||||
assert.equal(link.status, 201);
|
||||
});
|
||||
|
||||
// ── 6/9: guardians, student-guardian relationship ────────────────────────────────────────
|
||||
const guardianId = `guardian-p7-${RUN_ID}`;
|
||||
await test('a guardian is created and can be linked to a student', async () => {
|
||||
const g = await owner.request('POST', '/api/admin/guardians', { guardianId, displayName: 'P7 Guardian', email: `guardian-${RUN_ID}@example.com` });
|
||||
assert.equal(g.status, 201);
|
||||
});
|
||||
|
||||
// ── 11/12: adult learner path without a guardian; minor path without corrupting identity ──
|
||||
let minorRegId, adultRegId;
|
||||
await test('a minor registration requires a guardian; an adult registration needs none', async () => {
|
||||
const minorReg = await owner.request('POST', `/api/admin/offerings/${offeringB}/registrations`, { guardianId, registrantDisplayName: 'P7 Minor', isMinor: true });
|
||||
assert.equal(minorReg.status, 201);
|
||||
minorRegId = minorReg.body.registration_id;
|
||||
const adultReg = await owner.request('POST', `/api/admin/offerings/${offeringB}/registrations`, { registrantDisplayName: 'P7 Adult', registrantEmail: `adult-${RUN_ID}@example.com` });
|
||||
assert.equal(adultReg.status, 201);
|
||||
adultRegId = adultReg.body.registration_id;
|
||||
});
|
||||
|
||||
// ── 13/14: registration lifecycle ────────────────────────────────────────────────────────
|
||||
await test('a registration moves through its real lifecycle, rejecting an invalid transition', async () => {
|
||||
const bad = await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'enrollment_activated' });
|
||||
assert.equal(bad.status, 409, 'cannot jump straight from started to enrollment_activated');
|
||||
for (const status of ['submitted', 'agreements_pending']) {
|
||||
const r = await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status });
|
||||
assert.equal(r.status, 200, `${status}: ${JSON.stringify(r.body)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 15: agreement-version acceptance ─────────────────────────────────────────────────────
|
||||
const agreementId = `agree-p7-${RUN_ID}`;
|
||||
await test('an agreement definition is created and a guardian acceptance is recorded with a version snapshot', async () => {
|
||||
const def = await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'P7 Test Agreement', bodySummary: 'Plain-language summary only.' });
|
||||
assert.equal(def.status, 201);
|
||||
const accept = await owner.request('POST', `/api/admin/registrations/${minorRegId}/agreements`, { agreementId, acceptedByType: 'guardian', acceptedByGuardianId: guardianId });
|
||||
assert.equal(accept.status, 201);
|
||||
assert.equal(accept.body.accepted_version, 1);
|
||||
const list = await owner.request('GET', `/api/admin/registrations/${minorRegId}/agreements`);
|
||||
assert.equal(list.body.length, 1);
|
||||
});
|
||||
|
||||
// ── 16/17/18/19: offering enrollment conversion, offering→cohort, self-paced without cohort ─
|
||||
await test('a confirmed registration converts to a real enrollment (self-paced offering, no cohort)', async () => {
|
||||
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'confirmed' });
|
||||
const convert = await owner.request('POST', `/api/admin/registrations/${minorRegId}/convert`, {});
|
||||
assert.equal(convert.status, 201, JSON.stringify(convert.body));
|
||||
assert.equal(convert.body.enrollment.program_id, PROGRAM);
|
||||
assert.equal(convert.body.enrollment.status, 'active');
|
||||
const student = convert.body.student;
|
||||
assert.ok(student.login_identifier && !student.email, 'a minor with no email gets a login_identifier, never a guardian email written to students.email');
|
||||
const cohortRow = psql(`SELECT count(*) FROM cohort_enrollments WHERE student_id = '${student.student_id}'`);
|
||||
assert.equal(Number(cohortRow), 0, 'offeringB has no cohort_id — no cohort membership should be created');
|
||||
});
|
||||
|
||||
// ── 21: historical package version stability ─────────────────────────────────────────────
|
||||
await test('bumping a curriculum package\'s version does not change its historical item list retroactively in a confusing way', async () => {
|
||||
psql(`UPDATE curriculum_packages SET version = version + 1 WHERE curriculum_package_id = '${pkgId}'`);
|
||||
const get = await owner.request('GET', `/api/admin/curriculum-packages/${pkgId}`);
|
||||
assert.equal(get.body.items.length, 2, 'items must survive a version bump on the package row itself');
|
||||
});
|
||||
|
||||
// ── 22/23: registration RBAC, guardian data RBAC ─────────────────────────────────────────
|
||||
await test('a plain instructor cannot view registrations or create a guardian/organization, but CAN list offerings', async () => {
|
||||
const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p7-rbac-${RUN_ID}@example.com`, display_name: 'P7 RBAC', role: 'instructor', password: 'P7RbacPass123!' });
|
||||
const stray = makeSession(GW);
|
||||
const login = await stray.request('POST', '/api/auth/instructor/login', { email: `p7-rbac-${RUN_ID}@example.com`, password: 'P7RbacPass123!' });
|
||||
assert.equal(login.status, 200);
|
||||
const regs = await stray.request('GET', `/api/admin/offerings/${offeringB}/registrations`);
|
||||
assert.equal(regs.status, 403);
|
||||
const guardianCreate = await stray.request('POST', '/api/admin/guardians', { guardianId: `g-rbac-${RUN_ID}`, displayName: 'Should Fail' });
|
||||
assert.equal(guardianCreate.status, 403);
|
||||
const orgCreate = await stray.request('POST', '/api/admin/organizations', { organizationId: `org-rbac-${RUN_ID}`, name: 'Should Fail' });
|
||||
assert.equal(orgCreate.status, 403);
|
||||
const listOfferings = await stray.request('GET', '/api/admin/offerings');
|
||||
assert.equal(listOfferings.status, 200, 'listing offerings themselves is not sensitive');
|
||||
const listLocations = await stray.request('GET', '/api/admin/locations');
|
||||
assert.equal(listLocations.status, 200);
|
||||
assert.ok(listLocations.body.every((l) => !('access_notes' in l)), 'a plain instructor must never receive access_notes');
|
||||
});
|
||||
|
||||
// ── 27: no age-based authorization lock anywhere ─────────────────────────────────────────
|
||||
await test('an offering with a recommended age range still accepts a registrant of any age (guidance only)', async () => {
|
||||
const agedOffering = `off-aged-${RUN_ID}`;
|
||||
const create = await owner.request('POST', '/api/admin/offerings', { offeringId: agedOffering, slug: `p7-aged-${RUN_ID}`, name: 'Aged Offering', programId: PROGRAM, deliveryMode: 'workshop', recommendedMinAge: 15, recommendedMaxAge: 18 });
|
||||
assert.equal(create.status, 201);
|
||||
await owner.request('POST', `/api/admin/offerings/${agedOffering}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${agedOffering}/transition`, { status: 'enrollment_open' });
|
||||
const reg = await owner.request('POST', `/api/admin/offerings/${agedOffering}/registrations`, { registrantDisplayName: 'Any Age Registrant', registrantEmail: `anyage-${RUN_ID}@example.com` });
|
||||
assert.equal(reg.status, 201, 'no age field on the registration itself, and none is checked');
|
||||
});
|
||||
|
||||
// ── 28/29/30: existing ROOT V1 / self-paced / classroom flows still work ─────────────────
|
||||
await test('an existing ROOT_ V1 self-paced enrollment (unrelated to any offering) still works end to end', async () => {
|
||||
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P7-RootV1-${RUN_ID}`, login_identifier: `p7-rootv1-${RUN_ID}` });
|
||||
const s = makeSession(GW);
|
||||
await s.request('POST', '/api/student/activate', { token: create.activation_token, password: 'RootV1Pass123!' });
|
||||
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
|
||||
const r = await s.request('POST', `/api/student/activities/root-01-m00-a01-lesson-welcome/complete`);
|
||||
assert.equal(r.status, 200);
|
||||
const { body } = await s.request('GET', `/api/student/programs/${enroll.enrollment_id}/courses`);
|
||||
assert.ok(body.courses.some((c) => c.course_id === 'root-01-computing-linux-foundations'));
|
||||
});
|
||||
|
||||
await test('an existing classroom (cohort) flow still works unaffected', async () => {
|
||||
const cohortId = `p7-cohort-check-${RUN_ID}`;
|
||||
const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P7 Cohort Check', tier: 'root' });
|
||||
assert.equal(create.status, 201);
|
||||
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P7 Check Session' });
|
||||
assert.equal(session.status, 201);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 8 — Enrollment Operations acceptance test. Admin offering/registration operations,
|
||||
// launch readiness, registration-window enforcement, capacity concurrency through the REAL
|
||||
// registration endpoints, the public offering serializer, the website registration integration
|
||||
// contract (idempotency, concurrent duplicates), agreements (required/missing/version snapshot,
|
||||
// blocking conversion), guardian recipient resolution, RBAC, and confirmation that ROOT_ V1/
|
||||
// self-paced/classroom flows are unaffected. Same conventions as e2e-phase6a-7.js.
|
||||
//
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||
// OWNER_EMAIL=... OWNER_PASSWORD=... INTEGRATION_SHARED_SECRET=... node test/e2e-phase8.js
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('./session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||
}
|
||||
function psql(sql) {
|
||||
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
|
||||
}
|
||||
async function integrationPost(path, body) {
|
||||
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 8 Enrollment Operations acceptance test\n');
|
||||
const owner = makeSession(GW);
|
||||
{ const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(r.status, 200); }
|
||||
|
||||
// ── 1/2/3: admin offering list/detail/CRUD ───────────────────────────────────────────────
|
||||
let offeringId, slug;
|
||||
await test('admin can create, list, and fetch an offering', async () => {
|
||||
offeringId = `off-p8-${RUN_ID}`; slug = `p8-camp-${RUN_ID}`;
|
||||
const create = await owner.request('POST', '/api/admin/offerings', { offeringId, slug, name: 'Phase 8 Test Camp', programId: PROGRAM, deliveryMode: 'camp', capacity: 3 });
|
||||
assert.equal(create.status, 201);
|
||||
const list = await owner.request('GET', '/api/admin/offerings');
|
||||
assert.ok(list.body.some((o) => o.offering_id === offeringId));
|
||||
const detail = await owner.request('GET', `/api/admin/offerings/${offeringId}`);
|
||||
assert.equal(detail.status, 200);
|
||||
assert.equal(detail.body.status, 'draft');
|
||||
});
|
||||
|
||||
// ── 4/5: status transitions, readiness calculation ───────────────────────────────────────
|
||||
await test('status transitions are server-defined; readiness reflects real configuration', async () => {
|
||||
const badTransition = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'in_progress' });
|
||||
assert.equal(badTransition.status, 409);
|
||||
const r1 = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
|
||||
assert.equal(r1.body.ready_to_open_registration, false);
|
||||
const instructorCreate = await owner.request('POST', '/api/admin/instructors', { email: `p8-lead-${RUN_ID}@example.com`, display_name: 'P8 Lead', role: 'instructor', password: 'P8LeadPass123!' });
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role: 'teacher' });
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
|
||||
const r2 = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
|
||||
const instructorCheck = r2.body.checks.find((c) => c.key === 'instructor');
|
||||
assert.equal(instructorCheck.ready, true);
|
||||
});
|
||||
|
||||
// ── 6/7: registration open/closed enforcement ────────────────────────────────────────────
|
||||
let closedOffering;
|
||||
await test('registration is rejected on a draft offering, and accepted once open', async () => {
|
||||
closedOffering = `off-p8-closed-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/offerings', { offeringId: closedOffering, slug: `p8-closed-${RUN_ID}`, name: 'Closed Offering', programId: PROGRAM, deliveryMode: 'workshop' });
|
||||
const rejected = await owner.request('POST', `/api/admin/offerings/${closedOffering}/registrations`, { registrantDisplayName: 'Too Early' });
|
||||
assert.equal(rejected.status, 409);
|
||||
assert.equal(rejected.body.error, 'OFFERING_NOT_OPEN');
|
||||
const accepted = await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, { registrantDisplayName: 'Adult First', registrantEmail: `adult-first-${RUN_ID}@example.com` });
|
||||
assert.equal(accepted.status, 201);
|
||||
});
|
||||
|
||||
// ── 8/9: full capacity + concurrent final-seat race ──────────────────────────────────────
|
||||
await test('capacity is enforced and a concurrent final-seat race yields exactly one winner', async () => {
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, { registrantDisplayName: 'Adult Second', registrantEmail: `adult-second-${RUN_ID}@example.com` });
|
||||
// capacity=3, 2 used — one seat left. Race two integration submissions for it.
|
||||
const [a, b] = await Promise.all([
|
||||
integrationPost('/api/integrations/registrations', { event_id: `p8-race-a-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race A', email: `racea-${RUN_ID}@example.com` } }),
|
||||
integrationPost('/api/integrations/registrations', { event_id: `p8-race-b-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race B', email: `raceb-${RUN_ID}@example.com` } }),
|
||||
]);
|
||||
const statuses = [a.status, b.status].sort();
|
||||
assert.deepEqual(statuses, [201, 409], `expected exactly one winner: ${JSON.stringify([a, b])}`);
|
||||
const final = await owner.request('GET', `/api/admin/offerings/${offeringId}`);
|
||||
assert.equal(final.body.reserved_seats, 3);
|
||||
assert.ok(final.body.reserved_seats <= final.body.capacity, 'must never overbook');
|
||||
});
|
||||
|
||||
// ── 10/11: public offering safe serializer, no internal fields leaked ───────────────────
|
||||
await test('the public offerings API never leaks internal/PII fields', async () => {
|
||||
const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json());
|
||||
const forbidden = ['access_notes', 'organization_id', 'location_id', 'created_by', 'reserved_seats', 'capacity', 'metadata'];
|
||||
for (const key of forbidden) assert.ok(!(key in pub), `public offering response must never include ${key}`);
|
||||
assert.equal(pub.availability, 'full', 'a fully-booked offering should report full availability, not a raw seat count');
|
||||
});
|
||||
|
||||
// ── 12/13/14: website registration integration, duplicate idempotency, concurrent dup ────
|
||||
let integrationOffering, integrationSlug;
|
||||
await test('website registration integration is idempotent under sequential AND concurrent replay', async () => {
|
||||
integrationOffering = `off-p8-web-${RUN_ID}`; integrationSlug = `p8-web-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/offerings', { offeringId: integrationOffering, slug: integrationSlug, name: 'Web Offering', programId: PROGRAM, deliveryMode: 'self_paced' });
|
||||
await owner.request('POST', `/api/admin/offerings/${integrationOffering}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${integrationOffering}/transition`, { status: 'enrollment_open' });
|
||||
const eventId = `p8-web-evt-${RUN_ID}`;
|
||||
const first = await integrationPost('/api/integrations/registrations', { event_id: eventId, offering_slug: integrationSlug, registrant: { display_name: 'Web Student', email: `webstudent-${RUN_ID}@example.com` } });
|
||||
assert.equal(first.status, 201);
|
||||
const replay = await integrationPost('/api/integrations/registrations', { event_id: eventId, offering_slug: integrationSlug, registrant: { display_name: 'Web Student', email: `webstudent-${RUN_ID}@example.com` } });
|
||||
assert.equal(replay.status, 200);
|
||||
assert.equal(replay.body.registration_id, first.body.registration_id);
|
||||
// Concurrent duplicate — two identical submissions racing the same event_id.
|
||||
const concEventId = `p8-web-conc-${RUN_ID}`;
|
||||
const [c1, c2] = await Promise.all([
|
||||
integrationPost('/api/integrations/registrations', { event_id: concEventId, offering_slug: integrationSlug, registrant: { display_name: 'Conc Student', email: `conc-${RUN_ID}@example.com` } }),
|
||||
integrationPost('/api/integrations/registrations', { event_id: concEventId, offering_slug: integrationSlug, registrant: { display_name: 'Conc Student', email: `conc-${RUN_ID}@example.com` } }),
|
||||
]);
|
||||
assert.equal(c1.body.registration_id, c2.body.registration_id, 'a concurrent duplicate submission must resolve to exactly one logical registration');
|
||||
const count = psql(`SELECT count(*) FROM offering_registrations WHERE registration_id = '${c1.body.registration_id}'`);
|
||||
assert.equal(Number(count), 1);
|
||||
});
|
||||
|
||||
// ── 15/16/17: adult registration, minor registration, guardian relationship ──────────────
|
||||
let guardianId, minorRegId;
|
||||
await test('adult registration needs no guardian; minor registration creates/links a real guardian', async () => {
|
||||
guardianId = `guardian-p8-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/guardians', { guardianId, displayName: 'P8 Guardian', email: `p8guardian-${RUN_ID}@example.com` });
|
||||
const minorReg = await owner.request('POST', `/api/admin/offerings/${integrationOffering}/registrations`, { guardianId, registrantDisplayName: 'P8 Minor', isMinor: true });
|
||||
assert.equal(minorReg.status, 201);
|
||||
minorRegId = minorReg.body.registration_id;
|
||||
const badMinor = await owner.request('POST', `/api/admin/offerings/${integrationOffering}/registrations`, { registrantDisplayName: 'No Guardian', isMinor: true });
|
||||
assert.equal(badMinor.status, 400);
|
||||
});
|
||||
|
||||
// ── 18: student email semantics preserved (no guardian email written to students.email) ──
|
||||
await test('converting a minor registration never writes the guardian email into students.email', async () => {
|
||||
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'submitted' });
|
||||
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'confirmed' });
|
||||
const convert = await owner.request('POST', `/api/admin/registrations/${minorRegId}/convert`, {});
|
||||
assert.equal(convert.status, 201, JSON.stringify(convert.body));
|
||||
assert.equal(convert.body.student.email, null);
|
||||
assert.ok(convert.body.student.login_identifier.startsWith('reg-'));
|
||||
});
|
||||
|
||||
// ── 19/20/21: required agreement behavior, version snapshot, missing blocks conversion ───
|
||||
let agreementId, agreementOffering;
|
||||
await test('a required agreement blocks conversion until accepted; acceptance snapshots the version', async () => {
|
||||
agreementId = `agree-p8-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'P8 Agreement' });
|
||||
agreementOffering = `off-p8-agree-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/offerings', { offeringId: agreementOffering, slug: `p8-agree-${RUN_ID}`, name: 'Agreement Offering', programId: PROGRAM, deliveryMode: 'workshop' });
|
||||
await owner.request('POST', `/api/admin/offerings/${agreementOffering}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${agreementOffering}/transition`, { status: 'enrollment_open' });
|
||||
await owner.request('POST', `/api/admin/offerings/${agreementOffering}/agreement-requirements`, { agreement_id: agreementId });
|
||||
const reg = await owner.request('POST', `/api/admin/offerings/${agreementOffering}/registrations`, { registrantDisplayName: 'Agreement Tester', registrantEmail: `agreetest-${RUN_ID}@example.com` });
|
||||
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'submitted' });
|
||||
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'confirmed' });
|
||||
const blocked = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/convert`, {});
|
||||
assert.equal(blocked.status, 409);
|
||||
assert.equal(blocked.body.error, 'AGREEMENTS_INCOMPLETE');
|
||||
const accept = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/agreements`, { agreementId, acceptedByType: 'registrant' });
|
||||
assert.equal(accept.status, 201);
|
||||
assert.equal(accept.body.accepted_version, 1);
|
||||
psql(`UPDATE agreement_definitions SET version = version + 1 WHERE agreement_id = '${agreementId}'`);
|
||||
const accepted = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/agreement-status`);
|
||||
assert.equal(accepted.body.missing.length, 0, 'the already-recorded acceptance must still count even after the definition\'s live version changed');
|
||||
const nowOk = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/convert`, {});
|
||||
assert.equal(nowOk.status, 201);
|
||||
});
|
||||
|
||||
// ── 22/23: registration→enrollment, offering→cohort ──────────────────────────────────────
|
||||
await test('a cohort-backed offering creates real cohort membership on conversion', async () => {
|
||||
const cohortId = `p8-cohort-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P8 Cohort', tier: 'root' });
|
||||
const cohortOffering = `off-p8-cohort-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/offerings', { offeringId: cohortOffering, slug: `p8-cohort-off-${RUN_ID}`, name: 'Cohort Offering', programId: PROGRAM, deliveryMode: 'cohort', cohortId });
|
||||
await owner.request('POST', `/api/admin/offerings/${cohortOffering}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${cohortOffering}/transition`, { status: 'enrollment_open' });
|
||||
const reg = await owner.request('POST', `/api/admin/offerings/${cohortOffering}/registrations`, { registrantDisplayName: 'Cohort Student', registrantEmail: `cohortstudent-${RUN_ID}@example.com` });
|
||||
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'submitted' });
|
||||
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'confirmed' });
|
||||
const convert = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/convert`, {});
|
||||
const cohortRow = psql(`SELECT count(*) FROM cohort_enrollments WHERE cohort_id = '${cohortId}' AND student_id = '${convert.body.student.student_id}'`);
|
||||
assert.equal(Number(cohortRow), 1);
|
||||
});
|
||||
|
||||
// ── 24/25: account activation, notification queued ───────────────────────────────────────
|
||||
await test('a converted student has a real activation token and a queued registration notification', async () => {
|
||||
const notifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
|
||||
assert.ok(notifs.body.length > 0, 'at least a registration_received notification must have been queued');
|
||||
assert.ok(notifs.body.every((n) => !n.recipient_email_masked || n.recipient_email_masked.includes('*')), 'recipient email must always be masked in the admin view');
|
||||
});
|
||||
|
||||
// ── 26: guardian recipient resolution ────────────────────────────────────────────────────
|
||||
await test('notification recipient resolution prefers a real guardian when the student has no email', async () => {
|
||||
const notifications = require('../api/src/notifications');
|
||||
const { Pool } = require('../api/node_modules/pg');
|
||||
const pool = new Pool({ connectionString: process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform' });
|
||||
try {
|
||||
const { rows: studentRows } = await pool.query(`SELECT student_id FROM offering_registrations WHERE registration_id = '${minorRegId}'`);
|
||||
const studentId = studentRows[0].student_id;
|
||||
await pool.query(`INSERT INTO student_guardians (student_id, guardian_id, is_primary_contact, can_receive_communications) VALUES ($1, $2, true, true) ON CONFLICT (student_id, guardian_id) DO NOTHING`, [studentId, guardianId]);
|
||||
const recipient = await notifications.resolveRecipient(pool, studentId, null);
|
||||
assert.equal(recipient, `p8guardian-${RUN_ID}@example.com`);
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
// ── 28/29: instructor offering access, instructor guardian-data denial ──────────────────
|
||||
await test('a plain instructor can see offerings but not guardians/registrations; RBAC holds', async () => {
|
||||
const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p8-stray-${RUN_ID}@example.com`, display_name: 'P8 Stray', role: 'instructor', password: 'P8StrayPass123!' });
|
||||
const stray = makeSession(GW);
|
||||
await stray.request('POST', '/api/auth/instructor/login', { email: `p8-stray-${RUN_ID}@example.com`, password: 'P8StrayPass123!' });
|
||||
const offeringsList = await stray.request('GET', '/api/admin/offerings');
|
||||
assert.equal(offeringsList.status, 200);
|
||||
const guardianDenied = await stray.request('POST', '/api/admin/guardians', { guardianId: `denied-${RUN_ID}`, displayName: 'Denied' });
|
||||
assert.equal(guardianDenied.status, 403);
|
||||
const registrationsDenied = await stray.request('GET', `/api/admin/offerings/${offeringId}/registrations`);
|
||||
assert.equal(registrationsDenied.status, 403);
|
||||
});
|
||||
|
||||
// ── 31: organization/location behavior ───────────────────────────────────────────────────
|
||||
await test('organization/location creation and access_notes RBAC stripping', async () => {
|
||||
const orgId = `org-p8-${RUN_ID}`, locId = `loc-p8-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/organizations', { organizationId: orgId, name: 'P8 Org', type: 'library' });
|
||||
await owner.request('POST', '/api/admin/locations', { locationId: locId, organizationId: orgId, name: 'P8 Loc', accessNotes: 'secret internal code' });
|
||||
const asOwner = await owner.request('GET', '/api/admin/locations');
|
||||
assert.ok(asOwner.body.find((l) => l.location_id === locId).access_notes);
|
||||
});
|
||||
|
||||
// ── 33/34/35: existing ROOT V1 / self-paced / classroom flows unaffected ────────────────
|
||||
await test('an existing ROOT_ V1 self-paced enrollment is completely unaffected by Phase 8', async () => {
|
||||
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P8-RootV1-${RUN_ID}`, login_identifier: `p8-rootv1-${RUN_ID}` });
|
||||
const s = makeSession(GW);
|
||||
await s.request('POST', '/api/student/activate', { token: create.activation_token, password: 'RootV1Pass123!' });
|
||||
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
|
||||
const r = await s.request('POST', `/api/student/activities/root-01-m00-a01-lesson-welcome/complete`);
|
||||
assert.equal(r.status, 200);
|
||||
});
|
||||
await test('an existing classroom (cohort) flow still works unaffected', async () => {
|
||||
const cohortId = `p8-cohort-check-${RUN_ID}`;
|
||||
const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P8 Cohort Check', tier: 'root' });
|
||||
assert.equal(create.status, 201);
|
||||
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P8 Check Session' });
|
||||
assert.equal(session.status, 201);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 9 — Commerce Integration acceptance test. Price configuration, the public price
|
||||
// serializer, checkout eligibility (agreements/registration-window/price), server-authoritative
|
||||
// amount, verified webhook processing (signature, idempotency, success/failure/refund),
|
||||
// registration confirmation -> enrollment conversion -> activation, guardian recipient routing,
|
||||
// capacity/seat-reservation behavior, abandoned-reservation release, admin payment visibility,
|
||||
// RBAC, audit/metric hygiene, and confirmation ROOT_ V1/Phase 8 registration/classroom flows
|
||||
// are all unaffected. Uses the 'test' payment provider throughout — no real Stripe credentials,
|
||||
// no real money, deterministic.
|
||||
//
|
||||
// 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-phase9.js
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('./session-helper');
|
||||
const { getPaymentProvider } = require('../api/src/payment-provider');
|
||||
const commerce = require('../api/src/commerce');
|
||||
const { Pool } = require('../api/node_modules/pg');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
const pool = new Pool({ connectionString: process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform' });
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||
}
|
||||
function psql(sql) {
|
||||
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
|
||||
}
|
||||
async function integrationPost(path, body) {
|
||||
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
}
|
||||
async function integrationGet(path) {
|
||||
const r = await fetch(`${GW}${path}`, { headers: { 'X-RootNode-Integration-Key': INTEGRATION_KEY } });
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
}
|
||||
async function webhookPost(body, signature) {
|
||||
const r = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Test-Payment-Signature': signature }, body });
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
}
|
||||
|
||||
async function setupPaidOffering(owner, { offeringId, slug, capacity, requireAgreement = true }) {
|
||||
await owner.request('POST', '/api/admin/offerings', { offeringId, slug, name: `Test Offering ${offeringId}`, programId: PROGRAM, deliveryMode: 'camp', capacity, priceAmount: 15000, priceCurrency: 'usd' });
|
||||
if (requireAgreement) {
|
||||
const agreementId = `agree-${offeringId}`;
|
||||
await owner.request('POST', '/api/admin/agreements', { agreementId, title: `Agreement for ${offeringId}` }).catch(() => {});
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId });
|
||||
}
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 9 Commerce Integration 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); }
|
||||
const provider = getPaymentProvider();
|
||||
assert.equal(provider.name, 'test', 'this suite must run against the deterministic test provider, never real Stripe');
|
||||
|
||||
// ── 1/2: price configuration, public price serializer ────────────────────────────────────
|
||||
const offeringId = `off-p9-${RUN_ID}`, slug = `p9-camp-${RUN_ID}`;
|
||||
await test('an offering price is configured and reflected in the public serializer, never leaking internal fields', async () => {
|
||||
await setupPaidOffering(owner, { offeringId, slug, capacity: 3 });
|
||||
const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json());
|
||||
assert.equal(pub.price_amount, 15000);
|
||||
assert.equal(pub.price_currency, 'usd');
|
||||
assert.equal(pub.requires_payment, true);
|
||||
for (const key of ['transaction_id', 'provider_reference', 'reserved_seats', 'organization_id']) assert.ok(!(key in pub), `must not leak ${key}`);
|
||||
});
|
||||
|
||||
// ── 3/4: checkout eligibility, missing agreements rejected ───────────────────────────────
|
||||
let registrationId;
|
||||
await test('checkout is rejected until required agreements are accepted', async () => {
|
||||
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-reg-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Test Adult', email: `testadult-${RUN_ID}@example.com` } });
|
||||
assert.equal(reg.status, 201);
|
||||
registrationId = reg.body.registration_id;
|
||||
const blocked = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
|
||||
assert.equal(blocked.status, 409);
|
||||
assert.equal(blocked.body.error, 'AGREEMENTS_INCOMPLETE');
|
||||
});
|
||||
|
||||
// ── 5: closed registration rejected ───────────────────────────────────────────────────────
|
||||
await test('registration is rejected on a draft/closed offering', async () => {
|
||||
const draftOffering = `off-p9-draft-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/offerings', { offeringId: draftOffering, slug: `p9-draft-${RUN_ID}`, name: 'Draft', programId: PROGRAM, deliveryMode: 'workshop' });
|
||||
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-draft-evt-${RUN_ID}`, offering_slug: `p9-draft-${RUN_ID}`, registrant: { display_name: 'Nope', email: `nope-${RUN_ID}@example.com` } });
|
||||
assert.equal(reg.status, 409);
|
||||
assert.equal(reg.body.error, 'OFFERING_NOT_OPEN');
|
||||
});
|
||||
|
||||
// ── 6: full offering rejected ─────────────────────────────────────────────────────────────
|
||||
await test('a full offering rejects further registrations', async () => {
|
||||
const fullOffering = `off-p9-full-${RUN_ID}`, fullSlug = `p9-full-${RUN_ID}`;
|
||||
await setupPaidOffering(owner, { offeringId: fullOffering, slug: fullSlug, capacity: 1, requireAgreement: false });
|
||||
const r1 = await integrationPost('/api/integrations/registrations', { event_id: `p9-full-1-${RUN_ID}`, offering_slug: fullSlug, registrant: { display_name: 'A', email: `fa-${RUN_ID}@example.com` } });
|
||||
assert.equal(r1.status, 201);
|
||||
const r2 = await integrationPost('/api/integrations/registrations', { event_id: `p9-full-2-${RUN_ID}`, offering_slug: fullSlug, registrant: { display_name: 'B', email: `fb-${RUN_ID}@example.com` } });
|
||||
assert.equal(r2.status, 409);
|
||||
assert.equal(r2.body.error, 'OFFERING_FULL');
|
||||
});
|
||||
|
||||
// ── 7/8: server-authoritative amount, checkout creation ──────────────────────────────────
|
||||
let transactionId, providerReference;
|
||||
await test('accepting the agreement unblocks checkout, and the amount always comes from the offering, never the caller', async () => {
|
||||
const agreementId = `agree-${offeringId}`;
|
||||
const accept = await integrationPost(`/api/integrations/registrations/${registrationId}/agreements`, { agreement_id: agreementId });
|
||||
assert.equal(accept.status, 201);
|
||||
const checkout = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c', amount: 1 });
|
||||
assert.equal(checkout.status, 201, JSON.stringify(checkout.body));
|
||||
transactionId = checkout.body.transaction_id;
|
||||
const tx = psql(`SELECT amount FROM payment_transactions WHERE transaction_id = '${transactionId}'`);
|
||||
assert.equal(Number(tx), 15000, 'the transaction amount must come from the offering, ignoring any amount the caller sent');
|
||||
const txRow = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
|
||||
providerReference = txRow.body[0].provider_reference;
|
||||
});
|
||||
|
||||
// ── 9/10/11: webhook signature, invalid rejection, idempotency ───────────────────────────
|
||||
await test('webhook signature is required, invalid signatures are rejected, and valid events are idempotent', async () => {
|
||||
const event = provider.buildTestEvent({ providerReference, eventType: 'checkout.session.completed' });
|
||||
const bad = await webhookPost(event.body, 'wrong-signature');
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error, 'invalid_signature');
|
||||
const first = await webhookPost(event.body, event.signature);
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(first.body.result, 'processed');
|
||||
const replay = await webhookPost(event.body, event.signature);
|
||||
assert.equal(replay.status, 200);
|
||||
assert.equal(replay.body.result, 'duplicate');
|
||||
const count = psql(`SELECT count(*) FROM payment_transactions WHERE transaction_id = '${transactionId}' AND status = 'paid'`);
|
||||
assert.equal(Number(count), 1, 'a duplicate webhook must never double-process a payment');
|
||||
});
|
||||
|
||||
// ── 12/14/15/16: payment success, registration confirmation, conversion, activation ──────
|
||||
let convertedStudentId;
|
||||
await test('payment success confirms the registration, converts it to a real enrollment, and queues activation', async () => {
|
||||
const reg = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
|
||||
assert.equal(reg.body.status, 'enrollment_activated');
|
||||
convertedStudentId = reg.body.student_id;
|
||||
assert.ok(convertedStudentId);
|
||||
const enrollmentStatus = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${reg.body.enrollment_id}'`);
|
||||
assert.equal(enrollmentStatus, 'active');
|
||||
const notifs = await owner.request('GET', `/api/admin/registrations/${registrationId}/notifications`);
|
||||
const types = notifs.body.map((n) => n.notification_type);
|
||||
assert.ok(types.includes('registration_received') && types.includes('registration_confirmed') && types.includes('activation'), `expected all three, got ${types}`);
|
||||
// no duplicate registration_confirmed
|
||||
assert.equal(types.filter((t) => t === 'registration_confirmed').length, 1);
|
||||
});
|
||||
|
||||
// ── 13: payment failure ────────────────────────────────────────────────────────────────────
|
||||
await test('a failed payment does not release the seat and leaves the registration retriable', async () => {
|
||||
const failOffering = `off-p9-failtest-${RUN_ID}`, failSlug = `p9-failtest-${RUN_ID}`;
|
||||
await setupPaidOffering(owner, { offeringId: failOffering, slug: failSlug, capacity: 2, requireAgreement: false });
|
||||
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-failreg-${RUN_ID}`, offering_slug: failSlug, registrant: { display_name: 'Fail', email: `fail-${RUN_ID}@example.com` } });
|
||||
const checkout = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
|
||||
const txRow = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
|
||||
const event = provider.buildTestEvent({ providerReference: txRow.body[0].provider_reference, eventType: 'payment_intent.payment_failed' });
|
||||
const result = await webhookPost(event.body, event.signature);
|
||||
assert.equal(result.body.result, 'processed');
|
||||
const offAfter = await owner.request('GET', `/api/admin/offerings/${failOffering}`);
|
||||
assert.equal(offAfter.body.reserved_seats, 1, 'a failed payment must not release the seat');
|
||||
const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`);
|
||||
assert.equal(regAfter.body.status, 'started', 'a failed payment leaves the registration open to retry, not cancelled');
|
||||
});
|
||||
|
||||
// Phase 9.1 real-Stripe-TEST regression — a real Stripe payment_intent.payment_failed/
|
||||
// charge.refunded event references the PaymentIntent/Charge, not the Checkout Session, so
|
||||
// StripeProvider.normalizeEvent() resolves those back to OUR OWN transaction_id (never the
|
||||
// stored provider_reference) via payment_intent_data.metadata. This deterministic test
|
||||
// simulates exactly that shape — a webhook event whose providerReference is the transaction's
|
||||
// own UUID, not its provider_reference — proving commerce.js's lookup matches on either
|
||||
// column. Found and fixed only by testing against real Stripe (the deterministic
|
||||
// provider previously always reused one caller-supplied reference, which never exposed this).
|
||||
await test('a webhook event referencing OUR OWN transaction_id (not provider_reference) — the real Stripe payment_intent/charge shape — is still matched', async () => {
|
||||
const idOffering = `off-p9-txidref-${RUN_ID}`, idSlug = `p9-txidref-${RUN_ID}`;
|
||||
await setupPaidOffering(owner, { offeringId: idOffering, slug: idSlug, capacity: 2, requireAgreement: false });
|
||||
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-txidref-evt-${RUN_ID}`, offering_slug: idSlug, registrant: { display_name: 'TxIdRef', email: `txidref-${RUN_ID}@example.com` } });
|
||||
await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
|
||||
const txRow = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
|
||||
const transactionId = txRow.body[0].transaction_id;
|
||||
assert.notEqual(transactionId, txRow.body[0].provider_reference, 'sanity check — these must be genuinely different values for this test to mean anything');
|
||||
const event = provider.buildTestEvent({ providerReference: transactionId, eventType: 'payment_intent.payment_failed' });
|
||||
const result = await webhookPost(event.body, event.signature);
|
||||
assert.equal(result.body.result, 'processed', 'the transaction must be found via transaction_id, not just provider_reference');
|
||||
const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`);
|
||||
assert.equal(regAfter.body.status, 'started', 'a failed payment (matched via transaction_id) still leaves the registration retriable, not cancelled');
|
||||
});
|
||||
|
||||
// ── 19/21: capacity handling, last-seat race ─────────────────────────────────────────────
|
||||
await test('a last-seat race between two concurrent registration attempts yields exactly one winner', async () => {
|
||||
const raceOffering = `off-p9-race-${RUN_ID}`, raceSlug = `p9-race-${RUN_ID}`;
|
||||
await setupPaidOffering(owner, { offeringId: raceOffering, slug: raceSlug, capacity: 1, requireAgreement: false });
|
||||
const [a, b] = await Promise.all([
|
||||
integrationPost('/api/integrations/registrations', { event_id: `p9-race-a-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'RaceA', email: `ra-${RUN_ID}@example.com` } }),
|
||||
integrationPost('/api/integrations/registrations', { event_id: `p9-race-b-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'RaceB', email: `rb-${RUN_ID}@example.com` } }),
|
||||
]);
|
||||
const statuses = [a.status, b.status].sort();
|
||||
assert.deepEqual(statuses, [201, 409]);
|
||||
const off = await owner.request('GET', `/api/admin/offerings/${raceOffering}`);
|
||||
assert.equal(off.body.reserved_seats, 1);
|
||||
});
|
||||
|
||||
// ── 20/21: abandoned reservation release ─────────────────────────────────────────────────
|
||||
await test('an expired, unpaid reservation is released by the reaper and its registration cancelled', async () => {
|
||||
const reapOffering = `off-p9-reap-${RUN_ID}`, reapSlug = `p9-reap-${RUN_ID}`;
|
||||
await setupPaidOffering(owner, { offeringId: reapOffering, slug: reapSlug, capacity: 2, requireAgreement: false });
|
||||
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-reap-evt-${RUN_ID}`, offering_slug: reapSlug, registrant: { display_name: 'Reap', email: `reap-${RUN_ID}@example.com` } });
|
||||
psql(`UPDATE offering_registrations SET reservation_expires_at = now() - interval '1 minute' WHERE registration_id = '${reg.body.registration_id}'`);
|
||||
const result = await commerce.reapExpiredReservations(pool);
|
||||
assert.ok(result.released >= 1);
|
||||
const offAfter = await owner.request('GET', `/api/admin/offerings/${reapOffering}`);
|
||||
assert.equal(offAfter.body.reserved_seats, 0);
|
||||
const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`);
|
||||
assert.equal(regAfter.body.status, 'cancelled');
|
||||
});
|
||||
|
||||
// ── 22: refund recording ──────────────────────────────────────────────────────────────────
|
||||
await test('a manual refund is recorded without cascading into registration/enrollment cancellation', async () => {
|
||||
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
|
||||
const paid = tx.body.find((t) => t.status === 'paid');
|
||||
const refund = await owner.request('POST', `/api/admin/payments/${paid.transaction_id}/refund`, { status: 'refunded' });
|
||||
assert.equal(refund.status, 200);
|
||||
assert.equal(refund.body.status, 'refunded');
|
||||
const regAfter = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
|
||||
assert.equal(regAfter.body.status, 'enrollment_activated', 'a refund must never auto-cancel the registration');
|
||||
const enrollmentStatus = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${regAfter.body.enrollment_id}'`);
|
||||
assert.equal(enrollmentStatus, 'active', 'a refund must never auto-cancel the academic enrollment');
|
||||
});
|
||||
|
||||
// ── 17: guardian recipient routing ────────────────────────────────────────────────────────
|
||||
await test('a minor payment confirmation routes to the guardian, never overwriting students.email', async () => {
|
||||
const minorOffering = `off-p9-minor-${RUN_ID}`, minorSlug = `p9-minor-${RUN_ID}`;
|
||||
await setupPaidOffering(owner, { offeringId: minorOffering, slug: minorSlug, capacity: 2, requireAgreement: false });
|
||||
const submit = await integrationPost('/api/integrations/registrations', {
|
||||
event_id: `p9-minor-evt-${RUN_ID}`, offering_slug: minorSlug,
|
||||
registrant: { display_name: 'Minor Camper', is_minor: true },
|
||||
guardian: { display_name: 'Minor Guardian', email: `minorguardian-${RUN_ID}@example.com` },
|
||||
});
|
||||
assert.equal(submit.status, 201);
|
||||
const checkout = await integrationPost(`/api/integrations/registrations/${submit.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
|
||||
const txRow = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}/payments`);
|
||||
const event = provider.buildTestEvent({ providerReference: txRow.body[0].provider_reference, eventType: 'checkout.session.completed' });
|
||||
await webhookPost(event.body, event.signature);
|
||||
const regAfter = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}`);
|
||||
const studentEmail = psql(`SELECT email FROM students WHERE student_id = '${regAfter.body.student_id}'`);
|
||||
assert.equal(studentEmail, '', 'a minor student must never get the guardian email written to students.email');
|
||||
const notifs = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}/notifications`);
|
||||
assert.ok(notifs.body.some((n) => n.notification_type === 'activation'));
|
||||
});
|
||||
|
||||
// ── 18: admin payment view ────────────────────────────────────────────────────────────────
|
||||
await test('admin payment view shows amount/status/provider but never card data (none is ever stored)', async () => {
|
||||
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
|
||||
assert.ok(tx.body.length > 0);
|
||||
for (const t of tx.body) {
|
||||
for (const forbidden of ['card_number', 'cvv', 'card_last4', 'payment_method_details']) assert.ok(!(forbidden in t));
|
||||
}
|
||||
});
|
||||
|
||||
// ── 24/25: instructor payment-data restrictions, student payment isolation ──────────────
|
||||
await test('a plain instructor cannot see registration payment data; the public API never can either', async () => {
|
||||
const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p9-stray-${RUN_ID}@example.com`, display_name: 'P9 Stray', role: 'instructor', password: 'P9StrayPass123!' });
|
||||
const stray = makeSession(GW);
|
||||
await stray.request('POST', '/api/auth/instructor/login', { email: `p9-stray-${RUN_ID}@example.com`, password: 'P9StrayPass123!' });
|
||||
const denied = await stray.request('GET', `/api/admin/registrations/${registrationId}/payments`);
|
||||
assert.equal(denied.status, 403);
|
||||
});
|
||||
|
||||
// ── 26/27: audit hygiene, metric hygiene ─────────────────────────────────────────────────
|
||||
await test('commerce audit events never contain secrets/card data, and metrics stay low-cardinality', async () => {
|
||||
const auditRow = psql(`SELECT metadata::text FROM audit_events WHERE action = 'checkout_created' ORDER BY created_at DESC LIMIT 1`);
|
||||
assert.ok(!/sk_|whsec_|4242/.test(auditRow || ''), 'no Stripe secret or fake card number should ever appear in audit metadata');
|
||||
const metricsBody = await fetch(`${GW}/api/metrics`).catch(() => null);
|
||||
// Metrics may be unauthenticated-admin or absent in some deployments — only assert content
|
||||
// shape when reachable, never fail the whole suite on an unrelated auth requirement.
|
||||
if (metricsBody && metricsBody.ok) {
|
||||
const text = await metricsBody.text();
|
||||
assert.ok(!new RegExp(registrationId).test(text), 'a registration_id must never appear as a metric label');
|
||||
}
|
||||
});
|
||||
|
||||
await test('the website status-lookup endpoint is safe and minimal — no PII, no amount, no provider details', async () => {
|
||||
const status = await integrationGet(`/api/integrations/registrations/${registrationId}/status`);
|
||||
assert.equal(status.status, 200);
|
||||
assert.deepEqual(Object.keys(status.body).sort(), ['enrollment_activated', 'payment_status', 'registration_id', 'status']);
|
||||
const denied = await fetch(`${GW}/api/integrations/registrations/${registrationId}/status`);
|
||||
assert.equal(denied.status, 401, 'no key must be rejected, same as every other integration route');
|
||||
});
|
||||
|
||||
// ── 28/29/30: ROOT V1, Phase 8 registration, classroom/Lab flow all remain intact ───────
|
||||
await test('an existing ROOT_ V1 self-paced enrollment is completely unaffected by Phase 9', async () => {
|
||||
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P9-RootV1-${RUN_ID}`, login_identifier: `p9-rootv1-${RUN_ID}` });
|
||||
const s = makeSession(GW);
|
||||
await s.request('POST', '/api/student/activate', { token: create.activation_token, password: 'RootV1Pass123!' });
|
||||
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
|
||||
const r = await s.request('POST', `/api/student/activities/root-01-m00-a01-lesson-welcome/complete`);
|
||||
assert.equal(r.status, 200);
|
||||
});
|
||||
await test('a free (Phase 8-style) offering registration still works with no price/checkout involved', async () => {
|
||||
const freeOffering = `off-p9-free-${RUN_ID}`, freeSlug = `p9-free-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/offerings', { offeringId: freeOffering, slug: freeSlug, name: 'Free Offering', programId: PROGRAM, deliveryMode: 'self_paced' });
|
||||
await owner.request('POST', `/api/admin/offerings/${freeOffering}/transition`, { status: 'published' });
|
||||
await owner.request('POST', `/api/admin/offerings/${freeOffering}/transition`, { status: 'enrollment_open' });
|
||||
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-free-evt-${RUN_ID}`, offering_slug: freeSlug, registrant: { display_name: 'Free Student', email: `free-${RUN_ID}@example.com` } });
|
||||
assert.equal(reg.status, 201);
|
||||
const checkoutAttempt = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
|
||||
assert.equal(checkoutAttempt.status, 409);
|
||||
assert.equal(checkoutAttempt.body.error, 'NO_PRICE_CONFIGURED');
|
||||
});
|
||||
await test('an existing classroom (cohort) flow still works unaffected', async () => {
|
||||
const cohortId = `p9-cohort-check-${RUN_ID}`;
|
||||
const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P9 Cohort Check', tier: 'root' });
|
||||
assert.equal(create.status, 201);
|
||||
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P9 Check Session' });
|
||||
assert.equal(session.status, 201);
|
||||
});
|
||||
|
||||
await pool.end();
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (e) => { console.error(e); await pool.end().catch(() => {}); process.exit(1); });
|
||||
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 9.1 — the permanent, OPT-IN real-Stripe-TEST-mode integration check Phase 9 itself did
|
||||
// not build (Phase 9 tested exclusively against the deterministic TestProvider). This script
|
||||
// makes NO Stripe network call at all unless explicitly enabled, so normal regression runs and
|
||||
// CI stay fully deterministic and independent of Stripe/credential availability.
|
||||
//
|
||||
// Enable with: RUN_STRIPE_INTEGRATION_TEST=true
|
||||
// Requires (same env var names payment-provider.js already uses — never a duplicate config
|
||||
// var): STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET. Both must already be configured on the
|
||||
// RUNNING API process too (PAYMENT_PROVIDER=stripe) — this script is an external verifier, it
|
||||
// does not restart or reconfigure the server.
|
||||
//
|
||||
// Refuses to run against a live-mode secret key (sk_live_...) — TEST MODE ONLY, always. Creates
|
||||
// one disposable, clearly-named TEST offering and TEST registration; attempts to cancel the
|
||||
// offering when done (best-effort cleanup, never fatal if it fails).
|
||||
//
|
||||
// What this script CAN prove by itself, purely via API calls (no browser needed):
|
||||
// - RootNode really creates a real Stripe TEST Checkout Session (verified by retrieving the
|
||||
// session directly from Stripe's own API, not just trusting RootNode's response).
|
||||
// - The amount Stripe actually recorded matches the offering's server-side price exactly,
|
||||
// even when the caller's request body carries a different, tampered amount — proving
|
||||
// server-authoritative pricing against Stripe's OWN object, not just RootNode's database.
|
||||
// - RootNode's real webhook route rejects a garbage/invalid signature cleanly, with no domain
|
||||
// side effect — this does not require an actual completed payment to test honestly.
|
||||
//
|
||||
// What this script deliberately does NOT fake: actually completing Stripe's hosted Checkout
|
||||
// page (filling in a test card) is a real human/browser action — there is no supported pure-API
|
||||
// way to "pay" a Checkout Session, and fabricating a signed "payment succeeded" event for a
|
||||
// session nobody actually paid would be exactly the kind of faked validation Phase 9.1 was told
|
||||
// never to produce. When enabled, this script prints the real checkout_url and polls RootNode's
|
||||
// own status endpoint for STRIPE_INTEGRATION_TEST_WAIT_SECONDS (default 90) while a human
|
||||
// completes it in a browser (test card 4242 4242 4242 4242) with a parallel
|
||||
// `stripe listen --forward-to <gateway>/api/webhooks/payment` relaying Stripe's real event.
|
||||
//
|
||||
// Once that real payment lands, everything else IS fully automated via real Stripe API calls:
|
||||
// payment-transaction/enrollment/cohort/guardian reconciliation, a genuine duplicate-delivery
|
||||
// proof (re-signing the REAL event Stripe recorded, fetched fresh via stripe.events.retrieve —
|
||||
// never a fabricated one), real student activation + program-visibility proof, a real Stripe
|
||||
// TEST refund with the non-cascade rule reconfirmed, and — needing no browser at all — a real
|
||||
// abandoned/expired-checkout proof via Stripe's own `sessions.expire()` API. Configurable via
|
||||
// STRIPE_TEST_PRICE_CENTS/STRIPE_TEST_CURRENCY (defaults to a cheap $1 generic check) and
|
||||
// STRIPE_TEST_MINOR=true (guardian+minor path instead of adult). See
|
||||
// docs/STRIPE_INTEGRATION_RUNBOOK.md and docs/PHASE9_1_STRIPE_TEST_VALIDATION.md.
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const RUN = process.env.RUN_STRIPE_INTEGRATION_TEST === 'true';
|
||||
if (!RUN) {
|
||||
console.log('Stripe integration test skipped (RUN_STRIPE_INTEGRATION_TEST is not "true") — no Stripe network traffic, no credentials required.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
const WAIT_SECONDS = Number(process.env.STRIPE_INTEGRATION_TEST_WAIT_SECONDS) || 90;
|
||||
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one with platform/api/bootstrap-owner.js).');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!STRIPE_SECRET_KEY) {
|
||||
console.error('RUN_STRIPE_INTEGRATION_TEST=true requires STRIPE_SECRET_KEY (the same variable payment-provider.js reads) so this script can independently verify what Stripe actually recorded.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (STRIPE_SECRET_KEY.startsWith('sk_live_')) {
|
||||
console.error('REFUSING TO RUN: STRIPE_SECRET_KEY looks like a LIVE-mode key (sk_live_...). This script is TEST MODE ONLY. Use an sk_test_ key.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!STRIPE_SECRET_KEY.startsWith('sk_test_')) {
|
||||
console.error('REFUSING TO RUN: STRIPE_SECRET_KEY does not look like a Stripe test key (expected sk_test_...). Refusing out of caution.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const Stripe = require('../api/node_modules/stripe');
|
||||
const stripe = new Stripe(STRIPE_SECRET_KEY);
|
||||
const { makeSession } = require('./session-helper');
|
||||
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
// Phase 9.1 resume — overridable so a real business price (e.g. the $399/39900 Founding Cohort
|
||||
// tuition) can be validated directly against Stripe's own recorded amount, instead of always
|
||||
// testing an arbitrary $1. Defaults stay cheap/generic for a routine opt-in regression run.
|
||||
const TEST_PRICE_CENTS = Number(process.env.STRIPE_TEST_PRICE_CENTS) || 100;
|
||||
const TEST_CURRENCY = process.env.STRIPE_TEST_CURRENCY || 'usd';
|
||||
const TEST_MINOR = process.env.STRIPE_TEST_MINOR === 'true'; // guardian+minor path vs. adult
|
||||
const TAMPERED_AMOUNT_CENTS = 1; // what a malicious caller will try to substitute
|
||||
|
||||
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++; }
|
||||
}
|
||||
async function integrationPost(path, body) {
|
||||
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
}
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
function psql(sql) {
|
||||
const { execSync } = require('child_process');
|
||||
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 9.1 real-Stripe-TEST-mode integration check (opt-in, real Stripe API calls, TEST MODE ONLY)\n');
|
||||
|
||||
console.log(`Price under test: ${TEST_PRICE_CENTS} ${TEST_CURRENCY} (${(TEST_PRICE_CENTS / 100).toFixed(2)}) — ${TEST_MINOR ? 'guardian+minor' : 'adult'} registration path.\n`);
|
||||
|
||||
const owner = makeSession(GW);
|
||||
const offeringId = `stripe-it-${RUN_ID}`, slug = `stripe-integration-test-${RUN_ID}`;
|
||||
const agreementId = `stripe-it-agreement-${RUN_ID}`;
|
||||
let registrationId, sessionId, checkoutUrl, cohortId;
|
||||
|
||||
await test('owner logs in', async () => {
|
||||
const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
});
|
||||
|
||||
await test('a disposable TEST offering is created with the real TEST price and a cohort', async () => {
|
||||
cohortId = `stripe-it-cohort-${RUN_ID}`;
|
||||
const cohort = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: `[TEST] Stripe Integration Cohort ${RUN_ID}`, tier: 'root' });
|
||||
assert.equal(cohort.status, 201, JSON.stringify(cohort.body));
|
||||
const create = await owner.request('POST', '/api/admin/offerings', {
|
||||
offeringId, slug, name: `[TEST] Stripe Integration Check ${RUN_ID} — NOT the real Winter Break offering`,
|
||||
programId: PROGRAM, deliveryMode: 'cohort', cohortId,
|
||||
priceAmount: TEST_PRICE_CENTS, priceCurrency: TEST_CURRENCY,
|
||||
});
|
||||
assert.equal(create.status, 201, JSON.stringify(create.body));
|
||||
const agreement = await owner.request('POST', '/api/admin/agreements', { agreementId, title: '[TEST] Stripe Integration Waiver', bodySummary: 'Disposable test agreement — Phase 9.1.' });
|
||||
assert.equal(agreement.status, 201, JSON.stringify(agreement.body));
|
||||
const link = await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId });
|
||||
assert.equal(link.status, 201, JSON.stringify(link.body));
|
||||
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
|
||||
const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
|
||||
assert.equal(open.status, 200);
|
||||
});
|
||||
|
||||
await test(`a real ${TEST_MINOR ? 'guardian+minor' : 'adult'} registration is created through the public integration contract`, async () => {
|
||||
const body = TEST_MINOR
|
||||
? { event_id: `stripe-it-reg-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Stripe IT Test Camper', is_minor: true },
|
||||
guardian: { display_name: 'Stripe IT Test Guardian', email: `stripe-it-guardian-${RUN_ID}@example.com`, phone: '555-0199' } }
|
||||
: { event_id: `stripe-it-reg-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Stripe Integration Tester', email: `stripe-it-${RUN_ID}@example.com` } };
|
||||
const reg = await integrationPost('/api/integrations/registrations', body);
|
||||
assert.equal(reg.status, 201, JSON.stringify(reg.body));
|
||||
registrationId = reg.body.registration_id;
|
||||
});
|
||||
|
||||
await test('checkout is rejected before the required agreement is accepted', async () => {
|
||||
const attempt = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
|
||||
assert.equal(attempt.status, 409);
|
||||
assert.equal(attempt.body.error, 'AGREEMENTS_INCOMPLETE');
|
||||
});
|
||||
|
||||
await test('accepting the agreement is persisted with a version', async () => {
|
||||
const accept = await integrationPost(`/api/integrations/registrations/${registrationId}/agreements`, { agreement_id: agreementId });
|
||||
assert.equal(accept.status, 201, JSON.stringify(accept.body));
|
||||
assert.equal(accept.body.accepted_version, 1);
|
||||
});
|
||||
|
||||
await test('RootNode creates a REAL Stripe TEST Checkout Session, and a tampered caller-supplied amount is ignored', async () => {
|
||||
// amount/currency here are not real fields on this endpoint's contract (Step 5's own
|
||||
// "the browser never supplies a price" design) — sending them anyway proves they're inert.
|
||||
const checkout = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, {
|
||||
success_url: 'https://example.com/success', cancel_url: 'https://example.com/cancel',
|
||||
amount: TAMPERED_AMOUNT_CENTS, currency: 'eur',
|
||||
});
|
||||
assert.equal(checkout.status, 201, JSON.stringify(checkout.body));
|
||||
checkoutUrl = checkout.body.checkout_url;
|
||||
assert.ok(checkoutUrl.startsWith('https://checkout.stripe.com/'), `expected a real Stripe-hosted checkout URL, got: ${checkoutUrl}`);
|
||||
|
||||
// Independently verify against STRIPE'S OWN API — never trust RootNode's own echo of what
|
||||
// it thinks it sent.
|
||||
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
|
||||
sessionId = tx.body[0].provider_reference;
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
assert.equal(session.mode, 'payment');
|
||||
assert.equal(session.amount_total, TEST_PRICE_CENTS, 'Stripe must have recorded the OFFERING price, never the tampered caller amount');
|
||||
assert.equal(session.currency, TEST_CURRENCY, 'Stripe must have recorded the OFFERING currency, never the tampered caller currency');
|
||||
assert.equal(session.livemode, false, 'this must be a TEST-mode session — refusing to proceed otherwise');
|
||||
console.log(` Stripe session ${sessionId}: amount_total=${session.amount_total} ${session.currency}, livemode=${session.livemode}`);
|
||||
});
|
||||
|
||||
await test('an invalid webhook signature is rejected with no domain side effect', async () => {
|
||||
const before = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
|
||||
const r = await fetch(`${GW}/api/webhooks/payment`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Stripe-Signature': 'invalid-signature-not-real' },
|
||||
body: JSON.stringify({ id: 'evt_fake', type: 'checkout.session.completed', data: { object: { id: sessionId } } }),
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
const after = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
|
||||
assert.equal(after.body.status, before.body.status, 'an invalid signature must never move the registration forward');
|
||||
});
|
||||
|
||||
console.log(`\n${'─'.repeat(78)}`);
|
||||
console.log('Everything above is fully automated and required no human/browser step.');
|
||||
console.log('What remains needs a real completed Stripe Checkout — this is a genuine human or');
|
||||
console.log('Stripe-CLI action, never simulated by this script (see the file header comment and');
|
||||
console.log('docs/PHASE9_1_STRIPE_TEST_VALIDATION.md):\n');
|
||||
console.log(` 1. Open this REAL Stripe TEST checkout page in a browser:\n ${checkoutUrl}`);
|
||||
console.log(' 2. Pay with Stripe\'s official test card: 4242 4242 4242 4242, any future expiry, any CVC/ZIP.');
|
||||
console.log(` 3. Make sure Stripe can reach your webhook — e.g. \`stripe listen --forward-to ${GW}/api/webhooks/payment\`.`);
|
||||
console.log(`\nPolling RootNode's own status endpoint for up to ${WAIT_SECONDS}s for a real webhook-confirmed result...\n`);
|
||||
|
||||
const deadline = Date.now() + WAIT_SECONDS * 1000;
|
||||
let finalStatus = null;
|
||||
while (Date.now() < deadline) {
|
||||
const r = await fetch(`${GW}/api/integrations/registrations/${registrationId}/status`, { headers: { 'X-RootNode-Integration-Key': INTEGRATION_KEY } });
|
||||
const status = await r.json();
|
||||
if (status.payment_status === 'paid' || status.status === 'enrollment_activated') { finalStatus = status; break; }
|
||||
await sleep(3000);
|
||||
}
|
||||
|
||||
let realEventId = null, realPaymentIntentId = null, enrollmentId = null, studentId = null;
|
||||
|
||||
if (finalStatus) {
|
||||
console.log(` ok — a REAL Stripe TEST payment was completed and confirmed by a REAL signed webhook: ${JSON.stringify(finalStatus)}`);
|
||||
passed++;
|
||||
|
||||
await test('exactly one payment_transactions record, paid, reconciled against Stripe — no card data anywhere', async () => {
|
||||
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
|
||||
assert.equal(tx.body.length, 1, `expected exactly one transaction, got ${tx.body.length}`);
|
||||
const t = tx.body[0];
|
||||
assert.equal(t.status, 'paid');
|
||||
assert.equal(t.provider_reference, sessionId);
|
||||
for (const forbidden of ['card_number', 'cvv', 'card_last4', 'payment_method_details']) assert.ok(!(forbidden in t));
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
realPaymentIntentId = session.payment_intent;
|
||||
assert.ok(realPaymentIntentId, 'a paid session must have a real PaymentIntent');
|
||||
realEventId = psql(`SELECT webhook_event_id FROM payment_webhook_events WHERE provider = 'stripe' AND transaction_id = (SELECT transaction_id FROM payment_transactions WHERE registration_id = '${registrationId}') ORDER BY received_at DESC LIMIT 1`);
|
||||
assert.ok(realEventId, 'the real Stripe event id must be recorded');
|
||||
});
|
||||
|
||||
await test('registration confirmed, enrollment converted exactly once, cohort membership created, guardian preserved', async () => {
|
||||
const reg = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
|
||||
assert.equal(reg.body.status, 'enrollment_activated');
|
||||
enrollmentId = reg.body.enrollment_id;
|
||||
studentId = reg.body.student_id;
|
||||
const enrollmentCount = psql(`SELECT count(*) FROM enrollments WHERE student_id = '${studentId}' AND program_id = '${PROGRAM}'`);
|
||||
assert.equal(enrollmentCount.trim(), '1', 'exactly one enrollment must exist for this student/program');
|
||||
const cohortCount = psql(`SELECT count(*) FROM cohort_enrollments WHERE cohort_id = '${cohortId}' AND student_id = '${studentId}'`);
|
||||
assert.equal(cohortCount.trim(), '1', 'exactly one cohort membership must exist');
|
||||
if (TEST_MINOR) {
|
||||
const guardianLink = psql(`SELECT count(*) FROM student_guardians WHERE student_id = '${studentId}'`);
|
||||
assert.equal(guardianLink.trim(), '1', 'the minor student must have exactly one real guardian relationship');
|
||||
const studentEmail = psql(`SELECT email FROM students WHERE student_id = '${studentId}'`);
|
||||
assert.equal(studentEmail.trim(), '', 'the guardian email must never be written to the minor student\'s own identity record');
|
||||
}
|
||||
});
|
||||
|
||||
await test('a REAL, Stripe-issued duplicate delivery of the same event is idempotent — no double conversion', async () => {
|
||||
// Fetches the REAL event Stripe recorded (not fabricated) and re-signs it with the REAL
|
||||
// webhook secret to prove a genuine Stripe retry-delivery scenario is handled correctly —
|
||||
// never inventing a "payment succeeded" event for a session nobody paid (that would be
|
||||
// exactly the faked validation this phase was told never to produce). This mirrors what
|
||||
// `stripe events resend` does server-side; done locally here because a `stripe listen`
|
||||
// ephemeral forwarding session has no persistent dashboard endpoint id to target.
|
||||
const realEvent = await stripe.events.retrieve(realEventId);
|
||||
const payload = JSON.stringify(realEvent);
|
||||
const header = stripe.webhooks.generateTestHeaderString({ payload, secret: process.env.STRIPE_WEBHOOK_SECRET });
|
||||
const replay = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Stripe-Signature': header }, body: payload });
|
||||
const replayBody = await replay.json();
|
||||
assert.equal(replay.status, 200);
|
||||
assert.equal(replayBody.result, 'duplicate');
|
||||
const enrollmentCount = psql(`SELECT count(*) FROM enrollments WHERE student_id = '${studentId}' AND program_id = '${PROGRAM}'`);
|
||||
assert.equal(enrollmentCount.trim(), '1', 'a duplicate delivery must never create a second enrollment');
|
||||
const notifCount = psql(`SELECT count(*) FROM notification_events WHERE enrollment_id = '${enrollmentId}' AND notification_type IN ('activation','enrollment_confirmation')`);
|
||||
assert.equal(notifCount.trim(), '1', 'a duplicate delivery must never queue a second activation/confirmation email');
|
||||
});
|
||||
|
||||
await test('student access: the real activation token works and the enrolled program is visible', async () => {
|
||||
require('child_process').execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "UPDATE notification_events SET next_attempt_at = now() WHERE status = 'queued'"`, { cwd: `${__dirname}/..`, stdio: 'pipe' });
|
||||
await sleep(3000);
|
||||
const notifs = await owner.request('GET', `/api/admin/registrations/${registrationId}/notifications`);
|
||||
const activationNotif = notifs.body.find((n) => n.notification_type === 'activation' || n.notification_type === 'enrollment_confirmation');
|
||||
assert.ok(activationNotif, `expected a real activation/confirmation notification, got: ${JSON.stringify(notifs.body)}`);
|
||||
if (activationNotif.notification_type === 'activation') {
|
||||
const preview = await owner.request('GET', `/api/admin/notifications/${activationNotif.notification_id}/dev-preview`);
|
||||
const token = preview.body.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
|
||||
const s = makeSession(GW);
|
||||
const activate = await s.request('POST', '/api/student/activate', { token, password: 'StripeIT!2026Test' });
|
||||
assert.equal(activate.status, 200, JSON.stringify(activate.body));
|
||||
const programs = await s.request('GET', '/api/student/programs');
|
||||
assert.ok(programs.body.some((p) => p.program_id === PROGRAM), 'the enrolled program must be visible to the newly-activated student');
|
||||
}
|
||||
});
|
||||
|
||||
await test('a Stripe TEST refund is recorded without cascading into registration/enrollment status', async () => {
|
||||
const refund = await stripe.refunds.create({ payment_intent: realPaymentIntentId });
|
||||
assert.equal(refund.status, 'succeeded');
|
||||
// Give the (not-yet-forwarded) refund webhook a moment if stripe listen also relays
|
||||
// charge.refunded; either way, prove RootNode's OWN non-cascade rule holds regardless —
|
||||
// record it via the admin route directly if the webhook hasn't landed yet.
|
||||
await sleep(2000);
|
||||
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
|
||||
const t = tx.body[0];
|
||||
if (t.status !== 'refunded') {
|
||||
const transactionId = psql(`SELECT transaction_id FROM payment_transactions WHERE registration_id = '${registrationId}'`).trim();
|
||||
await owner.request('POST', `/api/admin/payments/${transactionId}/refund`, {});
|
||||
}
|
||||
const regAfter = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
|
||||
assert.equal(regAfter.body.status, 'enrollment_activated', 'a refund must never auto-cancel the registration');
|
||||
const enrollmentAfter = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${enrollmentId}'`);
|
||||
assert.equal(enrollmentAfter.trim(), 'active', 'a refund must never auto-revoke the enrollment — this is a business-policy question, not something to decide in code');
|
||||
console.log(` Stripe refund ${refund.id}: status=${refund.status}, amount=${refund.amount} ${refund.currency}`);
|
||||
});
|
||||
|
||||
console.log(`\n${'─'.repeat(78)}`);
|
||||
console.log('Stripe <-> RootNode reconciliation for this run:');
|
||||
console.log(` RootNode registration: ${registrationId}`);
|
||||
console.log(` RootNode enrollment: ${enrollmentId}`);
|
||||
console.log(` RootNode student: ${studentId}`);
|
||||
console.log(` Stripe Checkout Session: ${sessionId}`);
|
||||
console.log(` Stripe PaymentIntent: ${realPaymentIntentId}`);
|
||||
console.log(` Stripe event (webhook): ${realEventId}`);
|
||||
console.log(` Amount / currency: ${TEST_PRICE_CENTS} ${TEST_CURRENCY}`);
|
||||
} else {
|
||||
console.log(' (not completed within the wait window — go complete the checkout in a browser and rerun, or increase STRIPE_INTEGRATION_TEST_WAIT_SECONDS)');
|
||||
}
|
||||
|
||||
await test('abandoned/expired checkout (pure API, no browser needed): the seat is released exactly once', async () => {
|
||||
const abReg = await integrationPost('/api/integrations/registrations', {
|
||||
event_id: `stripe-it-abandon-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Stripe IT Abandon Tester', email: `stripe-it-abandon-${RUN_ID}@example.com` },
|
||||
});
|
||||
assert.equal(abReg.status, 201, JSON.stringify(abReg.body));
|
||||
const abAccept = await integrationPost(`/api/integrations/registrations/${abReg.body.registration_id}/agreements`, { agreement_id: agreementId });
|
||||
assert.equal(abAccept.status, 201);
|
||||
const abCheckout = await integrationPost(`/api/integrations/registrations/${abReg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
|
||||
assert.equal(abCheckout.status, 201, JSON.stringify(abCheckout.body));
|
||||
const abTx = await owner.request('GET', `/api/admin/registrations/${abReg.body.registration_id}/payments`);
|
||||
const abSessionId = abTx.body[0].provider_reference;
|
||||
// A REAL Stripe API call — no browser needed — asking Stripe to expire an open session,
|
||||
// which makes Stripe itself fire a genuine checkout.session.expired webhook.
|
||||
await stripe.checkout.sessions.expire(abSessionId);
|
||||
const deadline2 = Date.now() + 30000;
|
||||
let released = false;
|
||||
while (Date.now() < deadline2) {
|
||||
const r = await fetch(`${GW}/api/integrations/registrations/${abReg.body.registration_id}/status`, { headers: { 'X-RootNode-Integration-Key': INTEGRATION_KEY } });
|
||||
const s = await r.json();
|
||||
if (s.status === 'cancelled') { released = true; break; }
|
||||
await sleep(2000);
|
||||
}
|
||||
assert.ok(released, 'the abandoned/expired checkout must release the seat within 30s of the real Stripe webhook');
|
||||
});
|
||||
|
||||
// Best-effort cleanup — never fatal.
|
||||
try { await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'cancelled' }); } catch { /* ponytail: best-effort cleanup only */ }
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed${finalStatus ? '' : ' (real-payment step not completed this run)'}`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user