feat(admin): add instructor learning analytics and assessment management

New /api/admin routes: cohort learning summary (completion by stage, active learners),
per-student learning detail (attempts, hint usage, assessment history, capstone state),
scenario analytics (completion rate, avg attempts, failure/hint frequency, avg time), and a
needs-attention view (repeated failures, hints used but still stuck, inactivity, an expired
assessment - deliberately instructional wording, not punitive, with an explicit test asserting
that). Assessment definition CRUD (admin/owner only), cohort assignment and session view/cancel
(any instructor role, cohort-scoped for a plain instructor via the existing Phase 3A RBAC
helpers - no new authorization primitives needed).

New Learning and Assessments tabs in the admin portal, visible to every instructor role;
definition-authoring controls are shown but the server 403s a create/edit from a non-admin/
owner role, same pattern CohortsView already uses for admin-only controls.
This commit is contained in:
2026-08-18 14:35:59 -05:00
parent 4e3dacc043
commit 7e5f357b6f
5 changed files with 610 additions and 0 deletions
+8
View File
@@ -6,16 +6,22 @@ import StudentsView from './StudentsView'
import InstructorsView from './InstructorsView'
import AuditView from './AuditView'
import AccountView from './AccountView'
import LearningView from './LearningView'
import AssessmentsView from './AssessmentsView'
import styles from './Admin.module.css'
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
// separate rn_instructor session cookie, separate login). A student session cannot reach any
// of these views or their underlying /api/admin/* routes. Per-person accounts replace Phase
// 2B's single shared ADMIN_PASSWORD — see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md.
// Learning/Assessments (Phase 3B) are visible to every instructor role — cohort scoping for a
// plain 'instructor' happens server-side per route, not by hiding the whole tab.
const NAV = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'cohorts', label: 'Cohorts' },
{ key: 'students', label: 'Students' },
{ key: 'learning', label: 'Learning' },
{ key: 'assessments', label: 'Assessments' },
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
{ key: 'audit', label: 'Audit', roles: ['owner', 'admin'] },
]
@@ -53,6 +59,8 @@ export default function AdminPortal() {
{view === 'dashboard' && <AdminDashboard />}
{view === 'cohorts' && <CohortsView role={instructor.role} />}
{view === 'students' && <StudentsView role={instructor.role} />}
{view === 'learning' && <LearningView />}
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
{view === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
{view === 'audit' && ['owner', 'admin'].includes(instructor.role) && <AuditView />}
{view === 'account' && <AccountView instructor={instructor} />}
+164
View File
@@ -0,0 +1,164 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Exam and Capstone definition/session management. Definition authoring (create/edit) is
// admin/owner only server-side (RBAC) — a plain instructor can still assign an existing
// definition to their own cohort and view/cancel sessions within it, so the form below is
// always shown but the server will 403 a create/edit attempt from an instructor role; this
// mirrors CohortsView's approach of not pre-emptively hiding every control, since assessments
// are viewed by all roles and only definition-authoring is restricted.
export default function AssessmentsView({ role }) {
const [assessments, setAssessments] = useState(null)
const [cohorts, setCohorts] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const canAuthor = role === 'owner' || role === 'admin'
const reload = useCallback(() => {
adminApi.assessments().then(setAssessments).catch((e) => setError(e.message))
adminApi.cohorts().then(setCohorts).catch(() => {})
}, [])
useEffect(reload, [reload])
if (selected) return <AssessmentDetail definition={selected} cohorts={cohorts} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
{canAuthor && (
<div className={styles.card}>
<h2>Create assessment</h2>
<CreateAssessmentForm onCreated={reload} />
</div>
)}
<div className={styles.card}>
<h2>Exams &amp; Capstones</h2>
{!assessments ? <p className={styles.muted}>Loading…</p> : (
<table>
<thead><tr><th>Title</th><th>Type</th><th>Duration</th><th>Hints</th><th>Status</th><th /></tr></thead>
<tbody>
{assessments.map((a) => (
<tr key={a.assessment_definition_id}>
<td>{a.title}</td>
<td><span className={styles.pill}>{a.type}</span></td>
<td>{a.duration_minutes} min</td>
<td>{a.hint_policy}</td>
<td><span className={`${styles.pill} ${a.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{a.status}</span></td>
<td><button className={styles.linkBtn} onClick={() => setSelected(a)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function CreateAssessmentForm({ onCreated }) {
const [form, setForm] = useState({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled', pool_spec: '' })
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
const pool_spec = JSON.parse(form.pool_spec || '{"required":[],"random":[]}')
await adminApi.createAssessment({ ...form, duration_minutes: Number(form.duration_minutes), pool_spec })
setForm({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled', pool_spec: '' })
onCreated()
} catch (e) { setError(e.message?.includes('JSON') ? 'pool_spec must be valid JSON' : 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}>ID (slug)</label>
<input className={styles.input} value={form.id} onChange={(e) => setForm({ ...form, id: e.target.value })} required /></div>
<div><label className={styles.label}>Title</label>
<input className={styles.input} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required /></div>
<div><label className={styles.label}>Type</label>
<select className={styles.select} value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
<option value="exam">exam</option><option value="capstone">capstone</option>
</select></div>
<div><label className={styles.label}>Duration (min)</label>
<input className={styles.input} type="number" value={form.duration_minutes} onChange={(e) => setForm({ ...form, duration_minutes: e.target.value })} required /></div>
<div><label className={styles.label}>Hint policy</label>
<select className={styles.select} value={form.hint_policy} onChange={(e) => setForm({ ...form, hint_policy: e.target.value })}>
<option value="disabled">disabled</option><option value="flagged">flagged</option><option value="free">free</option>
</select></div>
</div>
<label className={styles.label}>Pool spec (JSON)</label>
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '4rem' }}
placeholder='{"required":["scenario-id"],"random":[{"count":1,"from":["a","b"]}]}'
value={form.pool_spec} onChange={(e) => setForm({ ...form, pool_spec: e.target.value })} required />
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create'}</button>
</form>
)
}
function AssessmentDetail({ definition, cohorts, onBack }) {
const [sessions, setSessions] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [cohortId, setCohortId] = useState(cohorts?.[0]?.cohort_id || '')
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
adminApi.assessmentSessions(definition.assessment_definition_id).then(setSessions).catch((e) => setError(e.message))
}, [definition])
useEffect(reload, [reload])
const assign = async () => {
setBusy(true); setError(null); setNotice(null)
try { await adminApi.assignAssessment(definition.assessment_definition_id, cohortId); setNotice('Assigned.') }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
const cancelSession = async (sessionId) => {
if (!window.confirm('Cancel this session?')) return
try { await adminApi.cancelAssessmentSession(sessionId); reload() } catch (e) { setError(e.message) }
}
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to assessments</button>
<h2 style={{ margin: '1rem 0' }}>{definition.title} <span className={styles.muted}>({definition.type})</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<div className={styles.card}>
<h3>Assign to cohort</h3>
<div className={styles.formRow}>
<div><select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
</select></div>
<div><button className={styles.btn} disabled={busy || !cohortId} onClick={assign}>Assign</button></div>
</div>
</div>
<div className={styles.card}>
<h3>Sessions</h3>
{!sessions ? <p className={styles.muted}>Loading…</p> : sessions.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Student</th><th>Status</th><th>Started</th><th>Result</th><th /></tr></thead>
<tbody>
{sessions.map((s) => (
<tr key={s.assessment_session_id}>
<td>{s.display_name}</td>
<td><span className={`${styles.pill} ${s.status === 'completed' ? styles.pillOk : s.status === 'expired' ? styles.pillErr : styles.pillWarn}`}>{s.status}</span></td>
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : '—'}</td>
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
<td>{['not_started', 'in_progress'].includes(s.status) && <button className={styles.linkBtn} onClick={() => cancelSession(s.assessment_session_id)}>Cancel</button>}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
+160
View File
@@ -0,0 +1,160 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Instructor learning analytics — cohort summary, "needs attention," per-scenario analytics,
// and a student drill-down. Deliberately no leaderboards or scoring language — this is
// instructional visibility, not a ranking (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §13).
export default function LearningView() {
const [cohorts, setCohorts] = useState(null)
const [cohortId, setCohortId] = useState(null)
const [summary, setSummary] = useState(null)
const [needsAttention, setNeedsAttention] = useState(null)
const [scenarioStats, setScenarioStats] = useState(null)
const [selectedStudent, setSelectedStudent] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { adminApi.cohorts().then((c) => { setCohorts(c); if (c[0]) setCohortId(c[0].cohort_id) }).catch((e) => setError(e.message)) }, [])
const reload = useCallback(() => {
if (!cohortId) return
adminApi.cohortLearning(cohortId).then(setSummary).catch((e) => setError(e.message))
adminApi.needsAttention(cohortId).then(setNeedsAttention).catch((e) => setError(e.message))
adminApi.scenarioAnalytics(cohortId).then(setScenarioStats).catch((e) => setError(e.message))
}, [cohortId])
useEffect(reload, [reload])
if (selectedStudent) return <StudentLearningDetail studentId={selectedStudent} onBack={() => setSelectedStudent(null)} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<label className={styles.label}>Cohort</label>
<select className={styles.select} value={cohortId || ''} onChange={(e) => setCohortId(e.target.value)}>
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
</select>
</div>
{summary && (
<div className={styles.card}>
<h2>Cohort Summary</h2>
<div className={styles.statGrid}>
<div className={styles.statCard}><div className={styles.statValue}>{summary.student_count}</div><div className={styles.statLabel}>Students</div></div>
<div className={styles.statCard}><div className={styles.statValue}>{summary.active_learners}</div><div className={styles.statLabel}>Active (7d)</div></div>
<div className={styles.statCard}><div className={styles.statValue}>{summary.overall_completion_pct}%</div><div className={styles.statLabel}>Avg. Completion</div></div>
</div>
<table>
<thead><tr><th>Stage</th><th>Students Complete</th></tr></thead>
<tbody>
{summary.stages.map((s) => (
<tr key={s.id}><td>{s.name}</td><td>{s.completedBy}/{summary.student_count} ({s.pct}%)</td></tr>
))}
</tbody>
</table>
</div>
)}
<div className={styles.card}>
<h2>Needs Attention</h2>
{!needsAttention ? <p className={styles.muted}>Loading…</p> : needsAttention.length === 0 ? <p className={styles.muted}>No signals right now.</p> : (
<table>
<thead><tr><th>Student</th><th>Signal</th><th>Detail</th><th /></tr></thead>
<tbody>
{needsAttention.map((s, i) => (
<tr key={i}>
<td>{s.display_name}</td>
<td><span className={`${styles.pill} ${styles.pillWarn}`}>{s.label}</span></td>
<td className={styles.muted}>{s.detail}</td>
<td><button className={styles.linkBtn} onClick={() => setSelectedStudent(s.student_id)}>View</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h2>Scenario Analytics</h2>
{!scenarioStats ? <p className={styles.muted}>Loading…</p> : (
<table>
<thead><tr><th>Scenario</th><th>Completions</th><th>Avg Attempts</th><th>Failures</th><th>Hints Used</th><th>Avg Time</th></tr></thead>
<tbody>
{scenarioStats.map((s) => (
<tr key={s.scenario_id}>
<td>{s.scenario_id}</td><td>{s.completions}</td><td>{s.avg_attempts}</td>
<td>{s.total_failures}</td><td>{s.total_hints_used}</td>
<td>{s.avg_duration_seconds ? `${Math.round(s.avg_duration_seconds / 60)}m` : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function StudentLearningDetail({ studentId, onBack }) {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { adminApi.studentLearning(studentId).then(setData).catch((e) => setError(e.message)) }, [studentId])
if (error) return <div className={styles.errorBox}>{error}</div>
if (!data) return <p className={styles.muted}>Loading…</p>
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back</button>
<h2 style={{ margin: '1rem 0' }}>{studentId}</h2>
{data.curriculum && (
<div className={styles.card}>
<h3>Curriculum ({data.curriculum.percent_complete}%)</h3>
{data.curriculum.stages.map((s) => (
<div key={s.id} style={{ fontSize: '0.85rem', padding: '0.2rem 0' }}>
{s.complete ? '✅' : s.locked ? '🔒' : '🟡'} {s.name} — {s.completed}/{s.total}
</div>
))}
</div>
)}
<div className={styles.card}>
<h3>Attempts</h3>
<table>
<thead><tr><th>Scenario</th><th>Status</th><th>Validations</th><th>Hints (max level)</th><th>Duration</th></tr></thead>
<tbody>
{data.attempts.map((a, i) => (
<tr key={i}>
<td>{a.scenario_id}</td>
<td><span className={`${styles.pill} ${a.status === 'completed' ? styles.pillOk : styles.pillWarn}`}>{a.status}</span></td>
<td>{a.validation_pass_count} passed, {a.validation_fail_count} failed</td>
<td>{a.hint_count} ({a.highest_hint_level})</td>
<td>{a.duration_seconds ? `${Math.round(a.duration_seconds / 60)}m` : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className={styles.card}>
<h3>Assessment History</h3>
{data.assessment_sessions.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Assessment</th><th>Status</th><th>Result</th></tr></thead>
<tbody>
{data.assessment_sessions.map((s) => (
<tr key={s.assessment_session_id}>
<td>{s.title}</td>
<td><span className={`${styles.pill} ${s.status === 'completed' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
+14
View File
@@ -80,4 +80,18 @@ export const adminApi = {
resetInstructorPassword: (id, new_password) => post(`/api/admin/instructors/${id}/reset-password`, { new_password }),
auditEvents: () => get('/api/admin/audit-events'),
// ── Phase 3B: learning analytics ────────────────────────────────────────────────────────
cohortLearning: (id) => get(`/api/admin/cohorts/${id}/learning`),
studentLearning: (id) => get(`/api/admin/students/${id}/learning`),
scenarioAnalytics: (cohortId) => get(`/api/admin/scenario-analytics${cohortId ? `?cohort_id=${cohortId}` : ''}`),
needsAttention: (cohortId) => get(`/api/admin/cohorts/${cohortId}/needs-attention`),
// ── Phase 3B: assessment (exam + capstone) management ───────────────────────────────────
assessments: () => get('/api/admin/assessments'),
createAssessment: (fields) => post('/api/admin/assessments', fields),
updateAssessment: (id, fields) => patch(`/api/admin/assessments/${id}`, fields),
assignAssessment: (id, cohort_id) => post(`/api/admin/assessments/${id}/assign`, { cohort_id }),
assessmentSessions: (id) => get(`/api/admin/assessments/${id}/sessions`),
cancelAssessmentSession: (sessionId) => post(`/api/admin/assessment-sessions/${sessionId}/cancel`),
}
+264
View File
@@ -0,0 +1,264 @@
// Instructor/Admin learning API — cohort/student/scenario analytics, "needs attention," and
// assessment (exam + capstone) definition/session management. Same RBAC shape as
// admin-routes.js: owner/admin get broad or global access, a plain 'instructor' is scoped to
// their assigned cohorts (see rbac.js). Assessment/capstone DEFINITION authoring (create/edit/
// activate) stays admin/owner-only — assigning an already-approved assessment to one of their
// own cohorts, and viewing/canceling sessions within their scope, is what a plain instructor
// can do, per the Phase 3B RBAC matrix.
const { computeCurriculumState } = require('./curriculum-rules');
const NEEDS_ATTENTION_FAIL_THRESHOLD = 3; // repeated validation failures on one scenario
const NEEDS_ATTENTION_STALE_DAYS = 7; // no progress in this many days
module.exports = function registerAdminLearningRoutes(app, deps) {
const { instructorSession, rbac, curriculum, getPool, loadProgress, recordAudit } = deps;
const { requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess } = rbac;
function wrap(fn) {
return (req, res, next) => {
try { const r = fn(req, res, next); if (r && r.catch) r.catch(next); } catch (e) { next(e); }
};
}
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)),
};
// ── Cohort learning summary ──────────────────────────────────────────────────────────────
admin.get('/api/admin/cohorts/:id/learning', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
const pool = getPool();
const path = curriculum.loadCurriculumPath();
const { rows: students } = await pool.query(
`SELECT s.student_id, s.display_name, s.last_active_at FROM cohort_enrollments e
JOIN students s ON s.student_id = e.student_id
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled'`,
[req.params.id]
);
const stageTotals = path ? path.stages.filter((s) => s.type !== 'capstone').map((s) => ({ id: s.id, name: s.name, completedBy: 0 })) : [];
let overallPct = 0, activeLearners = 0;
const staleCutoff = Date.now() - NEEDS_ATTENTION_STALE_DAYS * 86400000;
for (const s of students) {
const progress = await loadProgress(pool, s.student_id);
const state = path ? computeCurriculumState(path, progress) : { stages: [], percent_complete: 0 };
overallPct += state.percent_complete;
if (s.last_active_at && new Date(s.last_active_at).getTime() > staleCutoff) activeLearners++;
state.stages.forEach((st, i) => { if (st.complete) stageTotals[i].completedBy++; });
}
res.json({
student_count: students.length,
active_learners: activeLearners,
overall_completion_pct: students.length ? Math.round(overallPct / students.length) : 0,
stages: stageTotals.map((s) => ({ ...s, pct: students.length ? Math.round((s.completedBy / students.length) * 100) : 0 })),
});
});
// ── Student learning detail ──────────────────────────────────────────────────────────────
admin.get('/api/admin/students/:id/learning', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
const pool = getPool();
const path = curriculum.loadCurriculumPath();
const progress = await loadProgress(pool, req.params.id);
const capstoneAchievement = (await pool.query(
`SELECT * FROM achievements WHERE student_id = $1 AND achievement_type = 'capstone_completed' LIMIT 1`, [req.params.id]
)).rows[0];
const state = path ? computeCurriculumState(path, progress, capstoneAchievement ? 'completed' : undefined) : null;
const { rows: attempts } = await pool.query(
`SELECT scenario_id, status, result, validation_attempt_count, validation_pass_count, validation_fail_count,
hint_count, highest_hint_level, duration_seconds, started_at, completed_at
FROM scenario_attempts WHERE student_id = $1 ORDER BY started_at DESC NULLS LAST LIMIT 100`,
[req.params.id]
);
const { rows: assessmentSessions } = await pool.query(
`SELECT assessment_session_id, assessment_definition_id, type, title, status, started_at, submitted_at, result
FROM assessment_sessions WHERE student_id = $1 ORDER BY created_at DESC`,
[req.params.id]
);
res.json({
curriculum: state, attempts, assessment_sessions: assessmentSessions,
achievements: (await pool.query(`SELECT * FROM achievements WHERE student_id = $1 ORDER BY achieved_at DESC`, [req.params.id])).rows,
});
});
// ── Scenario analytics ───────────────────────────────────────────────────────────────────
admin.get('/api/admin/scenario-analytics', requireAnyInstructor, async (req, res) => {
const pool = getPool();
const cohortId = req.query.cohort_id || null;
if (req.instructor.role === 'instructor') {
if (!cohortId) return res.status(400).json({ error: 'cohort_id is required for the instructor role' });
const { rowCount } = await pool.query(`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`, [cohortId, req.instructor.instructor_id]);
if (!rowCount) return res.status(403).json({ error: 'forbidden' });
}
const { rows } = await pool.query(
`SELECT scenario_id,
count(*) FILTER (WHERE status = 'completed')::int AS completions,
count(DISTINCT student_id)::int AS distinct_students,
avg(validation_attempt_count)::numeric(10,1) AS avg_attempts,
sum(validation_fail_count)::int AS total_failures,
sum(hint_count)::int AS total_hints_used,
avg(duration_seconds) FILTER (WHERE duration_seconds IS NOT NULL)::int AS avg_duration_seconds
FROM scenario_attempts
WHERE ($1::text IS NULL OR cohort_id = $1)
GROUP BY scenario_id ORDER BY completions DESC`,
[cohortId]
);
res.json(rows);
});
// ── Needs attention ───────────────────────────────────────────────────────────────────────
admin.get('/api/admin/cohorts/:id/needs-attention', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
const pool = getPool();
const staleCutoff = new Date(Date.now() - NEEDS_ATTENTION_STALE_DAYS * 86400000);
const { rows: students } = await pool.query(
`SELECT s.student_id, s.display_name, s.last_active_at FROM cohort_enrollments e
JOIN students s ON s.student_id = e.student_id WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled'`,
[req.params.id]
);
const signals = [];
for (const s of students) {
const { rows: stuck } = await pool.query(
`SELECT scenario_id, validation_fail_count, highest_hint_level FROM scenario_attempts
WHERE student_id = $1 AND status = 'in_progress' AND validation_fail_count >= $2
ORDER BY validation_fail_count DESC LIMIT 1`,
[s.student_id, NEEDS_ATTENTION_FAIL_THRESHOLD]
);
if (stuck[0]) {
signals.push({ student_id: s.student_id, display_name: s.display_name, reason: 'repeated_attempts',
label: 'Repeated Attempts', scenario_id: stuck[0].scenario_id, detail: `${stuck[0].validation_fail_count} failed validations` });
}
const { rows: maxedHints } = await pool.query(
`SELECT sa.scenario_id, sa.highest_hint_level FROM scenario_attempts sa
WHERE sa.student_id = $1 AND sa.status = 'in_progress' AND sa.highest_hint_level > 0 LIMIT 1`,
[s.student_id]
);
if (maxedHints[0]) {
signals.push({ student_id: s.student_id, display_name: s.display_name, reason: 'used_hints',
label: 'May Need Help', scenario_id: maxedHints[0].scenario_id, detail: `hint level ${maxedHints[0].highest_hint_level} used, still in progress` });
}
if (!s.last_active_at || new Date(s.last_active_at) < staleCutoff) {
signals.push({ student_id: s.student_id, display_name: s.display_name, reason: 'inactive',
label: 'Needs Attention', detail: s.last_active_at ? `inactive since ${s.last_active_at}` : 'never active' });
}
const { rows: failedAssessment } = await pool.query(
`SELECT assessment_session_id, title FROM assessment_sessions WHERE student_id = $1 AND status = 'expired' ORDER BY created_at DESC LIMIT 1`,
[s.student_id]
);
if (failedAssessment[0]) {
signals.push({ student_id: s.student_id, display_name: s.display_name, reason: 'incomplete_assessment',
label: 'Needs Attention', detail: `"${failedAssessment[0].title}" expired without completion` });
}
}
res.json(signals);
});
// ── Assessment definitions ───────────────────────────────────────────────────────────────
admin.get('/api/admin/assessments', requireAnyInstructor, async (req, res) => {
const { rows } = await getPool().query(`SELECT * FROM assessment_definitions ORDER BY type, title`);
res.json(rows);
});
admin.post('/api/admin/assessments', requireAdminOrOwner, async (req, res) => {
const { id, type, title, description, duration_minutes, hint_policy, pool_spec, completion_rule, unlock_rule } = req.body || {};
if (!id || !type || !title || !duration_minutes || !pool_spec) {
return res.status(400).json({ error: 'id, type, title, duration_minutes, and pool_spec are required' });
}
if (!['exam', 'capstone'].includes(type)) return res.status(400).json({ error: 'invalid type' });
const pool = getPool();
try {
await pool.query(
`INSERT INTO assessment_definitions (assessment_definition_id, type, title, description, duration_minutes, hint_policy, pool_spec, completion_rule, unlock_rule, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[id, type, title, description || null, duration_minutes, hint_policy || 'disabled', JSON.stringify(pool_spec),
JSON.stringify(completion_rule || { type: 'all_scenarios_passed' }), unlock_rule ? JSON.stringify(unlock_rule) : null, req.instructor.instructor_id]
);
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'id already exists' });
throw e;
}
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'assessment_create', resourceType: 'assessment_definition', resourceId: id, result: 'success', sourceIp: req.ip });
res.status(201).json({ id });
});
admin.patch('/api/admin/assessments/:id', requireAdminOrOwner, async (req, res) => {
const { title, description, duration_minutes, hint_policy, pool_spec, completion_rule, unlock_rule, status } = req.body || {};
const pool = getPool();
const { rows } = await pool.query(`SELECT * FROM assessment_definitions WHERE assessment_definition_id = $1`, [req.params.id]);
const current = rows[0];
if (!current) return res.status(404).json({ error: 'not found' });
// Anything that would change what an in-progress session is graded against bumps the
// version — already-started sessions keep their own snapshot, so this never changes a
// result retroactively.
const semanticChange = [
duration_minutes !== undefined && duration_minutes !== current.duration_minutes,
hint_policy !== undefined && hint_policy !== current.hint_policy,
pool_spec !== undefined && JSON.stringify(pool_spec) !== JSON.stringify(current.pool_spec),
completion_rule !== undefined && JSON.stringify(completion_rule) !== JSON.stringify(current.completion_rule),
].some(Boolean);
const fields = ['updated_at = now()'], values = [];
const set = (col, val) => { values.push(val); fields.push(`${col} = $${values.length}`); };
if (title !== undefined) set('title', title);
if (description !== undefined) set('description', description);
if (duration_minutes !== undefined) set('duration_minutes', duration_minutes);
if (hint_policy !== undefined) set('hint_policy', hint_policy);
if (pool_spec !== undefined) set('pool_spec', JSON.stringify(pool_spec));
if (completion_rule !== undefined) set('completion_rule', JSON.stringify(completion_rule));
if (unlock_rule !== undefined) set('unlock_rule', JSON.stringify(unlock_rule));
if (status !== undefined && ['active', 'inactive'].includes(status)) set('status', status);
if (semanticChange) { values.push(current.version + 1); fields.push(`version = $${values.length}`); }
values.push(req.params.id);
await pool.query(`UPDATE assessment_definitions SET ${fields.join(', ')} WHERE assessment_definition_id = $${values.length}`, values);
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'assessment_update', resourceType: 'assessment_definition', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { semanticChange } });
res.json({ ok: true, version_bumped: semanticChange });
});
admin.post('/api/admin/assessments/:id/assign', requireAnyInstructor, async (req, res) => {
const { cohort_id } = req.body || {};
if (!cohort_id) return res.status(400).json({ error: 'cohort_id required' });
if (req.instructor.role === 'instructor') {
const { rowCount } = await getPool().query(`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`, [cohort_id, req.instructor.instructor_id]);
if (!rowCount) return res.status(403).json({ error: 'forbidden' });
}
const pool = getPool();
await pool.query(
`INSERT INTO assessment_cohort_assignments (assessment_definition_id, cohort_id, assigned_by) VALUES ($1,$2,$3)
ON CONFLICT (assessment_definition_id, cohort_id) DO NOTHING`,
[req.params.id, cohort_id, req.instructor.instructor_id]
);
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'assessment_assign', resourceType: 'assessment_definition', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { cohort_id } });
res.json({ ok: true });
});
admin.get('/api/admin/assessments/:id/sessions', requireAnyInstructor, async (req, res) => {
const pool = getPool();
let scopeFilter = '';
const params = [req.params.id];
if (req.instructor.role === 'instructor') {
scopeFilter = `AND s.cohort_id IN (SELECT cohort_id FROM cohort_instructors WHERE instructor_id = $2)`;
params.push(req.instructor.instructor_id);
}
const { rows } = await pool.query(
`SELECT s.assessment_session_id, s.student_id, st.display_name, s.status, s.started_at, s.submitted_at, s.expires_at, s.result
FROM assessment_sessions s JOIN students st ON st.student_id = s.student_id
WHERE s.assessment_definition_id = $1 ${scopeFilter} ORDER BY s.created_at DESC`,
params
);
res.json(rows);
});
admin.post('/api/admin/assessment-sessions/:sessionId/cancel', requireAnyInstructor, async (req, res) => {
const pool = getPool();
const { rows } = await pool.query(`SELECT * FROM assessment_sessions WHERE assessment_session_id = $1`, [req.params.sessionId]);
const session = rows[0];
if (!session) return res.status(404).json({ error: 'not found' });
if (req.instructor.role === 'instructor') {
const { rowCount } = await pool.query(`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`, [session.cohort_id, req.instructor.instructor_id]);
if (!rowCount) return res.status(403).json({ error: 'forbidden' });
}
await pool.query(`UPDATE assessment_sessions SET status = 'cancelled', submitted_at = COALESCE(submitted_at, now()) WHERE assessment_session_id = $1 AND status IN ('not_started','in_progress')`, [req.params.sessionId]);
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'assessment_session_cancel', resourceType: 'assessment_session', resourceId: req.params.sessionId, result: 'success', sourceIp: req.ip });
res.json({ ok: true });
});
};