Files
rootnode-academy/platform/api/migrations/035_phase8_enrollment_operations.sql
T
ced4568andClaude Sonnet 5 24eee80354 feat(offering): launch readiness, agreement requirements, guardian communications, website registration API
- 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]>
2026-08-26 23:59:11 -05:00

54 lines
5.7 KiB
SQL

-- 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;