feat(platform): pagination, admission control, cohort roles, version endpoint, structured assessment editor
- 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.
This commit is contained in:
@@ -7,9 +7,13 @@
|
||||
// can do, per the Phase 3B RBAC matrix.
|
||||
|
||||
const { computeCurriculumState } = require('./curriculum-rules');
|
||||
const { parsePagination, paginatedResponse } = require('./pagination'); // Phase 4.1 §16
|
||||
|
||||
const NEEDS_ATTENTION_FAIL_THRESHOLD = 3; // repeated validation failures on one scenario
|
||||
const NEEDS_ATTENTION_STALE_DAYS = 7; // no progress in this many days
|
||||
// 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;
|
||||
@@ -70,9 +74,13 @@ module.exports = function registerAdminLearningRoutes(app, deps) {
|
||||
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`,
|
||||
FROM assessment_sessions WHERE student_id = $1 ORDER BY created_at DESC LIMIT 50`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json({
|
||||
@@ -152,6 +160,21 @@ module.exports = function registerAdminLearningRoutes(app, deps) {
|
||||
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`);
|
||||
@@ -231,21 +254,32 @@ module.exports = function registerAdminLearningRoutes(app, deps) {
|
||||
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 { 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);
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user