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