// 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); })); };