// Instructor-facing classroom API — Phase 5A §14/§21. RBAC via rbac.js's // requireClassroomAccess/requireClassroomWriteAccess/requireHelpRequestWriteAccess (resolve // through to the classroom's cohort, same owner/admin/instructor/viewer rules as everything // else cohort-scoped — see docs/PHASE4_1_PREPRODUCTION_HARDENING.md §19 for role_in_cohort). // // The dashboard aggregation (GET /api/admin/classroom-sessions/:id) is the one place in this // file that reaches beyond classroom.js — it pulls in Provisioner Lab state (§16) and Phase 3B // academic records (§17) alongside classroom.js's own participant/presence/help-queue data, // because that combined view is the entire point of a live classroom dashboard. Neither the // Provisioner nor the academic-records shape is duplicated here — both are read exactly the // way admin-routes.js/admin-learning-routes.js already read them. const classroom = require('./classroom'); const { prepareLabsForCohort } = require('./prepare-labs'); module.exports = function registerAdminClassroomRoutes(app, deps) { const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps; const { requireAnyInstructor, requireCohortAccess, requireCohortWriteAccess, requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess } = rbac; function wrap(fn) { return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; } const admin = { get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)), post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)), }; // Maps classroom.js's INVALID_STATE/NOT_FOUND/ALREADY_LIVE error codes to HTTP status — // every lifecycle route below funnels through this instead of repeating the same try/catch. function classroomErrorStatus(code) { if (code === 'NOT_FOUND') return 404; if (code === 'ALREADY_LIVE' || code === 'INVALID_STATE') return 409; return 500; } admin.post('/api/admin/cohorts/:id/classroom-sessions', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => { const { title, description, scheduled_start, scheduled_end, meeting_url } = req.body || {}; if (!title) return res.status(400).json({ error: 'title is required' }); const created = await classroom.createClassroom(getPool(), { cohortId: req.params.id, title, description, scheduledStart: scheduled_start, scheduledEnd: scheduled_end, meetingUrl: meeting_url, createdBy: req.instructor.instructor_id, }); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_created', resourceType: 'classroom_session', resourceId: created.classroom_session_id, result: 'success', sourceIp: req.ip }); res.status(201).json(created); }); admin.get('/api/admin/cohorts/:id/classroom-sessions', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => { res.json(await classroom.listClassroomsForCohort(getPool(), req.params.id)); }); // The dashboard itself — §14's "ROOTNODE LIVE CLASSROOM" view. Built from real available // data, not a fixed mock: participant presence (classroom.js), each participant's Lab status // (Provisioner, reusing the SAME environment-state values everywhere else in the app uses — // no second state system, §16), and a lightweight per-student learning snapshot (§17) — // current stage, most recent scenario worked, its status/validation-failure count, hints // used. Never inspects terminal command history. admin.get('/api/admin/classroom-sessions/:id', requireAnyInstructor, requireClassroomAccess('id'), async (req, res) => { const pool = getPool(); const classroomRow = await classroom.getClassroom(pool, req.params.id); const [participants, announcements, helpRequests] = await Promise.all([ classroom.listParticipants(pool, req.params.id), classroom.listAnnouncements(pool, req.params.id), classroom.listHelpRequests(pool, req.params.id), ]); const path = curriculum.loadCurriculumPath(); const students = await Promise.all(participants.map(async (p) => { const [envRes, progress, lastAttempt] = await Promise.all([ provisioner.listForStudent(p.student_id), loadProgress(pool, p.student_id), pool.query( `SELECT scenario_id, status, validation_fail_count, hint_count FROM scenario_attempts WHERE student_id = $1 ORDER BY started_at DESC NULLS LAST LIMIT 1`, [p.student_id] ), ]); const env = envRes.body?.find((e) => e.status !== 'destroyed'); const completed = Object.values(progress).filter((pr) => pr.status === 'completed').length; const currentStage = path ? path.stages.find((s) => (s.scenario_ids || []).some((sid) => progress[sid]?.status !== 'completed'))?.id || null : null; return { ...p, lab_status: env ? env.status : 'none', // 'none'|'requested'|'provisioning'|'running'|'resetting'|'error'|'destroying'|'destroyed' — the real Provisioner enum, unchanged current_stage: currentStage, current_scenario: lastAttempt.rows[0]?.scenario_id || null, attempt_status: lastAttempt.rows[0]?.status || null, validation_failures: lastAttempt.rows[0]?.validation_fail_count || 0, hints_used: lastAttempt.rows[0]?.hint_count || 0, scenarios_completed: completed, }; })); res.json({ classroom: classroomRow, students, announcements, help_requests: helpRequests }); }); admin.post('/api/admin/classroom-sessions/:id/start', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => { try { const updated = await classroom.startClassroom(getPool(), req.params.id, req.instructor.instructor_id); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_started', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip }); res.json(updated); } catch (e) { res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message }); } }); admin.post('/api/admin/classroom-sessions/:id/end', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => { try { const updated = await classroom.endClassroom(getPool(), req.params.id, req.instructor.instructor_id); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_ended', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip }); res.json(updated); } catch (e) { res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message }); } }); admin.post('/api/admin/classroom-sessions/:id/cancel', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => { try { const updated = await classroom.cancelClassroom(getPool(), req.params.id, req.instructor.instructor_id); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_cancelled', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip }); res.json(updated); } catch (e) { res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message }); } }); // §21 — the same underlying prepareLabsForCohort() the cohort-level route uses, just entered // from the classroom dashboard and scoped to req.classroomCohortId (set by // requireClassroomWriteAccess). Respects admission control exactly the same way (Phase 4.1 // §26) — a full host surfaces as lab_status: 'capacity_waiting' per student, not a crash. admin.post('/api/admin/classroom-sessions/:id/prepare-labs', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => { const results = await prepareLabsForCohort(getPool(), provisioner, req.classroomCohortId); await classroom.emitEvent(getPool(), req.params.id, 'instructor', req.instructor.instructor_id, 'labs_prepared', { student_count: results.length }); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_labs_prepared', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: results.length } }); res.json({ results }); }); admin.post('/api/admin/classroom-sessions/:id/activity', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => { const { scenario_id } = req.body || {}; try { const updated = await classroom.setCurrentActivity(getPool(), req.params.id, scenario_id, req.instructor.instructor_id); res.json(updated); } catch (e) { res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message }); } }); admin.post('/api/admin/classroom-sessions/:id/announcements', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => { const { message } = req.body || {}; if (!message || !message.trim()) return res.status(400).json({ error: 'message is required' }); const created = await classroom.postAnnouncement(getPool(), req.params.id, req.instructor.instructor_id, message.trim()); res.status(201).json(created); }); admin.get('/api/admin/classroom-sessions/:id/help-requests', requireAnyInstructor, requireClassroomAccess('id'), async (req, res) => { res.json(await classroom.listHelpRequests(getPool(), req.params.id, req.query.status)); }); admin.post('/api/admin/help-requests/:id/acknowledge', requireAnyInstructor, requireHelpRequestWriteAccess('id'), async (req, res) => { try { const updated = await classroom.acknowledgeHelpRequest(getPool(), req.params.id, req.instructor.instructor_id); res.json(updated); } catch (e) { res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message }); } }); admin.post('/api/admin/help-requests/:id/resolve', requireAnyInstructor, requireHelpRequestWriteAccess('id'), async (req, res) => { const { note } = req.body || {}; try { const updated = await classroom.resolveHelpRequest(getPool(), req.params.id, req.instructor.instructor_id, note); res.json(updated); } catch (e) { res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message }); } }); };