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]>
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user