// 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 { parsePagination, paginatedResponse } = require('./pagination'); // Phase 4.1 §16 // Phase 4.1 §17 — configurable via env (environment-backed policy, matching the pattern // sessions.js already uses for TTLs), not a database-backed settings table — that's more // machinery than two numbers need. Sensible defaults if unset. const NEEDS_ATTENTION_FAIL_THRESHOLD = Number(process.env.NEEDS_ATTENTION_FAIL_THRESHOLD || 3); // repeated validation failures on one scenario const NEEDS_ATTENTION_STALE_DAYS = Number(process.env.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] ); // Phase 4.1 §16 — this is a per-student summary embedded in a wider dashboard view, not a // standalone history list (that's /api/admin/assessments/:id/sessions, which got real // pagination above) — a bounded LIMIT is the appropriate fix here, not a second paginator // inside an already-composite response. 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 LIMIT 50`, [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); }); // Phase 4.1 §18 — the scenario catalog (stage -> scenario id/title, no student-specific // status) the structured assessment editor picks from, instead of an instructor hand-typing // scenario ids into raw JSON. Same source data as the per-student progress view // (admin-routes.js's /api/admin/students/:id/progress), just without the progress overlay. admin.get('/api/admin/scenario-catalog', requireAnyInstructor, async (req, res) => { const path = curriculum.loadCurriculumPath(); const scenarios = curriculum.loadScenarios(); if (!path) return res.json({ stages: [] }); const stages = path.stages.map((stage) => ({ id: stage.id, name: stage.name, scenarios: stage.scenario_ids.map((sid) => ({ id: sid, title: scenarios.find((s) => s.id === sid)?.title || sid })), })); res.json({ stages }); }); // ── 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 }); }); // Phase 4.1 §16 — was fully unbounded (no LIMIT at all — the one endpoint here that could // genuinely grow into thousands of rows over a semester of exam sessions). Now real // pagination, same shape as audit-events/student-attempts. admin.get('/api/admin/assessments/:id/sessions', requireAnyInstructor, async (req, res) => { const pool = getPool(); const pg = parsePagination(req); 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 pageParams = [...params, pg.pageSize, pg.offset]; const [{ rows }, { rows: countRows }] = await Promise.all([ 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 LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, pageParams ), pool.query( `SELECT count(*)::int AS n FROM assessment_sessions s WHERE s.assessment_definition_id = $1 ${scopeFilter}`, params ), ]); res.json(paginatedResponse(rows, countRows[0].n, pg)); }); 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 }); }); };