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

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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 22:43:52 -05:00

149 lines
11 KiB
SQL

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