- 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
2.6 KiB
JavaScript
52 lines
2.6 KiB
JavaScript
// 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 };
|