feat(platform-api): split lightweight platform service from Lab image
New platform/api/ service owns students, cohorts, curriculum, progress, scenario attempts, and student/admin sessions. No k3s, no --privileged, no terminal runtime — starts instantly, unlike the old identity service which booted an unused k3s cluster just to serve roster/progress APIs. Provisioner calls (student and admin) are consolidated here too, in provisioner-client.js, so it's the only service that talks to the Provisioner directly.
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
// Instructor/Admin API — /api/admin/*. Every route here requires an admin session
|
||||
// (adminSession middleware), completely separate from student sessions (different cookie,
|
||||
// different token namespace, see sessions.js). Student sessions can never call these routes;
|
||||
// admin sessions can never be used for /api/environment/* student routes either, since those
|
||||
// require a *student* session specifically.
|
||||
//
|
||||
// This module owns every instructor-facing Provisioner interaction (prepare-labs, reset,
|
||||
// destroy, events) — the Admin UI never talks to the Provisioner directly, same boundary as
|
||||
// the student path (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §12).
|
||||
|
||||
module.exports = function registerAdminRoutes(app, deps) {
|
||||
const { adminSession, verifyAdminPassword, sessions, ADMIN_COOKIE, provisioner, curriculum, getPool, loadProgress } = deps;
|
||||
|
||||
// ── Admin auth ───────────────────────────────────────────────────────────────────────
|
||||
app.post('/api/admin/login', (req, res) => {
|
||||
const { password } = req.body || {};
|
||||
if (!verifyAdminPassword(password)) return res.status(401).json({ error: 'invalid_credentials' });
|
||||
const token = sessions.createAdminSession();
|
||||
res.append('Set-Cookie', `${ADMIN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=14400`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/admin/logout', (req, res) => {
|
||||
const header = req.headers.cookie || '';
|
||||
const token = Object.fromEntries(header.split(';').map((p) => p.trim().split('=')))[ADMIN_COOKIE];
|
||||
sessions.destroyAdminSession(token);
|
||||
res.append('Set-Cookie', `${ADMIN_COOKIE}=; Path=/; HttpOnly; Max-Age=0`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/admin/whoami', adminSession, (req, res) => res.json({ admin: true }));
|
||||
|
||||
const admin = express_router_guard(app, adminSession);
|
||||
|
||||
// ── Dashboard ────────────────────────────────────────────────────────────────────────
|
||||
admin.get('/api/admin/dashboard', async (req, res) => {
|
||||
const pool = getPool();
|
||||
const [cohorts, students, envs] = await Promise.all([
|
||||
pool.query(`SELECT count(*)::int AS n FROM cohorts WHERE status = 'active'`),
|
||||
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
|
||||
WHERE sa.result = 'passed' 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`
|
||||
);
|
||||
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,
|
||||
},
|
||||
recent_completions: recentCompletions.rows,
|
||||
recent_failures: recentFailures.rows,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Students ─────────────────────────────────────────────────────────────────────────
|
||||
admin.get('/api/admin/students', async (req, res) => {
|
||||
const { rows } = await getPool().query(`SELECT * FROM students ORDER BY display_name`);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
admin.post('/api/admin/students', async (req, res) => {
|
||||
const { display_name, cohort_id } = 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')}`;
|
||||
await pool.query(`INSERT INTO students (student_id, display_name) VALUES ($1, $2)`, [studentId, display_name.trim()]);
|
||||
if (cohort_id) {
|
||||
await pool.query(
|
||||
`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[cohort_id, studentId]
|
||||
);
|
||||
}
|
||||
res.status(201).json({ student_id: studentId, display_name: display_name.trim() });
|
||||
});
|
||||
|
||||
admin.patch('/api/admin/students/:id', 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', 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 });
|
||||
});
|
||||
|
||||
admin.get('/api/admin/students/:id/attempts', async (req, res) => {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT * FROM scenario_attempts WHERE student_id = $1 ORDER BY created_at DESC LIMIT 100`, [req.params.id]
|
||||
);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
admin.get('/api/admin/students/:id/environment', 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', 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', 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]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
admin.delete('/api/admin/students/:id/cohort/:cohortId', 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]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Cohorts ──────────────────────────────────────────────────────────────────────────
|
||||
admin.get('/api/admin/cohorts', async (req, res) => {
|
||||
const { rows } = await getPool().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', 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;
|
||||
}
|
||||
res.status(201).json({ cohort_id });
|
||||
});
|
||||
|
||||
admin.patch('/api/admin/cohorts/:id', 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' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
admin.get('/api/admin/cohorts/:id/students', 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', 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']
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
admin.get('/api/admin/cohorts/:id/progress', 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', async (req, res) => {
|
||||
const { rows: students } = await getPool().query(
|
||||
`SELECT s.student_id, s.display_name FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id
|
||||
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' AND s.status = 'active'`,
|
||||
[req.params.id]
|
||||
);
|
||||
// Provision concurrently — each is a real k3s boot (1-3 min); sequential would make a
|
||||
// 20-student cohort prep take the better part of an hour. Promise.allSettled so one
|
||||
// student's failure doesn't block reporting on the rest.
|
||||
const results = await Promise.allSettled(students.map(async (s) => {
|
||||
const existing = await provisioner.listForStudent(s.student_id);
|
||||
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
|
||||
if (active) return { student_id: s.student_id, display_name: s.display_name, status: active.status };
|
||||
const { status, body } = await provisioner.provision(s.student_id);
|
||||
if (status !== 201) return { student_id: s.student_id, display_name: s.display_name, status: 'error', error: body?.error };
|
||||
return { student_id: s.student_id, display_name: s.display_name, status: body.status };
|
||||
}));
|
||||
res.json({
|
||||
results: results.map((r, i) => r.status === 'fulfilled' ? r.value : { student_id: students[i].student_id, display_name: students[i].display_name, status: 'error', error: r.reason?.message }),
|
||||
});
|
||||
});
|
||||
|
||||
admin.post('/api/admin/cohorts/:id/destroy-labs', 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' };
|
||||
await provisioner.destroy(active.environment_id);
|
||||
return { student_id: s.student_id, status: 'destroyed' };
|
||||
}));
|
||||
// Academic progress is Postgres-side and untouched by any of this — destroying compute
|
||||
// never touches the students/progress/scenario_attempts tables.
|
||||
res.json({ results: results.map((r) => r.status === 'fulfilled' ? r.value : { status: 'error', error: r.reason?.message }) });
|
||||
});
|
||||
|
||||
// ── Instructor environment controls ─────────────────────────────────────────────────────
|
||||
admin.post('/api/admin/environments/:id/reset', async (req, res) => {
|
||||
const { status, body } = await provisioner.reset(req.params.id);
|
||||
res.status(status).json(body);
|
||||
});
|
||||
|
||||
admin.delete('/api/admin/environments/:id', async (req, res) => {
|
||||
const { status, body } = await provisioner.destroy(req.params.id);
|
||||
res.status(status).json(body);
|
||||
});
|
||||
|
||||
admin.get('/api/admin/environments/:id/events', async (req, res) => {
|
||||
const { status, body } = await provisioner.events(req.params.id);
|
||||
res.status(status).json(body);
|
||||
});
|
||||
};
|
||||
|
||||
// Tiny helper so every admin.<verb>(...) call below doesn't need `adminSession` repeated
|
||||
// as a second argument by hand — same effect, less visual noise.
|
||||
function express_router_guard(app, adminSession) {
|
||||
return {
|
||||
get: (p, h) => app.get(p, adminSession, h),
|
||||
post: (p, h) => app.post(p, adminSession, h),
|
||||
patch: (p, h) => app.patch(p, adminSession, h),
|
||||
delete: (p, h) => app.delete(p, adminSession, h),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user