- migrations/019: programs table (slug PK, matching assessment_definitions/cohorts convention), family (init/compile/root) and delivery_mode (self_paced/cohort/hybrid) as real CHECK-constrained enums, versioned like assessment_definitions so an edit to curriculum_path/delivery_mode never silently changes what an existing enrollment means. Seeds exactly one program wrapping the existing RootNode/Kubernetes curriculum and capstone — no new curriculum content invented. - programs.js: list/get/create/update, with automatic version bump only when a snapshot-relevant field actually changes.
52 lines
3.9 KiB
SQL
52 lines
3.9 KiB
SQL
-- Phase 5B §3 — enrollments: a student's academic access to a program. Deliberately separate
|
|
-- from cohort_enrollments (002_cohorts.sql) — a self-paced enrollment has no cohort at all, and
|
|
-- a student can hold both kinds independently (see docs/PHASE5B_SELF_PACED_ENROLLMENT.md §4).
|
|
--
|
|
-- delivery_mode/program_version/curriculum_path are snapshotted from the program AT enrollment
|
|
-- time — same versioning principle as assessment_sessions (013_assessments.sql) snapshotting a
|
|
-- definition's fields at draw time, so editing a program later never silently changes what an
|
|
-- already-enrolled student is being held to.
|
|
--
|
|
-- status is server-transitioned only (enrollment.js) — pending -> active -> completed/expired,
|
|
-- or pending/active -> cancelled. 'expired' is set lazily/derived at read time from
|
|
-- access_ends_at (matching classroom.js's derivePresence pattern — no stored value to go
|
|
-- stale), so it is intentionally NOT a value the application ever writes directly except when
|
|
-- reconciling; see enrollment.js's deriveEnrollmentAccess.
|
|
CREATE TABLE IF NOT EXISTS enrollments (
|
|
enrollment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
student_id TEXT NOT NULL REFERENCES students(student_id),
|
|
program_id TEXT NOT NULL REFERENCES programs(program_id),
|
|
delivery_mode TEXT NOT NULL, -- snapshot of programs.delivery_mode at enrollment time
|
|
program_version INTEGER NOT NULL, -- snapshot of programs.version at enrollment time
|
|
curriculum_path TEXT NOT NULL, -- snapshot of programs.curriculum_path at enrollment time
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'completed', 'cancelled', 'expired')),
|
|
source TEXT NOT NULL DEFAULT 'admin', -- free text provenance ('admin', or whatever the integration event's own "source" said, e.g. 'website') — NEVER used as a metrics label, see metrics.js
|
|
external_event_id TEXT, -- idempotency key for integration-created enrollments; null for admin-created ones
|
|
external_reference TEXT, -- optional business reference (order/payment id) — free text, not unique, audit-only
|
|
starts_at TIMESTAMPTZ, -- null = immediate access once active
|
|
access_ends_at TIMESTAMPTZ, -- null = no expiration
|
|
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
activated_at TIMESTAMPTZ,
|
|
completed_at TIMESTAMPTZ,
|
|
cancelled_at TIMESTAMPTZ,
|
|
metadata JSONB,
|
|
created_by UUID REFERENCES instructors(instructor_id), -- null for integration-created enrollments
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_enrollments_student ON enrollments(student_id);
|
|
CREATE INDEX IF NOT EXISTS idx_enrollments_program ON enrollments(program_id);
|
|
CREATE INDEX IF NOT EXISTS idx_enrollments_status ON enrollments(status);
|
|
CREATE INDEX IF NOT EXISTS idx_enrollments_created_by ON enrollments(created_by);
|
|
|
|
-- Idempotency: the same external_event_id can never create two rows. A unique index (not just
|
|
-- app-level "SELECT first") is what actually makes concurrent duplicate submissions safe — see
|
|
-- enrollment.js's createFromIntegrationEvent, which relies on this via ON CONFLICT.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_enrollments_external_event ON enrollments(external_event_id) WHERE external_event_id IS NOT NULL;
|
|
|
|
-- At most one non-terminal (pending/active) enrollment per student+program at a time. A
|
|
-- completed/cancelled/expired enrollment doesn't block a new one — re-enrollment creates a new
|
|
-- row rather than reviving a historical one (§3: "do not corrupt historical records").
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_enrollments_one_open_per_program ON enrollments(student_id, program_id) WHERE status IN ('pending', 'active');
|