From 17160da89e881c7431bc20f4c229ce313fa96196 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 25 Aug 2026 12:08:29 -0500 Subject: [PATCH] feat(admin): Programs area, enrollment management, student enrolled-programs view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin-programs-routes.js: program CRUD (admin/owner authoring, all roles can view — matches assessment_definitions' convention), scoped enrollment listing, manual enroll, activate/ cancel (via rbac.js's requireEnrollmentAccess). - ProgramsView.jsx: cohort-picker-free program list/create, per-program detail with enrollment table and a manual-enroll form. - StudentsView.jsx: student detail gained an 'Enrolled programs' card. - AdminPortal.jsx: new Programs nav entry. --- frontend/src/admin/AdminPortal.jsx | 3 + frontend/src/admin/ProgramsView.jsx | 216 ++++++++++++++++++++++ frontend/src/admin/StudentsView.jsx | 24 +++ frontend/src/admin/api.js | 12 ++ platform/api/src/admin-programs-routes.js | 167 +++++++++++++++++ 5 files changed, 422 insertions(+) create mode 100644 frontend/src/admin/ProgramsView.jsx create mode 100644 platform/api/src/admin-programs-routes.js diff --git a/frontend/src/admin/AdminPortal.jsx b/frontend/src/admin/AdminPortal.jsx index a521ad9..e10621e 100644 --- a/frontend/src/admin/AdminPortal.jsx +++ b/frontend/src/admin/AdminPortal.jsx @@ -9,6 +9,7 @@ import AccountView from './AccountView' import LearningView from './LearningView' import AssessmentsView from './AssessmentsView' import ClassroomView from './ClassroomView' +import ProgramsView from './ProgramsView' import styles from './Admin.module.css' // Instructor/Admin portal — clearly separate from the student experience (/admin route, @@ -21,6 +22,7 @@ const NAV = [ { key: 'dashboard', label: 'Dashboard' }, { key: 'cohorts', label: 'Cohorts' }, { key: 'students', label: 'Students' }, + { key: 'programs', label: 'Programs' }, { key: 'classroom', label: 'Classroom' }, { key: 'learning', label: 'Learning' }, { key: 'assessments', label: 'Assessments' }, @@ -61,6 +63,7 @@ export default function AdminPortal() { {view === 'dashboard' && } {view === 'cohorts' && } {view === 'students' && } + {view === 'programs' && } {view === 'classroom' && } {view === 'learning' && } {view === 'assessments' && } diff --git a/frontend/src/admin/ProgramsView.jsx b/frontend/src/admin/ProgramsView.jsx new file mode 100644 index 0000000..5d4e124 --- /dev/null +++ b/frontend/src/admin/ProgramsView.jsx @@ -0,0 +1,216 @@ +import { useState, useEffect, useCallback } from 'react' +import { adminApi } from './api' +import styles from './Admin.module.css' + +// Phase 5B §11 — Programs area. Authoring (create/edit/activate-deactivate) is admin/owner +// only, matching AssessmentsView's "viewed by all roles, only authoring restricted" pattern — +// canAuthor gates the create form and the activate/deactivate toggle only, not the list itself. +export default function ProgramsView({ role }) { + const [programs, setPrograms] = useState(null) + const [selected, setSelected] = useState(null) + const [error, setError] = useState(null) + const canAuthor = role === 'owner' || role === 'admin' + + const reload = useCallback(() => { + adminApi.programs().then(setPrograms).catch((e) => setError(e.message)) + }, []) + useEffect(reload, [reload]) + + if (selected) return { setSelected(null); reload() }} /> + + return ( + <> + {error &&
{error}
} + {canAuthor && ( +
+

Create program

+ +
+ )} +
+

Programs

+ {!programs ?

Loading…

: ( + + + + {programs.map((p) => ( + + + + + + + + + ))} + +
NameFamilyDeliveryEnrollmentsStatus
{p.name}{p.family}{p.delivery_mode.replace('_', ' ')}{p.enrollment_count}{p.status}
+ )} +
+ + ) +} + +function CreateProgramForm({ onCreated }) { + const [form, setForm] = useState({ program_id: '', name: '', family: 'root', delivery_mode: 'self_paced', description: '' }) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const submit = async (e) => { + e.preventDefault() + setBusy(true); setError(null) + try { + await adminApi.createProgram(form) + setForm({ program_id: '', name: '', family: 'root', delivery_mode: 'self_paced', description: '' }) + onCreated() + } catch (e) { setError(e.message) } finally { setBusy(false) } + } + + return ( +
+ {error &&
{error}
} +
+
+ setForm({ ...form, program_id: e.target.value })} required />
+
+ setForm({ ...form, name: e.target.value })} required />
+
+
+
+
+
+ + setForm({ ...form, description: e.target.value })} /> +

