feat(admin): Programs area, enrollment management, student enrolled-programs view
- 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.
This commit is contained in:
@@ -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' && <AdminDashboard />}
|
||||
{view === 'cohorts' && <CohortsView role={instructor.role} />}
|
||||
{view === 'students' && <StudentsView role={instructor.role} />}
|
||||
{view === 'programs' && <ProgramsView role={instructor.role} />}
|
||||
{view === 'classroom' && <ClassroomView />}
|
||||
{view === 'learning' && <LearningView />}
|
||||
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
|
||||
|
||||
@@ -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 <ProgramDetail programId={selected} canAuthor={canAuthor} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{canAuthor && (
|
||||
<div className={styles.card}>
|
||||
<h2>Create program</h2>
|
||||
<CreateProgramForm onCreated={reload} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.card}>
|
||||
<h2>Programs</h2>
|
||||
{!programs ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Family</th><th>Delivery</th><th>Enrollments</th><th>Status</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{programs.map((p) => (
|
||||
<tr key={p.program_id}>
|
||||
<td>{p.name}</td>
|
||||
<td><span className={styles.pill}>{p.family}</span></td>
|
||||
<td className={styles.muted}>{p.delivery_mode.replace('_', ' ')}</td>
|
||||
<td>{p.enrollment_count}</td>
|
||||
<td><span className={`${styles.pill} ${p.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{p.status}</span></td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(p.program_id)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Program ID (slug)</label>
|
||||
<input className={styles.input} value={form.program_id} onChange={(e) => setForm({ ...form, program_id: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Name</label>
|
||||
<input className={styles.input} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Family</label>
|
||||
<select className={styles.select} value={form.family} onChange={(e) => setForm({ ...form, family: e.target.value })}>
|
||||
<option value="init">init_ (ages ~5-10)</option>
|
||||
<option value="compile">compile_ (ages ~11-14)</option>
|
||||
<option value="root">root_ (ages ~15-18+)</option>
|
||||
</select></div>
|
||||
<div><label className={styles.label}>Delivery mode</label>
|
||||
<select className={styles.select} value={form.delivery_mode} onChange={(e) => setForm({ ...form, delivery_mode: e.target.value })}>
|
||||
<option value="self_paced">self-paced</option>
|
||||
<option value="cohort">cohort</option>
|
||||
<option value="hybrid">hybrid</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<label className={styles.label}>Description</label>
|
||||
<input className={styles.input} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
<p className={styles.muted} style={{ marginTop: '0.5rem' }}>
|
||||
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.
|
||||
</p>
|
||||
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to programs</button>
|
||||
{!program ? <p className={styles.muted}>Loading…</p> : (
|
||||
<>
|
||||
<h2 style={{ margin: '1rem 0' }}>{program.name} <span className={`${styles.pill} ${program.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{program.status}</span></h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
<div className={styles.card}>
|
||||
<h3>Overview</h3>
|
||||
<p className={styles.muted}>{program.description || 'No description.'}</p>
|
||||
<p>Family: <span className={styles.pill}>{program.family}</span> — Delivery: {program.delivery_mode.replace('_', ' ')} — Curriculum: {program.curriculum_path} (v{program.version})</p>
|
||||
<p className={styles.muted}>
|
||||
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}
|
||||
</p>
|
||||
{canAuthor && (
|
||||
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.updateProgram(programId, { status: program.status === 'active' ? 'inactive' : 'active' }))}>
|
||||
{program.status === 'active' ? 'Deactivate' : 'Activate'} program
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canAuthor && (
|
||||
<div className={styles.card}>
|
||||
<h3>Enroll a student</h3>
|
||||
<EnrollForm programId={programId} onEnrolled={() => { setNotice('Enrolled.'); reload() }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Enrollments</h3>
|
||||
{!enrollments ? <p className={styles.muted}>Loading…</p> : enrollments.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Status</th><th>Source</th><th>Enrolled</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{enrollments.map((e) => (
|
||||
<tr key={e.enrollment_id}>
|
||||
<td>{e.student_display_name}</td>
|
||||
<td><span className={`${styles.pill} ${e.status === 'active' ? styles.pillOk : e.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{e.status}</span></td>
|
||||
<td className={styles.muted}>{e.source}</td>
|
||||
<td className={styles.muted}>{new Date(e.enrolled_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
{e.status === 'pending' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.activateEnrollment(e.enrollment_id))}>Activate</button>}
|
||||
{['pending', 'active'].includes(e.status) && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.cancelEnrollment(e.enrollment_id))}>Cancel</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Student ID</label>
|
||||
<input className={styles.input} value={studentId} onChange={(e) => setStudentId(e.target.value)} placeholder="student-001" required /></div>
|
||||
<div><label className={styles.label}>Initial status</label>
|
||||
<select className={styles.select} value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="pending">pending</option>
|
||||
<option value="active">active (skip activation step)</option>
|
||||
</select></div>
|
||||
<div><label className={styles.label}>Starts (optional)</label>
|
||||
<input className={styles.input} type="date" value={startsAt} onChange={(e) => setStartsAt(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Access ends (optional)</label>
|
||||
<input className={styles.input} type="date" value={accessEndsAt} onChange={(e) => setAccessEndsAt(e.target.value)} /></div>
|
||||
</div>
|
||||
<button className={styles.btn} disabled={busy || !studentId.trim()}>{busy ? 'Enrolling…' : 'Enroll'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -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 }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Enrolled programs</h3>
|
||||
{!enrollments ? <p className={styles.muted}>Loading…</p> : enrollments.length === 0 ? <p className={styles.muted}>No program enrollments.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Program</th><th>Status</th><th>Delivery</th><th>Progress</th><th>Last activity</th></tr></thead>
|
||||
<tbody>
|
||||
{enrollments.map((e) => (
|
||||
<tr key={e.enrollment_id}>
|
||||
<td>{e.program_name}</td>
|
||||
<td><span className={`${styles.pill} ${e.effective_status === 'active' ? styles.pillOk : e.effective_status === 'completed' ? styles.pillOk : styles.pillMuted}`}>{e.effective_status}</span></td>
|
||||
<td className={styles.muted}>{e.delivery_mode.replace('_', ' ')}</td>
|
||||
<td className={styles.muted}>{e.progress ? `${e.progress.percent_complete}% — ${e.progress.current_stage || 'capstone'}` : '—'}</td>
|
||||
<td className={styles.muted}>{e.completed_at ? `Completed ${new Date(e.completed_at).toLocaleDateString()}` : e.activated_at ? new Date(e.activated_at).toLocaleDateString() : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Curriculum progress</h3>
|
||||
{!progress ? <p className={styles.muted}>Loading…</p> : progress.stages.map((s) => (
|
||||
|
||||
@@ -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`),
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user