- webhook-routes.js: POST /api/webhooks/payment — signature-verified, idempotent, always 200
once verified (a forged/invalid signature is a clean quiet 400)
- integration-routes.js: POST /api/integrations/registrations/:id/agreements (closes Phase 8's
gap — agreement acceptance now on the public registration contract), POST .../:id/checkout
(server-to-server, amount always server-side), GET .../:id/status (minimal safe status lookup
for the website's success page — no PII, no amount, no provider reference)
- public-offerings-routes.js: price_amount/price_currency/requires_payment in the public serializer
- metrics.js: checkout/payment/webhook/conversion/seat-released counters, all low-cardinality
- notifications.js: listForRegistration now unions by the registration's own enrollment_id too,
so the post-conversion activation notification shows up in the registration's communication
history (previously only pre-conversion notifications were visible)
- Registration is now server-authoritative: draft/cancelled/completed/full/window-closed
offerings reject registration attempts with distinct error codes (assertRegistrationOpen).
- offering_agreement_requirements + conversion-time blocking on missing required
agreements; accepted_by_type gained 'registrant' for an adult self-accepting before
their student_id exists.
- computeOfferingReadiness() — a server-derived launch checklist (curriculum, instructor,
schedule, location, capacity, registration window, agreements, curriculum package).
- Guardian communications wired into the existing Phase 5C notification system: a guardian
email fallback in resolveRecipient(), a parallel enqueueForRegistration()/
resolveRegistrationRecipient() pair for pre-account registration-stage notifications
(registration_received/confirmed/needs_action, agreement_required, camp_operational_update),
five new RootNode-branded templates. Same table, same worker, same masked admin view —
no second notification system.
- public-offerings-routes.js: the unauthenticated, explicitly-allowlisted offering read API.
- POST /api/integrations/registrations: the website's idempotent registration submission
contract, same shared-secret boundary as the existing enrollment integration endpoint.
Two real bugs found and fixed during this phase's own testing before any commit:
(1) the five new notification_type values were wired into the worker before the DB's own
CHECK constraint was widened to accept them — every registration-stage notification was
silently failing until a comprehensive smoke test caught it (036 fixes the constraint;
every previously-silent notification catch now at least logs).
(2) firing a notification right after a registration's insert, before its seat reservation
could still fail and roll the row back, created a real foreign-key violation on rollback —
fixed by reordering createRegistration() to validate -> reserve -> insert -> notify.
See docs/OFFERING_AND_REGISTRATION_MODEL.md and docs/PHASE8_ENROLLMENT_OPERATIONS.md.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
- migrations/021: notification_events (queued/sending/sent/failed/cancelled, bounded
exponential backoff via attempt_count/next_attempt_at, a classified last_error_code, never a
raw stack trace or provider credential).
- notifications.js: enqueue (records a no-recipient case as already-failed rather than
silently dropping it), claimNext (FOR UPDATE SKIP LOCKED — safe if two worker processes ever
run), markSent/markFailedOrRetry (bounded retry, terminal-state token scrubbing),
notifyForEnrollment (the shared activation-vs-confirmation decision every enrollment-creation
path calls), maskEmail/toAdminView (the only shapes any admin API ever returns).
- enrollment.js: enrollmentContactEmail() — reads back the minor/no-email transitional
contact-email convention (stored in the existing enrollments.metadata JSONB, never written to
students.email) that createFromIntegrationEvent/createManualEnrollment now accept.