feat(programs): first-class program model — init_/compile_/root_ ladder, seeded root-kubernetes-foundations
- 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.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
-- 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');
|
||||
Reference in New Issue
Block a user