- integration-routes.js: accepts student.contact_email, calls notifyForEnrollment on a genuinely new enrollment only (never on an idempotent replay — inherits Phase 5B's proven enrollment-level idempotency, no separate notification dedup needed). - admin-routes.js: create-student/convert/reset-password now enqueue the corresponding notification using the SAME token already minted synchronously (never mints twice, which would silently invalidate the token just handed back in the response). Fixed a real bug: request-password-reset would mint a reset token for a never-activated student, which makes no sense and would confuse them — now falls through to the generic anti-enumeration response. - admin-programs-routes.js: manual enrollment triggers the same notification decision as the website path. - student-auth-routes.js: /activate records a metric; /change-password and /reset-password both queue a password_changed security notice; /request-password-reset now actually delivers email (production still never returns the token — unchanged anti-enumeration/isProduction gating, just now with a real send behind it). - rbac.js: requireStudentRelationshipAccess/checkStudentRelationshipAccess — the notification visibility rule (cohort-shared or an enrollment they created), reusable beyond just enrollments. - server.js: wires notification-routes.js and starts the notification worker in main(). - docker-compose.dev.yml: PUBLIC_APP_ORIGIN, EMAIL_FROM_NAME, and a parametrized COOKIE_SECURE (was hard-commented-out; needed so e2e-phase5c.js can actually prove production gating).
610 lines
39 KiB
JavaScript
610 lines
39 KiB
JavaScript
// Instructor/Admin API — /api/admin/*. Every route requires an instructor session
|
|
// (instructorSession) plus a role check (rbac.js) — completely separate from student sessions
|
|
// (different cookie, different token namespace, see sessions.js). Student sessions can never
|
|
// call these routes.
|
|
//
|
|
// Permission shape (see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md for the full matrix):
|
|
// owner — everything, including instructor account lifecycle and audit log.
|
|
// admin — cohorts/students/curriculum/labs, global teaching visibility. No instructor
|
|
// account administration.
|
|
// instructor — same operational actions, but scoped to cohorts they're assigned to via
|
|
// cohort_instructors (requireCohortAccess / requireStudentCohortAccess).
|
|
//
|
|
// This module still owns every instructor-facing Provisioner interaction (prepare-labs, reset,
|
|
// destroy, events) — the Admin UI never talks to the Provisioner directly.
|
|
|
|
const { listInstructors, createInstructor, assertNotLastOwner } = require('./instructors');
|
|
const attemptsLib = require('./attempts'); // Phase 4.1 §13 — abandonment on instructor-driven reset/destroy
|
|
const { parsePagination, paginatedResponse } = require('./pagination'); // Phase 4.1 §16
|
|
const { issueCredentialToken } = require('./students-auth'); // Phase 5A §4/§5/§7
|
|
const { prepareLabsForCohort } = require('./prepare-labs'); // Phase 5A §21 — shared with admin-classroom-routes.js
|
|
const { parseStudentCsv } = require('./student-csv'); // Phase 5A §29
|
|
|
|
module.exports = function registerAdminRoutes(app, deps) {
|
|
const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps;
|
|
const {
|
|
requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess,
|
|
requireCohortWriteAccess, requireStudentCohortWriteAccess,
|
|
} = rbac;
|
|
|
|
// Tiny helper: every admin.<verb> call goes through instructorSession first, then whatever
|
|
// role/scope middleware is passed, then the handler (last function in the list — auto-wrapped
|
|
// so a thrown/rejected error reaches Express's error handler instead of crashing the process,
|
|
// same as every other async handler in this Express 4 app).
|
|
function wrap(fn) {
|
|
return (req, res, next) => {
|
|
try {
|
|
const result = fn(req, res, next);
|
|
if (result && typeof result.catch === 'function') result.catch(next);
|
|
} catch (err) { next(err); }
|
|
};
|
|
}
|
|
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)),
|
|
delete: (p, ...mw) => app.delete(p, instructorSession, ...mw.map(wrap)),
|
|
};
|
|
|
|
// ── Dashboard ────────────────────────────────────────────────────────────────────────
|
|
// Global for owner/admin. A plain 'instructor' only sees stats for cohorts they're assigned
|
|
// to — an empty cohort_instructors row set means an empty (not global) dashboard, not a 403,
|
|
// since the dashboard itself isn't a sensitive resource, just its contents.
|
|
admin.get('/api/admin/dashboard', requireAnyInstructor, async (req, res) => {
|
|
const pool = getPool();
|
|
const scoped = req.instructor.role === 'instructor';
|
|
const cohortFilter = scoped
|
|
? `AND e.cohort_id IN (SELECT cohort_id FROM cohort_instructors WHERE instructor_id = '${req.instructor.instructor_id}')`
|
|
: '';
|
|
// (string-interpolated only for a UUID we already validated came from req.instructor, never
|
|
// from request input — every other query below stays parameterized as usual.)
|
|
const [cohorts, students, envs] = await Promise.all([
|
|
pool.query(scoped
|
|
? `SELECT count(DISTINCT c.cohort_id)::int AS n FROM cohorts c JOIN cohort_instructors ci ON ci.cohort_id = c.cohort_id WHERE c.status = 'active' AND ci.instructor_id = $1`
|
|
: `SELECT count(*)::int AS n FROM cohorts WHERE status = 'active'`, scoped ? [req.instructor.instructor_id] : []),
|
|
scoped
|
|
? pool.query(`SELECT count(DISTINCT e.student_id)::int AS n FROM cohort_enrollments e JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id WHERE ci.instructor_id = $1 AND e.enrollment_status = 'enrolled'`, [req.instructor.instructor_id])
|
|
: pool.query(`SELECT count(*)::int AS n FROM students WHERE status = 'active'`),
|
|
pool.query(`SELECT status, count(*)::int AS n FROM environments GROUP BY status`),
|
|
]);
|
|
const envCounts = Object.fromEntries(envs.rows.map((r) => [r.status, r.n]));
|
|
const recentCompletions = await pool.query(
|
|
`SELECT sa.student_id, s.display_name, sa.scenario_id, sa.completed_at
|
|
FROM scenario_attempts sa JOIN students s ON s.student_id = sa.student_id
|
|
LEFT JOIN cohort_enrollments e ON e.student_id = s.student_id
|
|
WHERE sa.result = 'passed' ${cohortFilter}
|
|
ORDER BY sa.completed_at DESC LIMIT 10`
|
|
);
|
|
const recentFailures = await pool.query(
|
|
`SELECT student_id, lab_host_id, status, error_reason, started_at
|
|
FROM environments WHERE status = 'error' ORDER BY started_at DESC NULLS LAST LIMIT 10`
|
|
);
|
|
// Phase 4.1 §26 — best-effort: a Provisioner hiccup here shouldn't break the whole
|
|
// dashboard, capacity is just omitted (null) rather than the request failing.
|
|
const capacity = await provisioner.capacity().then((r) => r.status === 200 ? r.body : null).catch(() => null);
|
|
res.json({
|
|
active_cohorts: cohorts.rows[0].n,
|
|
total_students: students.rows[0].n,
|
|
labs: {
|
|
running: envCounts.running || 0,
|
|
provisioning: (envCounts.requested || 0) + (envCounts.provisioning || 0) + (envCounts.resetting || 0),
|
|
error: envCounts.error || 0,
|
|
idle_or_destroyed: envCounts.destroyed || 0,
|
|
},
|
|
capacity, // { current, max } — max is null when admission control is unconfigured (see resource-policy.yaml)
|
|
recent_completions: recentCompletions.rows,
|
|
recent_failures: scoped ? [] : recentFailures.rows, // env-level failures aren't cohort-scoped data; keep them admin/owner-only
|
|
});
|
|
});
|
|
|
|
// ── Students ─────────────────────────────────────────────────────────────────────────
|
|
// Phase 5A: explicit column list, not SELECT * — students now carries password_hash and
|
|
// credential_token_hash (migration 017), which must never reach an API response
|
|
// (docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §2). (password_hash IS NOT NULL) is
|
|
// exposed as a boolean, matching students-auth.js's publicStudent() shape, so the admin UI
|
|
// can tell "pilot, needs conversion" from "already credentialed" without ever seeing the hash.
|
|
admin.get('/api/admin/students', requireAnyInstructor, async (req, res) => {
|
|
const pool = getPool();
|
|
const { rows } = req.instructor.role === 'instructor'
|
|
? await pool.query(
|
|
`SELECT DISTINCT s.student_id, s.display_name, s.status, s.email, s.login_identifier,
|
|
(s.password_hash IS NOT NULL) AS has_credentials, s.created_at, s.updated_at, s.last_active_at, s.last_login_at
|
|
FROM students s
|
|
JOIN cohort_enrollments e ON e.student_id = s.student_id
|
|
JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
|
|
WHERE ci.instructor_id = $1 AND e.enrollment_status = 'enrolled' ORDER BY s.display_name`, [req.instructor.instructor_id])
|
|
: await pool.query(
|
|
`SELECT student_id, display_name, status, email, login_identifier,
|
|
(password_hash IS NOT NULL) AS has_credentials, created_at, updated_at, last_active_at, last_login_at
|
|
FROM students ORDER BY display_name`);
|
|
res.json(rows);
|
|
});
|
|
|
|
// Phase 5A §2/§3: email/login_identifier are both optional and independent — an older
|
|
// self-paced student gets email, a younger instructor-managed one gets login_identifier
|
|
// (any instructor-assigned safe string, not necessarily an email). Providing EITHER issues
|
|
// an activation token immediately (§5's "instructor creates account -> temporary credential"
|
|
// flow) and returns it ONCE in this response — the instructor relays it to the student
|
|
// out-of-band (print it, read it to them, hand it over); it is never stored in plaintext or
|
|
// logged anywhere (students-auth.js's issueCredentialToken only ever persists the hash).
|
|
// Providing neither creates a plain pilot row, unchanged from before Phase 5A.
|
|
admin.post('/api/admin/students', requireAdminOrOwner, async (req, res) => {
|
|
const { display_name, cohort_id, email, login_identifier } = req.body || {};
|
|
if (!display_name) return res.status(400).json({ error: 'display_name is required' });
|
|
const pool = getPool();
|
|
const { rows: countRows } = await pool.query('SELECT count(*)::int AS n FROM students');
|
|
const studentId = `student-${String(countRows[0].n + 1).padStart(3, '0')}`;
|
|
try {
|
|
await pool.query(
|
|
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4)`,
|
|
[studentId, display_name.trim(), email ? String(email).toLowerCase().trim() : null, login_identifier ? String(login_identifier).trim() : null]
|
|
);
|
|
} catch (e) {
|
|
if (e.code === '23505') return res.status(409).json({ error: 'email_or_identifier_in_use' });
|
|
throw e;
|
|
}
|
|
if (cohort_id) {
|
|
await pool.query(
|
|
`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
|
[cohort_id, studentId]
|
|
);
|
|
}
|
|
let activationToken = null;
|
|
if (email || login_identifier) {
|
|
const issued = await issueCredentialToken(pool, studentId, 'activation');
|
|
activationToken = issued.token;
|
|
// Phase 5C §5/§6 — reuses the SAME token just minted above (never mints a second one,
|
|
// which would silently invalidate the one this response is about to hand the admin for
|
|
// manual relay). A student with only login_identifier and no email has no deliverable
|
|
// address here — enqueue() records that as an already-failed row (never silently
|
|
// dropped), and the admin's manual-relay token in the response still works either way.
|
|
await require('./notifications').enqueue(pool, {
|
|
studentId, notificationType: 'activation', plaintextToken: activationToken,
|
|
metadata: { display_name: display_name.trim() },
|
|
});
|
|
}
|
|
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id, credentialed: !!activationToken } });
|
|
res.status(201).json({ student_id: studentId, display_name: display_name.trim(), activation_token: activationToken });
|
|
});
|
|
|
|
// §7 — converts an existing PILOT student (password_hash IS NULL) into a credentialed
|
|
// account: assigns email/login_identifier and issues an activation token, same mechanism as
|
|
// creation. Nothing about the student's existing progress/attempts/achievements/cohort
|
|
// membership is touched — student_id, the FK every other table hangs off of, never changes.
|
|
admin.post('/api/admin/students/:id/convert', requireAdminOrOwner, async (req, res) => {
|
|
const { email, login_identifier } = req.body || {};
|
|
if (!email && !login_identifier) return res.status(400).json({ error: 'email or login_identifier is required' });
|
|
const pool = getPool();
|
|
const { rows } = await pool.query(`SELECT student_id, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
|
|
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
|
if (rows[0].password_hash) return res.status(409).json({ error: 'already_converted', message: 'This student already has real credentials.' });
|
|
try {
|
|
await pool.query(
|
|
`UPDATE students SET email = COALESCE($1, email), login_identifier = COALESCE($2, login_identifier), updated_at = now() WHERE student_id = $3`,
|
|
[email ? String(email).toLowerCase().trim() : null, login_identifier ? String(login_identifier).trim() : null, req.params.id]
|
|
);
|
|
} catch (e) {
|
|
if (e.code === '23505') return res.status(409).json({ error: 'email_or_identifier_in_use' });
|
|
throw e;
|
|
}
|
|
const { token } = await issueCredentialToken(pool, req.params.id, 'activation');
|
|
// Phase 5C — same token, same "never mint twice" reasoning as create-student above.
|
|
const { rows: nameRows } = await pool.query(`SELECT display_name FROM students WHERE student_id = $1`, [req.params.id]);
|
|
await require('./notifications').enqueue(pool, {
|
|
studentId: req.params.id, notificationType: 'activation', plaintextToken: token,
|
|
metadata: { display_name: nameRows[0]?.display_name },
|
|
});
|
|
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_converted', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
|
res.json({ student_id: req.params.id, activation_token: token });
|
|
});
|
|
|
|
// §4 — for an ALREADY-credentialed account (unlike convert, above). Issues a fresh 'reset'
|
|
// token and revokes every existing session, same "instructor never needs to know the
|
|
// student's password" principle as activation. Phase 5C §8/§15: also queues a real
|
|
// password-reset email whenever the student has a deliverable address — the response still
|
|
// returns reset_token unchanged (a deliberate, documented exception to "admin cannot view
|
|
// token" for the population with no deliverable address at all — see notification-routes.js's
|
|
// header comment).
|
|
admin.post('/api/admin/students/:id/reset-password', requireAdminOrOwner, async (req, res) => {
|
|
const pool = getPool();
|
|
const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
|
|
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
|
if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_converted', message: 'Convert this student to a real account first.' });
|
|
const { token } = await issueCredentialToken(pool, req.params.id, 'reset');
|
|
await require('./sessions').revokeAllStudentSessions(pool, req.params.id);
|
|
await require('./notifications').enqueue(pool, {
|
|
studentId: req.params.id, notificationType: 'password_reset', plaintextToken: token,
|
|
metadata: { display_name: rows[0].display_name },
|
|
});
|
|
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_password_reset', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
|
res.json({ student_id: req.params.id, reset_token: token });
|
|
});
|
|
|
|
admin.post('/api/admin/students/:id/revoke-sessions', requireAdminOrOwner, async (req, res) => {
|
|
await require('./sessions').revokeAllStudentSessions(getPool(), req.params.id);
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_sessions_revoked', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
admin.get('/api/admin/students/:id/sessions', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
|
const sessionsList = await require('./sessions').listStudentSessions(getPool(), req.params.id);
|
|
res.json(sessionsList);
|
|
});
|
|
|
|
// §29 — CSV bulk roster import. Deliberately transactional: the WHOLE file is parsed and
|
|
// validated first; if any row is invalid, NOTHING is imported and every row-level error is
|
|
// returned, rather than silently importing the valid rows and skipping the rest (the safer
|
|
// default per docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §29 — a partial import that
|
|
// "mostly worked" is a worse failure mode than a rejected file with a clear error list).
|
|
// Expected columns: display_name, login_identifier (or email), cohort_id (optional). Never
|
|
// accepts a password/password_hash column — activation tokens are always generated server-side.
|
|
admin.post('/api/admin/students/import', requireAdminOrOwner, async (req, res) => {
|
|
const { csv } = req.body || {};
|
|
if (!csv || typeof csv !== 'string') return res.status(400).json({ error: 'csv (text) is required' });
|
|
const { rows: parsedRows, errors: parseErrors } = parseStudentCsv(csv);
|
|
if (parseErrors.length) return res.status(400).json({ error: 'invalid_csv', row_errors: parseErrors });
|
|
|
|
const pool = getPool();
|
|
const client = await pool.connect();
|
|
const created = [];
|
|
try {
|
|
await client.query('BEGIN');
|
|
const { rows: countRows } = await client.query('SELECT count(*)::int AS n FROM students');
|
|
let nextN = countRows[0].n + 1;
|
|
for (const row of parsedRows) {
|
|
const studentId = `student-${String(nextN++).padStart(3, '0')}`;
|
|
await client.query(
|
|
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4)`,
|
|
[studentId, row.display_name, row.email || null, row.login_identifier || null]
|
|
);
|
|
if (row.cohort_id) {
|
|
const { rowCount } = await client.query(`SELECT 1 FROM cohorts WHERE cohort_id = $1`, [row.cohort_id]);
|
|
if (!rowCount) throw Object.assign(new Error(`cohort_id '${row.cohort_id}' does not exist (row for ${row.display_name})`), { code: 'BAD_COHORT' });
|
|
await client.query(`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2)`, [row.cohort_id, studentId]);
|
|
}
|
|
let activationToken = null;
|
|
if (row.email || row.login_identifier) {
|
|
// issueCredentialToken only ever calls .query() on what it's given — a transaction
|
|
// client works identically to a pool here, so the whole import (including every
|
|
// token) commits or rolls back together with everything else.
|
|
const issued = await issueCredentialToken(client, studentId, 'activation');
|
|
activationToken = issued.token;
|
|
}
|
|
created.push({ student_id: studentId, display_name: row.display_name, activation_token: activationToken });
|
|
}
|
|
await client.query('COMMIT');
|
|
} catch (e) {
|
|
await client.query('ROLLBACK');
|
|
if (e.code === '23505') return res.status(409).json({ error: 'duplicate_email_or_identifier_in_file_or_existing_roster' });
|
|
if (e.code === 'BAD_COHORT') return res.status(400).json({ error: 'invalid_cohort', message: e.message });
|
|
throw e;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_csv_import', result: 'success', sourceIp: req.ip, metadata: { count: created.length } });
|
|
res.status(201).json({ created });
|
|
});
|
|
|
|
admin.patch('/api/admin/students/:id', requireAdminOrOwner, async (req, res) => {
|
|
const { display_name, status } = req.body || {};
|
|
const fields = [], values = [];
|
|
if (display_name) { fields.push(`display_name = $${fields.length + 1}`); values.push(display_name); }
|
|
if (status && ['active', 'inactive'].includes(status)) { fields.push(`status = $${fields.length + 1}`); values.push(status); }
|
|
if (!fields.length) return res.status(400).json({ error: 'nothing to update' });
|
|
fields.push(`updated_at = now()`);
|
|
values.push(req.params.id);
|
|
const { rowCount } = await getPool().query(`UPDATE students SET ${fields.join(', ')} WHERE student_id = $${values.length}`, values);
|
|
if (!rowCount) return res.status(404).json({ error: 'not found' });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
admin.get('/api/admin/students/:id/progress', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
|
const progress = await loadProgress(getPool(), req.params.id);
|
|
const path = curriculum.loadCurriculumPath();
|
|
const scenarios = curriculum.loadScenarios();
|
|
if (!path) return res.json({ progress });
|
|
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,
|
|
status: progress[sid]?.status || 'not_started',
|
|
})),
|
|
}));
|
|
res.json({ stages });
|
|
});
|
|
|
|
// Phase 4.1 §16 — was a flat LIMIT 100; now real pagination (see pagination.js).
|
|
admin.get('/api/admin/students/:id/attempts', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
|
const pool = getPool();
|
|
const pg = parsePagination(req);
|
|
const [{ rows }, { rows: countRows }] = await Promise.all([
|
|
pool.query(`SELECT * FROM scenario_attempts WHERE student_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, [req.params.id, pg.pageSize, pg.offset]),
|
|
pool.query(`SELECT count(*)::int AS n FROM scenario_attempts WHERE student_id = $1`, [req.params.id]),
|
|
]);
|
|
res.json(paginatedResponse(rows, countRows[0].n, pg));
|
|
});
|
|
|
|
admin.get('/api/admin/students/:id/environment', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
|
const { status, body } = await provisioner.listForStudent(req.params.id);
|
|
if (status !== 200) return res.status(502).json({ error: 'provisioner_unreachable' });
|
|
res.json(body);
|
|
});
|
|
|
|
admin.post('/api/admin/students/:id/environment/provision', requireAnyInstructor, requireStudentCohortWriteAccess('id'), async (req, res) => {
|
|
const { status, body } = await provisioner.provision(req.params.id);
|
|
res.status(status === 409 ? 200 : status).json(body);
|
|
});
|
|
|
|
admin.post('/api/admin/students/:id/cohort', requireAdminOrOwner, async (req, res) => {
|
|
const { cohort_id } = req.body || {};
|
|
if (!cohort_id) return res.status(400).json({ error: 'cohort_id required' });
|
|
await getPool().query(
|
|
`INSERT INTO cohort_enrollments (cohort_id, student_id, enrollment_status) VALUES ($1, $2, 'enrolled')
|
|
ON CONFLICT (cohort_id, student_id) DO UPDATE SET enrollment_status = 'enrolled'`,
|
|
[cohort_id, req.params.id]
|
|
);
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_change', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { cohort_id, op: 'add' } });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
admin.delete('/api/admin/students/:id/cohort/:cohortId', requireAdminOrOwner, async (req, res) => {
|
|
await getPool().query(
|
|
`UPDATE cohort_enrollments SET enrollment_status = 'removed' WHERE cohort_id = $1 AND student_id = $2`,
|
|
[req.params.cohortId, req.params.id]
|
|
);
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_change', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { cohort_id: req.params.cohortId, op: 'remove' } });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ── Cohorts ──────────────────────────────────────────────────────────────────────────
|
|
admin.get('/api/admin/cohorts', requireAnyInstructor, async (req, res) => {
|
|
const pool = getPool();
|
|
const { rows } = req.instructor.role === 'instructor'
|
|
? await pool.query(`
|
|
SELECT c.*, count(e.student_id) FILTER (WHERE e.enrollment_status = 'enrolled')::int AS student_count
|
|
FROM cohorts c
|
|
JOIN cohort_instructors ci ON ci.cohort_id = c.cohort_id AND ci.instructor_id = $1
|
|
LEFT JOIN cohort_enrollments e ON e.cohort_id = c.cohort_id
|
|
GROUP BY c.cohort_id ORDER BY c.created_at DESC`, [req.instructor.instructor_id])
|
|
: await pool.query(`
|
|
SELECT c.*, count(e.student_id) FILTER (WHERE e.enrollment_status = 'enrolled')::int AS student_count
|
|
FROM cohorts c LEFT JOIN cohort_enrollments e ON e.cohort_id = c.cohort_id
|
|
GROUP BY c.cohort_id ORDER BY c.created_at DESC`);
|
|
res.json(rows);
|
|
});
|
|
|
|
admin.post('/api/admin/cohorts', requireAdminOrOwner, async (req, res) => {
|
|
const { cohort_id, name, tier, start_date, end_date, seat_target } = req.body || {};
|
|
if (!cohort_id || !name) return res.status(400).json({ error: 'cohort_id and name are required' });
|
|
try {
|
|
await getPool().query(
|
|
`INSERT INTO cohorts (cohort_id, name, tier, start_date, end_date, seat_target) VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
[cohort_id, name, tier || null, start_date || null, end_date || null, seat_target || null]
|
|
);
|
|
} catch (e) {
|
|
if (e.code === '23505') return res.status(409).json({ error: 'cohort_id already exists' });
|
|
throw e;
|
|
}
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'cohort_create', resourceType: 'cohort', resourceId: cohort_id, result: 'success', sourceIp: req.ip });
|
|
res.status(201).json({ cohort_id });
|
|
});
|
|
|
|
admin.patch('/api/admin/cohorts/:id', requireAdminOrOwner, async (req, res) => {
|
|
const { name, tier, start_date, end_date, status, seat_target } = req.body || {};
|
|
const map = { name, tier, start_date, end_date, status, seat_target };
|
|
const fields = [], values = [];
|
|
for (const [k, v] of Object.entries(map)) {
|
|
if (v !== undefined) { fields.push(`${k} = $${fields.length + 1}`); values.push(v); }
|
|
}
|
|
if (!fields.length) return res.status(400).json({ error: 'nothing to update' });
|
|
fields.push('updated_at = now()');
|
|
values.push(req.params.id);
|
|
const { rowCount } = await getPool().query(`UPDATE cohorts SET ${fields.join(', ')} WHERE cohort_id = $${values.length}`, values);
|
|
if (!rowCount) return res.status(404).json({ error: 'not found' });
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'cohort_update', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: map });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Assign an instructor to a cohort (admin/owner only — this IS the scoping mechanism, so a
|
|
// plain instructor can't grant themselves or anyone else access to a cohort).
|
|
admin.post('/api/admin/cohorts/:id/instructors', requireAdminOrOwner, async (req, res) => {
|
|
const { instructor_id, role_in_cohort } = req.body || {};
|
|
if (!instructor_id) return res.status(400).json({ error: 'instructor_id required' });
|
|
// Phase 4.1 §19 — role_in_cohort is now actually enforced (rbac.js's
|
|
// requireCohortWriteAccess/requireStudentCohortWriteAccess), so it's validated here rather
|
|
// than accepting any string that would silently never match either check.
|
|
if (role_in_cohort && !['teacher', 'viewer'].includes(role_in_cohort)) {
|
|
return res.status(400).json({ error: 'role_in_cohort must be teacher or viewer' });
|
|
}
|
|
await getPool().query(
|
|
`INSERT INTO cohort_instructors (cohort_id, instructor_id, role_in_cohort) VALUES ($1, $2, $3)
|
|
ON CONFLICT (cohort_id, instructor_id) DO UPDATE SET role_in_cohort = EXCLUDED.role_in_cohort`,
|
|
[req.params.id, instructor_id, role_in_cohort || 'teacher']
|
|
);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
admin.get('/api/admin/cohorts/:id/students', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
|
|
const { rows } = await getPool().query(
|
|
`SELECT s.student_id, s.display_name, s.status, s.last_active_at, e.enrolled_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' ORDER BY s.display_name`,
|
|
[req.params.id]
|
|
);
|
|
res.json(rows);
|
|
});
|
|
|
|
admin.post('/api/admin/cohorts/:id/curriculum', requireAdminOrOwner, async (req, res) => {
|
|
const { curriculum_path } = req.body || {};
|
|
await getPool().query(
|
|
`INSERT INTO curriculum_assignments (cohort_id, curriculum_path) VALUES ($1, $2)`,
|
|
[req.params.id, curriculum_path || 'rootnode-path']
|
|
);
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'curriculum_assign', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { curriculum_path } });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
admin.get('/api/admin/cohorts/:id/progress', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
|
|
const pool = getPool();
|
|
const { rows: students } = await pool.query(
|
|
`SELECT student_id FROM cohort_enrollments WHERE cohort_id = $1 AND enrollment_status = 'enrolled'`,
|
|
[req.params.id]
|
|
);
|
|
const path = curriculum.loadCurriculumPath();
|
|
if (!path) return res.json({ stages: [] });
|
|
const stageTotals = path.stages.map((s) => ({ id: s.id, name: s.name, total: s.scenario_ids.length * students.length, completed: 0 }));
|
|
for (const { student_id } of students) {
|
|
const progress = await loadProgress(pool, student_id);
|
|
path.stages.forEach((stage, i) => {
|
|
stageTotals[i].completed += stage.scenario_ids.filter((sid) => progress[sid]?.status === 'completed').length;
|
|
});
|
|
}
|
|
res.json({
|
|
student_count: students.length,
|
|
stages: stageTotals.map((s) => ({ ...s, pct: s.total > 0 ? Math.round((s.completed / s.total) * 100) : 0 })),
|
|
});
|
|
});
|
|
|
|
// ── Classroom prep: Prepare Labs / Destroy Cohort Labs ──────────────────────────────────
|
|
admin.post('/api/admin/cohorts/:id/prepare-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
|
|
const results = await prepareLabsForCohort(getPool(), provisioner, req.params.id);
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'prepare_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: results.length } });
|
|
res.json({ results });
|
|
});
|
|
|
|
admin.post('/api/admin/cohorts/:id/destroy-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
|
|
const { rows: students } = await getPool().query(
|
|
`SELECT student_id FROM cohort_enrollments WHERE cohort_id = $1 AND enrollment_status = 'enrolled'`,
|
|
[req.params.id]
|
|
);
|
|
const results = await Promise.allSettled(students.map(async (s) => {
|
|
const { body } = await provisioner.listForStudent(s.student_id);
|
|
const active = body?.find((e) => e.status !== 'destroyed');
|
|
if (!active) return { student_id: s.student_id, status: 'none' };
|
|
// Phase 4.1 §13 — before the destroy, so any still-open attempt is honestly abandoned
|
|
// rather than left dangling once its environment no longer exists.
|
|
await attemptsLib.abandonOpenAttemptsForEnvironment(getPool(), active.environment_id);
|
|
await provisioner.destroy(active.environment_id);
|
|
return { student_id: s.student_id, status: 'destroyed' };
|
|
}));
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'destroy_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: students.length } });
|
|
// Academic progress is Postgres-side and untouched by any of this.
|
|
res.json({ results: results.map((r) => r.status === 'fulfilled' ? r.value : { status: 'error', error: r.reason?.message }) });
|
|
});
|
|
|
|
// ── Instructor environment controls (by raw environment id — admin/owner only; a plain
|
|
// instructor operates on Labs via the cohort/student-scoped routes above instead, since
|
|
// resolving a bare environment id back to a cohort would need an extra Provisioner round
|
|
// trip on every call — a documented simplification, see docs/PHASE3A...) ───────────────────
|
|
admin.post('/api/admin/environments/:id/reset', requireAdminOrOwner, async (req, res) => {
|
|
await attemptsLib.abandonOpenAttemptsForEnvironment(getPool(), req.params.id); // Phase 4.1 §13
|
|
const { status, body } = await provisioner.reset(req.params.id);
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'lab_reset', resourceType: 'environment', resourceId: req.params.id, result: status === 200 ? 'success' : 'failure', sourceIp: req.ip });
|
|
res.status(status).json(body);
|
|
});
|
|
|
|
admin.delete('/api/admin/environments/:id', requireAdminOrOwner, async (req, res) => {
|
|
await attemptsLib.abandonOpenAttemptsForEnvironment(getPool(), req.params.id); // Phase 4.1 §13
|
|
const { status, body } = await provisioner.destroy(req.params.id);
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'lab_destroy', resourceType: 'environment', resourceId: req.params.id, result: status === 200 ? 'success' : 'failure', sourceIp: req.ip });
|
|
res.status(status).json(body);
|
|
});
|
|
|
|
admin.get('/api/admin/environments/:id/events', requireAdminOrOwner, async (req, res) => {
|
|
const { status, body } = await provisioner.events(req.params.id);
|
|
res.status(status).json(body);
|
|
});
|
|
|
|
// ── Instructor accounts — owner only ────────────────────────────────────────────────────
|
|
admin.get('/api/admin/instructors', requireOwner, async (req, res) => {
|
|
res.json(await listInstructors(getPool()));
|
|
});
|
|
|
|
admin.post('/api/admin/instructors', requireOwner, async (req, res) => {
|
|
const { email, display_name, role, password } = req.body || {};
|
|
if (!email || !display_name || !password) return res.status(400).json({ error: 'email, display_name, and password are required' });
|
|
if (password.length < 12) return res.status(400).json({ error: 'password must be at least 12 characters' });
|
|
if (role && !['owner', 'admin', 'instructor'].includes(role)) return res.status(400).json({ error: 'invalid role' });
|
|
let instructor;
|
|
try {
|
|
instructor = await createInstructor(getPool(), { email, displayName: display_name, role, password });
|
|
} catch (e) {
|
|
if (e.code === '23505') return res.status(409).json({ error: 'email already in use' });
|
|
throw e;
|
|
}
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'instructor_create', resourceType: 'instructor', resourceId: instructor.instructor_id, result: 'success', sourceIp: req.ip, metadata: { email: instructor.email, role: instructor.role } });
|
|
const { password_hash, ...pub } = instructor;
|
|
res.status(201).json(pub);
|
|
});
|
|
|
|
admin.get('/api/admin/instructors/:id', requireOwner, async (req, res) => {
|
|
const { rows } = await getPool().query(`SELECT * FROM instructors WHERE instructor_id = $1`, [req.params.id]);
|
|
if (!rows[0]) return res.status(404).json({ error: 'not found' });
|
|
const { password_hash, ...pub } = rows[0];
|
|
const sessionsList = await require('./sessions').listInstructorSessions(getPool(), req.params.id);
|
|
res.json({ ...pub, sessions: sessionsList });
|
|
});
|
|
|
|
admin.patch('/api/admin/instructors/:id', requireOwner, async (req, res) => {
|
|
const { display_name, role, status } = req.body || {};
|
|
const pool = getPool();
|
|
if (role && !['owner', 'admin', 'instructor'].includes(role)) return res.status(400).json({ error: 'invalid role' });
|
|
if (status && !['active', 'inactive'].includes(status)) return res.status(400).json({ error: 'invalid status' });
|
|
|
|
const { rows: targetRows } = await pool.query(`SELECT * FROM instructors WHERE instructor_id = $1`, [req.params.id]);
|
|
const target = targetRows[0];
|
|
if (!target) return res.status(404).json({ error: 'not found' });
|
|
|
|
const demotingFromOwner = target.role === 'owner' && role && role !== 'owner';
|
|
const deactivatingOwner = target.role === 'owner' && status === 'inactive';
|
|
if (demotingFromOwner || deactivatingOwner) {
|
|
try { await assertNotLastOwner(pool, req.params.id); }
|
|
catch (e) { if (e.code === 'LAST_OWNER') return res.status(400).json({ error: 'last_owner', message: e.message }); throw e; }
|
|
}
|
|
|
|
const fields = [], values = [];
|
|
if (display_name) { fields.push(`display_name = $${fields.length + 1}`); values.push(display_name.trim()); }
|
|
if (role) { fields.push(`role = $${fields.length + 1}`); values.push(role); }
|
|
if (status) { fields.push(`status = $${fields.length + 1}`); values.push(status); }
|
|
if (!fields.length) return res.status(400).json({ error: 'nothing to update' });
|
|
fields.push('updated_at = now()');
|
|
values.push(req.params.id);
|
|
await pool.query(`UPDATE instructors SET ${fields.join(', ')} WHERE instructor_id = $${values.length}`, values);
|
|
|
|
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'instructor_update', resourceType: 'instructor', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { display_name, role, status } });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
admin.post('/api/admin/instructors/:id/revoke-sessions', requireOwner, async (req, res) => {
|
|
await require('./sessions').revokeAllInstructorSessions(getPool(), req.params.id);
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'sessions_revoked', resourceType: 'instructor', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
admin.post('/api/admin/instructors/:id/reset-password', requireOwner, async (req, res) => {
|
|
const { new_password } = req.body || {};
|
|
if (!new_password || new_password.length < 12) return res.status(400).json({ error: 'new_password must be at least 12 characters' });
|
|
const { hashPassword } = require('./auth/password');
|
|
const pool = getPool();
|
|
const { rowCount } = await pool.query(`UPDATE instructors SET password_hash = $1, updated_at = now() WHERE instructor_id = $2`, [hashPassword(new_password), req.params.id]);
|
|
if (!rowCount) return res.status(404).json({ error: 'not found' });
|
|
await require('./sessions').revokeAllInstructorSessions(pool, req.params.id); // force re-login everywhere with the new password
|
|
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'password_reset', resourceType: 'instructor', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ── Audit log — admin/owner ──────────────────────────────────────────────────────────────
|
|
// Phase 4.1 §16 — was a flat LIMIT 200 with no way to see anything older; now real
|
|
// server-side pagination (see pagination.js). Default page size (50) intentionally smaller
|
|
// than the old flat cap since this is now genuinely browsable instead of "the last 200 ever."
|
|
admin.get('/api/admin/audit-events', requireAdminOrOwner, async (req, res) => {
|
|
const pool = getPool();
|
|
const pg = parsePagination(req);
|
|
const [{ rows }, { rows: countRows }] = await Promise.all([
|
|
pool.query(`SELECT * FROM audit_events ORDER BY created_at DESC LIMIT $1 OFFSET $2`, [pg.pageSize, pg.offset]),
|
|
pool.query(`SELECT count(*)::int AS n FROM audit_events`),
|
|
]);
|
|
res.json(paginatedResponse(rows, countRows[0].n, pg));
|
|
});
|
|
};
|