// Phase 5B §11 — instructor/admin program and enrollment management. Program authoring // (create/edit) is admin/owner only, matching assessment_definitions' existing convention; // every role can list/inspect (matches AssessmentsView's "viewed by all roles, only authoring // restricted" pattern). Enrollment creation is admin/owner only (matches roster-creation // precedent — POST /api/admin/students is the same restriction); a plain instructor can still // view/activate/cancel an enrollment they created or that belongs to a student sharing one of // their cohorts, via rbac.js's requireEnrollmentAccess — see that function's own comment for // the exact documented rule. const programsLib = require('./programs'); const enrollmentLib = require('./enrollment'); const notificationsLib = require('./notifications'); const studentsAuth = require('./students-auth'); const { buildProgramStatus } = require('./program-status'); function enrollmentErrorStatus(code) { if (code === 'NOT_FOUND') return 404; if (code === 'PROGRAM_NOT_FOUND') return 404; if (code === 'ALREADY_ENROLLED' || code === 'INVALID_STATE' || code === 'PROGRAM_INACTIVE') return 409; return 500; } module.exports = function registerAdminProgramsRoutes(app, deps) { const { instructorSession, rbac, curriculum, getPool, loadProgress, recordAudit } = deps; const { requireAnyInstructor, requireAdminOrOwner, requireEnrollmentAccess } = rbac; const statusDeps = { pool: getPool(), curriculum, loadProgress }; // Local wrap, same convention as admin-routes.js/admin-classroom-routes.js (NOT the stricter // server.js-level `wrap`, which assumes fn always returns a promise — RBAC middleware like // requireAnyInstructor is a plain sync `(req,res,next)` function, so it needs the // Promise.resolve() guard here to be safely mixed into the same mw.map(wrap) list as the // async route handlers). function wrap(fn) { return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; } const admin = { get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)), post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)), patch: (p, ...mw) => app.patch(p, instructorSession, ...mw.map(wrap)), }; // ── Programs ───────────────────────────────────────────────────────────────────────────── admin.get('/api/admin/programs', requireAnyInstructor, async (req, res) => { res.json(await programsLib.listPrograms(getPool())); }); admin.get('/api/admin/programs/:id', requireAnyInstructor, async (req, res) => { const program = await programsLib.getProgram(getPool(), req.params.id); if (!program) return res.status(404).json({ error: 'not found' }); const { rows: statusCounts } = await getPool().query( `SELECT status, count(*)::int AS n FROM enrollments WHERE program_id = $1 GROUP BY status`, [req.params.id] ); res.json({ ...program, enrollment_counts: Object.fromEntries(statusCounts.map((r) => [r.status, r.n])) }); }); admin.post('/api/admin/programs', requireAdminOrOwner, async (req, res) => { const { program_id, name, description, family, delivery_mode, curriculum_path, completion_assessment_id, estimated_duration_hours } = req.body || {}; if (!program_id || !name || !family) return res.status(400).json({ error: 'program_id, name, and family are required' }); if (!['init', 'compile', 'root'].includes(family)) return res.status(400).json({ error: 'family must be init, compile, or root' }); try { const program = await programsLib.createProgram(getPool(), { programId: program_id, name, description, family, deliveryMode: delivery_mode, curriculumPath: curriculum_path, completionAssessmentId: completion_assessment_id, estimatedDurationHours: estimated_duration_hours, createdBy: req.instructor.instructor_id, }); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'program_created', resourceType: 'program', resourceId: program.program_id, result: 'success', sourceIp: req.ip }); res.status(201).json(program); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'program_id already exists' }); throw e; } }); admin.patch('/api/admin/programs/:id', requireAdminOrOwner, async (req, res) => { const { name, description, family, delivery_mode, curriculum_path, completion_assessment_id, estimated_duration_hours, status } = req.body || {}; const map = { name, description, family, delivery_mode, curriculum_path, completion_assessment_id, estimated_duration_hours, status }; const fields = {}; for (const [k, v] of Object.entries(map)) if (v !== undefined) fields[k] = v; if (!Object.keys(fields).length) return res.status(400).json({ error: 'nothing to update' }); const program = await programsLib.updateProgram(getPool(), req.params.id, fields); if (!program) return res.status(404).json({ error: 'not found' }); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'program_updated', resourceType: 'program', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: fields }); res.json(program); }); // ── Enrollments ────────────────────────────────────────────────────────────────────────── // Scoped list, same shape as GET /api/admin/students: owner/admin see every enrollment for // the program; a plain instructor sees only the ones they created or that belong to a // student sharing one of their cohorts (the same rule requireEnrollmentAccess enforces for // single-resource routes, applied here as a query filter instead of a per-row check). admin.get('/api/admin/programs/:id/enrollments', requireAnyInstructor, async (req, res) => { const pool = getPool(); const { rows } = req.instructor.role === 'instructor' ? await pool.query( `SELECT DISTINCT e.*, s.display_name AS student_display_name FROM enrollments e JOIN students s ON s.student_id = e.student_id LEFT JOIN cohort_enrollments ce ON ce.student_id = e.student_id AND ce.enrollment_status = 'enrolled' LEFT JOIN cohort_instructors ci ON ci.cohort_id = ce.cohort_id AND ci.instructor_id = $2 WHERE e.program_id = $1 AND (e.created_by = $2 OR ci.instructor_id IS NOT NULL) ORDER BY e.enrolled_at DESC`, [req.params.id, req.instructor.instructor_id]) : await pool.query( `SELECT e.*, s.display_name AS student_display_name FROM enrollments e JOIN students s ON s.student_id = e.student_id WHERE e.program_id = $1 ORDER BY e.enrolled_at DESC`, [req.params.id]); res.json(rows); }); admin.post('/api/admin/enrollments', requireAdminOrOwner, async (req, res) => { const { student_id, program_id, status, starts_at, access_ends_at, delivery_mode, external_reference, contact_email, metadata } = req.body || {}; if (!student_id || !program_id) return res.status(400).json({ error: 'student_id and program_id are required' }); // Phase 5C §10 — same transitional contact-email convention as the integration endpoint; // folded into the same metadata JSONB an admin could already pass, not a new column. const mergedMetadata = contact_email ? { ...(metadata || {}), contact_email: String(contact_email).trim() } : metadata; let created; try { created = await enrollmentLib.createManualEnrollment(getPool(), { studentId: student_id, programId: program_id, status, startsAt: starts_at, accessEndsAt: access_ends_at, deliveryMode: delivery_mode, externalReference: external_reference, metadata: mergedMetadata, createdBy: req.instructor.instructor_id, }); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_created', resourceType: 'enrollment', resourceId: created.enrollment_id, result: 'success', sourceIp: req.ip, metadata: { student_id, program_id } }); } catch (e) { if (e.code) return res.status(enrollmentErrorStatus(e.code)).json({ error: e.code, message: e.message }); throw e; } // §11 — an admin-created enrollment notifies exactly like a website-triggered one: activation // for a not-yet-credentialed student, enrollment confirmation for an already-active one. const program = await programsLib.getProgram(getPool(), program_id); const notification = await notificationsLib.notifyForEnrollment(getPool(), studentsAuth, { studentId: student_id, enrollmentId: created.enrollment_id, programName: program?.name, }); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: `notification_queued_${notification?.notification_type || 'unknown'}`, resourceType: 'notification', resourceId: notification?.notification_id, result: 'success', sourceIp: req.ip }); res.status(201).json(created); }); admin.get('/api/admin/enrollments/:id', requireAnyInstructor, requireEnrollmentAccess('id'), async (req, res) => { const row = await enrollmentLib.getEnrollment(getPool(), req.params.id); res.json(await buildProgramStatus(statusDeps, row)); }); admin.post('/api/admin/enrollments/:id/activate', requireAnyInstructor, requireEnrollmentAccess('id'), async (req, res) => { try { const updated = await enrollmentLib.activateEnrollment(getPool(), req.params.id); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_activated', resourceType: 'enrollment', resourceId: req.params.id, result: 'success', sourceIp: req.ip }); res.json(updated); } catch (e) { res.status(enrollmentErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message }); } }); admin.post('/api/admin/enrollments/:id/cancel', requireAnyInstructor, requireEnrollmentAccess('id'), async (req, res) => { try { const updated = await enrollmentLib.cancelEnrollment(getPool(), req.params.id); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_cancelled', resourceType: 'enrollment', resourceId: req.params.id, result: 'success', sourceIp: req.ip }); res.json(updated); } catch (e) { res.status(enrollmentErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message }); } }); // §11 — extends the existing student detail view: enrolled programs, status, progress, // current stage, last activity, completion, delivery mode. Scoped the same way the // enrollments-list route above is (owner/admin see everything; a plain instructor sees only // enrollments they created or that share a cohort with them) rather than requiring // requireStudentCohortAccess, which would 403 for a self-paced-only student with no cohort at // all even for the instructor who manually enrolled them. admin.get('/api/admin/students/:id/enrollments', requireAnyInstructor, async (req, res) => { const pool = getPool(); const { rows } = req.instructor.role === 'instructor' ? await pool.query( `SELECT DISTINCT e.* FROM enrollments e LEFT JOIN cohort_enrollments ce ON ce.student_id = e.student_id AND ce.enrollment_status = 'enrolled' LEFT JOIN cohort_instructors ci ON ci.cohort_id = ce.cohort_id AND ci.instructor_id = $2 WHERE e.student_id = $1 AND (e.created_by = $2 OR ci.instructor_id IS NOT NULL) ORDER BY e.enrolled_at DESC`, [req.params.id, req.instructor.instructor_id]) : await pool.query(`SELECT * FROM enrollments WHERE student_id = $1 ORDER BY enrolled_at DESC`, [req.params.id]); const views = await Promise.all(rows.map((e) => buildProgramStatus(statusDeps, e))); res.json(views); }); };