Files
rootnode-academy/platform/api/src/classroom-routes.js
T
ced4568 ee8ccb2ab6 feat(classroom): remote classroom backend — lifecycle, presence, help queue, dashboard
- 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.
2026-08-25 01:19:33 -05:00

76 lines
4.1 KiB
JavaScript

// Student-facing classroom API — Phase 5A §22/§24. Every route here requires studentSession
// (session-derived student_id, never trusted from the request body/params — same rule as
// everywhere else a student acts) AND that the student is actually enrolled in the classroom's
// cohort (cohort_enrollments) — a student can never join/view/act on a classroom for a cohort
// they don't belong to, regardless of whether they know or can guess its id.
const classroom = require('./classroom');
module.exports = function registerClassroomRoutes(app, deps) {
const { studentSession, getPool, curriculum } = deps;
function wrap(fn) {
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
}
// Resolves the classroom AND checks cohort enrollment in one place — every route below uses
// this instead of repeating the join. Sets req.classroom on success.
async function requireEnrolledClassroom(req, res, next) {
const classroomRow = await classroom.getClassroom(getPool(), req.params.id);
if (!classroomRow) return res.status(404).json({ error: 'not found' });
const { rowCount } = await getPool().query(
`SELECT 1 FROM cohort_enrollments WHERE cohort_id = $1 AND student_id = $2 AND enrollment_status = 'enrolled'`,
[classroomRow.cohort_id, req.studentId]
);
if (!rowCount) return res.status(403).json({ error: 'forbidden' });
req.classroom = classroomRow;
next();
}
// §22 — "LIVE NOW" banner on the student dashboard. Null if nothing is live for any cohort
// this student belongs to (deliberately not "their one cohort" — §27 self-paced compatible).
app.get('/api/classroom/live', studentSession, wrap(async (req, res) => {
const live = await classroom.getLiveClassroomForStudent(getPool(), req.studentId);
res.json({ classroom: live });
}));
app.get('/api/classroom/:id', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
const [announcements, own] = await Promise.all([
classroom.listAnnouncements(getPool(), req.params.id),
getPool().query(`SELECT * FROM help_requests WHERE classroom_session_id = $1 AND student_id = $2 ORDER BY created_at DESC LIMIT 5`, [req.params.id, req.studentId]),
]);
res.json({ classroom: req.classroom, announcements, my_help_requests: own.rows });
}));
app.get('/api/classroom/:id/announcements', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
res.json(await classroom.listAnnouncements(getPool(), req.params.id));
}));
app.post('/api/classroom/:id/join', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
if (req.classroom.status !== 'live') return res.status(409).json({ error: 'not_live', message: 'This classroom is not currently live.' });
const participant = await classroom.joinClassroom(getPool(), req.params.id, req.studentId);
res.json(participant);
}));
app.post('/api/classroom/:id/leave', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
await classroom.leaveClassroom(getPool(), req.params.id, req.studentId);
res.json({ ok: true });
}));
// §15 — throttled server-side (classroom.js's heartbeat only writes if the last recorded
// beat is old enough), so the frontend can just call this every 20-30s without either side
// needing to track timing beyond "did the interval fire."
app.post('/api/classroom/:id/heartbeat', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
await classroom.heartbeat(getPool(), req.params.id, req.studentId);
res.json({ ok: true });
}));
// §18 — no chat, no video, no remote shell takeover: exactly one action (raise a flag with
// an optional category/message), visible only to instructors managing this cohort.
app.post('/api/classroom/:id/help-request', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
const { category, message } = req.body || {};
const created = await classroom.createHelpRequest(getPool(), req.params.id, req.studentId, category, message);
res.status(201).json(created);
}));
};