diff --git a/platform/api/migrations/019_programs.sql b/platform/api/migrations/019_programs.sql new file mode 100644 index 0000000..394e800 --- /dev/null +++ b/platform/api/migrations/019_programs.sql @@ -0,0 +1,45 @@ +-- Phase 5B §2 — programs: first-class learning products, distinct from curriculum JSON itself +-- (a program is the commercial/academic unit a student enrolls in; curriculum_path is still +-- just the content it points at, unchanged since Phase 1). program_id IS the slug — same +-- convention as assessment_definitions/cohorts (a single TEXT primary key, no separate numeric +-- id + slug pair). +-- +-- family is the init_/compile_/root_ youth-engineering ladder (see +-- docs/PHASE5B_SELF_PACED_ENROLLMENT.md §1) — only 'root' has real curriculum content today; +-- init/compile rows can be added later without a schema change, they just won't have a working +-- curriculum_path yet. delivery_mode describes how the program is normally taken; an +-- individual enrollment can still snapshot a different mode (see 020_enrollments.sql). +-- +-- version is a plain incrementing integer, matching assessment_definitions' existing +-- versioning convention — enrollments snapshot the version+curriculum_path they were created +-- against so a later edit to a program never silently changes what an already-enrolled +-- student is being held to. +CREATE TABLE IF NOT EXISTS programs ( + program_id TEXT PRIMARY KEY, -- slug, e.g. 'root-kubernetes-foundations' + name TEXT NOT NULL, + description TEXT, + family TEXT NOT NULL CHECK (family IN ('init', 'compile', 'root')), + delivery_mode TEXT NOT NULL DEFAULT 'self_paced' CHECK (delivery_mode IN ('self_paced', 'cohort', 'hybrid')), + curriculum_path TEXT NOT NULL DEFAULT 'rootnode-path', + completion_assessment_id TEXT REFERENCES assessment_definitions(assessment_definition_id), + estimated_duration_hours INTEGER, + version INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_by UUID REFERENCES instructors(instructor_id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_programs_status ON programs(status); +CREATE INDEX IF NOT EXISTS idx_programs_family ON programs(family); + +-- Seed: the one legitimate self-paced program this phase ships, wrapping the EXISTING +-- RootNode/Kubernetes curriculum and capstone — no new curriculum content invented. init_/ +-- compile_ programs are intentionally NOT seeded (no real curriculum for them yet, per this +-- phase's explicit scope line). +INSERT INTO programs (program_id, name, description, family, delivery_mode, curriculum_path, completion_assessment_id, estimated_duration_hours) +VALUES ( + 'root-kubernetes-foundations', 'ROOT — Kubernetes Foundations', + 'Self-paced Linux, containers, and Kubernetes operations — the existing RootNode curriculum, from Foundations through the RootNode Capstone.', + 'root', 'self_paced', 'rootnode-path', 'rootnode-capstone', 40 +) ON CONFLICT (program_id) DO NOTHING; diff --git a/platform/api/migrations/020_enrollments.sql b/platform/api/migrations/020_enrollments.sql new file mode 100644 index 0000000..74d2a28 --- /dev/null +++ b/platform/api/migrations/020_enrollments.sql @@ -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'); diff --git a/platform/api/src/programs.js b/platform/api/src/programs.js new file mode 100644 index 0000000..8072362 --- /dev/null +++ b/platform/api/src/programs.js @@ -0,0 +1,51 @@ +// Phase 5B §2 — programs: first-class learning products (distinct from curriculum JSON, which +// stays just the content a program points at). CRUD here mirrors assessments.js's definition +// pattern (a slug primary key, a plain incrementing version, active/inactive status) rather +// than inventing a new convention. + +async function listPrograms(pool, { activeOnly } = {}) { + const { rows } = await pool.query( + `SELECT p.*, count(e.enrollment_id)::int AS enrollment_count + FROM programs p LEFT JOIN enrollments e ON e.program_id = p.program_id + ${activeOnly ? "WHERE p.status = 'active'" : ''} + GROUP BY p.program_id ORDER BY p.family, p.name` + ); + return rows; +} + +async function getProgram(pool, programId) { + const { rows } = await pool.query(`SELECT * FROM programs WHERE program_id = $1`, [programId]); + return rows[0] || null; +} + +async function createProgram(pool, { programId, name, description, family, deliveryMode, curriculumPath, completionAssessmentId, estimatedDurationHours, createdBy }) { + const { rows } = await pool.query( + `INSERT INTO programs (program_id, name, description, family, delivery_mode, curriculum_path, completion_assessment_id, estimated_duration_hours, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, + [programId, name, description || null, family, deliveryMode || 'self_paced', curriculumPath || 'rootnode-path', completionAssessmentId || null, estimatedDurationHours || null, createdBy || null] + ); + return rows[0]; +} + +// Bumps version whenever a field an enrollment snapshots (curriculum_path, delivery_mode) +// actually changes — matches assessment_definitions' "edit later never changes what an +// already-drawn session is graded against" principle. Editing only cosmetic fields (name, +// description, estimated_duration_hours) does not bump the version. +async function updateProgram(pool, programId, fields) { + const current = await getProgram(pool, programId); + if (!current) return null; + const snapshotChanged = (fields.curriculum_path && fields.curriculum_path !== current.curriculum_path) + || (fields.delivery_mode && fields.delivery_mode !== current.delivery_mode); + const map = { ...fields }; + if (snapshotChanged) map.version = current.version + 1; + const cols = Object.keys(map); + if (!cols.length) return current; + const setClause = cols.map((c, i) => `${c} = $${i + 2}`).join(', '); + const { rows } = await pool.query( + `UPDATE programs SET ${setClause}, updated_at = now() WHERE program_id = $1 RETURNING *`, + [programId, ...cols.map((c) => map[c])] + ); + return rows[0]; +} + +module.exports = { listPrograms, getProgram, createProgram, updateProgram };