- classroom.js: create/start/end/cancel (DB-level partial unique index guarantees at most one live classroom per cohort; app layer catches 23505 and returns a clean ALREADY_LIVE), join/ leave/heartbeat, derivePresence (pure, derived from last_seen_at at read time — no stored status to go stale), help-request create/acknowledge/resolve, announcements, current-activity (guidance only, never redirects a browser or marks work complete), append-only event log. - admin-classroom-routes.js: instructor-facing classroom API, RBAC via rbac.js's requireClassroomAccess/requireClassroomWriteAccess/requireHelpRequestWriteAccess (resolves through to the classroom's cohort, same owner/admin/instructor/viewer rules as everywhere else cohort-scoped). The dashboard route (GET /api/admin/classroom-sessions/:id) combines real Provisioner Lab state and real academic records with classroom.js's own data — no second Lab-state or academic-state system. - classroom-routes.js: student-facing API (live classroom, join/leave/heartbeat, help-request, announcements), requireEnrolledClassroom middleware. - prepare-labs.js: shared prepareLabsForCohort, extracted so the existing cohort-level and new classroom-level Prepare Labs routes share one implementation; maps Phase 4.1 admission control's CAPACITY_EXCEEDED into a capacity_waiting per-student result.
176 lines
10 KiB
JavaScript
176 lines
10 KiB
JavaScript
// 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 });
|
|
}
|
|
});
|
|
};
|