- Real server-side pagination (platform/api/src/pagination.js) on the audit log (was a flat LIMIT 200), student attempt history (was LIMIT 100), and assessment session history (was fully unbounded - the one genuine growth risk found). Frontend updated (AuditView, AssessmentsView, StudentsView). - Admission control: resource-policy.yaml's new (disabled-by-default) admission_control.max_concurrent_labs, checked before touching Docker at all, returns a clean 503 CAPACITY_EXCEEDED instead of failing mid-provision. Relayed as a clean student-facing message, never a raw 500. New GET /capacity endpoint feeds the instructor dashboard. - cohort_instructors.role_in_cohort (existed since Phase 2B, never read) is now enforced: teacher vs viewer, rbac.js's requireCohortWriteAccess/requireStudentCohortWriteAccess. - GET /api/version - app version, deployed Git SHA, latest migration as a schema-version stand-in. - Structured assessment authoring UI (AssessmentsView.jsx's PoolSpecEditor) replaces the raw-JSON pool_spec textarea, backed by a new GET /api/admin/scenario-catalog endpoint. Owner-only raw-JSON fallback remains for anything the structured editor doesn't cover. - NEEDS_ATTENTION_FAIL_THRESHOLD/STALE_DAYS moved to env-var configuration. Full regression suite (e2e-phase2a/2b/3a/3b, 67/67) re-run clean after these changes; frontend production build confirmed clean.
299 lines
19 KiB
JavaScript
299 lines
19 KiB
JavaScript
// 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 });
|
|
});
|
|
};
|