Loading…
: ( +| Name | Family | Delivery | Enrollments | Status | |
|---|---|---|---|---|---|
| {p.name} | +{p.family} | +{p.delivery_mode.replace('_', ' ')} | +{p.enrollment_count} | +{p.status} | ++ |
Loading…
: ( + <> +{notice}
} +{program.description || 'No description.'}
+Family: {program.family} — Delivery: {program.delivery_mode.replace('_', ' ')} — Curriculum: {program.curriculum_path} (v{program.version})
++ Enrollments — pending: {program.enrollment_counts.pending || 0}, active: {program.enrollment_counts.active || 0}, + {' '}completed: {program.enrollment_counts.completed || 0}, cancelled: {program.enrollment_counts.cancelled || 0}, expired: {program.enrollment_counts.expired || 0} +
+ {canAuthor && ( + + )} +Loading…
: enrollments.length === 0 ?None yet.
: ( +| Student | Status | Source | Enrolled | |
|---|---|---|---|---|
| {e.student_display_name} | +{e.status} | +{e.source} | +{new Date(e.enrolled_at).toLocaleDateString()} | ++ {e.status === 'pending' && } + {['pending', 'active'].includes(e.status) && } + | +
Loading…
: enrollments.length === 0 ?No program enrollments.
: ( +| Program | Status | Delivery | Progress | Last activity |
|---|---|---|---|---|
| {e.program_name} | +{e.effective_status} | +{e.delivery_mode.replace('_', ' ')} | +{e.progress ? `${e.progress.percent_complete}% — ${e.progress.current_stage || 'capstone'}` : '—'} | +{e.completed_at ? `Completed ${new Date(e.completed_at).toLocaleDateString()}` : e.activated_at ? new Date(e.activated_at).toLocaleDateString() : '—'} | +
Loading…
: progress.stages.map((s) => ( diff --git a/frontend/src/admin/api.js b/frontend/src/admin/api.js index cc0715e..fc2d812 100644 --- a/frontend/src/admin/api.js +++ b/frontend/src/admin/api.js @@ -116,4 +116,16 @@ export const adminApi = { postClassroomAnnouncement: (id, message) => post(`/api/admin/classroom-sessions/${id}/announcements`, { message }), acknowledgeHelpRequest: (id) => post(`/api/admin/help-requests/${id}/acknowledge`), resolveHelpRequest: (id, note) => post(`/api/admin/help-requests/${id}/resolve`, { note }), + + // ── Phase 5B §11: programs and enrollment ─────────────────────────────────────────────── + programs: () => get('/api/admin/programs'), + program: (id) => get(`/api/admin/programs/${id}`), + createProgram: (fields) => post('/api/admin/programs', fields), + updateProgram: (id, fields) => patch(`/api/admin/programs/${id}`, fields), + programEnrollments: (id) => get(`/api/admin/programs/${id}/enrollments`), + createEnrollment: (fields) => post('/api/admin/enrollments', fields), + enrollment: (id) => get(`/api/admin/enrollments/${id}`), + activateEnrollment: (id) => post(`/api/admin/enrollments/${id}/activate`), + cancelEnrollment: (id) => post(`/api/admin/enrollments/${id}/cancel`), + studentEnrollments: (studentId) => get(`/api/admin/students/${studentId}/enrollments`), } diff --git a/platform/api/src/admin-programs-routes.js b/platform/api/src/admin-programs-routes.js new file mode 100644 index 0000000..c5e8ba5 --- /dev/null +++ b/platform/api/src/admin-programs-routes.js @@ -0,0 +1,167 @@ +// 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 { 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, metadata } = req.body || {}; + if (!student_id || !program_id) return res.status(400).json({ error: 'student_id and program_id are required' }); + try { + const 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, 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 } }); + res.status(201).json(created); + } catch (e) { + if (e.code) return res.status(enrollmentErrorStatus(e.code)).json({ error: e.code, message: e.message }); + throw e; + } + }); + + 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); + }); +};