+ New programs default to curriculum_path 'rootnode-path' — the only curriculum content that exists today. A program for init_/compile_ can be created now and wired to real content later without a schema change. +

+ +
+ ) +} + +function ProgramDetail({ programId, canAuthor, onBack }) { + const [program, setProgram] = useState(null) + const [enrollments, setEnrollments] = useState(null) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [busy, setBusy] = useState(false) + + const reload = useCallback(() => { + adminApi.program(programId).then(setProgram).catch((e) => setError(e.message)) + adminApi.programEnrollments(programId).then(setEnrollments).catch(() => {}) + }, [programId]) + useEffect(reload, [reload]) + + const withBusy = (fn) => async () => { + setBusy(true); setError(null); setNotice(null) + try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) } + } + + return ( + <> + + {!program ?

Loading…

: ( + <> +

{program.name} {program.status}

+ {error &&
{error}
} + {notice &&

{notice}

} +
+

Overview

+

{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 && ( + + )} +
+ + {canAuthor && ( +
+

Enroll a student

+ { setNotice('Enrolled.'); reload() }} /> +
+ )} + +
+

Enrollments

+ {!enrollments ?

Loading…

: enrollments.length === 0 ?

None yet.

: ( + + + + {enrollments.map((e) => ( + + + + + + + + ))} + +
StudentStatusSourceEnrolled
{e.student_display_name}{e.status}{e.source}{new Date(e.enrolled_at).toLocaleDateString()} + {e.status === 'pending' && } + {['pending', 'active'].includes(e.status) && } +
+ )} +
+ + )} + + ) +} + +function EnrollForm({ programId, onEnrolled }) { + const [studentId, setStudentId] = useState('') + const [status, setStatus] = useState('pending') + const [startsAt, setStartsAt] = useState('') + const [accessEndsAt, setAccessEndsAt] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const submit = async (e) => { + e.preventDefault() + setBusy(true); setError(null) + try { + await adminApi.createEnrollment({ + student_id: studentId.trim(), program_id: programId, status, + starts_at: startsAt || undefined, access_ends_at: accessEndsAt || undefined, + }) + setStudentId(''); setStartsAt(''); setAccessEndsAt('') + onEnrolled() + } catch (e) { setError(e.message) } finally { setBusy(false) } + } + + return ( +
+ {error &&
{error}
} +
+
+ setStudentId(e.target.value)} placeholder="student-001" required />
+
+
+
+ setStartsAt(e.target.value)} />
+
+ setAccessEndsAt(e.target.value)} />
+
+ +
+ ) +} diff --git a/frontend/src/admin/StudentsView.jsx b/frontend/src/admin/StudentsView.jsx index 934d7fa..b8c694a 100644 --- a/frontend/src/admin/StudentsView.jsx +++ b/frontend/src/admin/StudentsView.jsx @@ -167,6 +167,7 @@ function StudentDetail({ studentId, canManage, onBack }) { const [env, setEnv] = useState(null) const [events, setEvents] = useState(null) const [sessions, setSessions] = useState(null) + const [enrollments, setEnrollments] = useState(null) const [error, setError] = useState(null) const [notice, setNotice] = useState(null) const [busy, setBusy] = useState(false) @@ -180,6 +181,9 @@ function StudentDetail({ studentId, canManage, onBack }) { adminApi.studentAttempts(studentId).then((r) => setAttempts(r.items)).catch(() => {}) adminApi.studentEnvironment(studentId).then((rows) => setEnv(rows?.find((e) => e.status !== 'destroyed') || null)).catch(() => {}) adminApi.studentSessions(studentId).then(setSessions).catch(() => {}) + // Phase 5B §11 — scoped server-side the same way as everything else here: a plain + // instructor only sees enrollments they created or that share a cohort with them. + adminApi.studentEnrollments(studentId).then(setEnrollments).catch(() => {}) }, [studentId]) useEffect(reload, [reload]) @@ -248,6 +252,26 @@ function StudentDetail({ studentId, canManage, onBack }) { )} +
+

Enrolled programs

+ {!enrollments ?

Loading…

: enrollments.length === 0 ?

No program enrollments.

: ( + + + + {enrollments.map((e) => ( + + + + + + + + ))} + +
ProgramStatusDeliveryProgressLast 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() : '—'}
+ )} +
+

Curriculum progress

{!progress ?

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); + }); +};