From ee8ccb2ab62b41e9a229ee7db7d1e42d68ced0ab Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 25 Aug 2026 01:19:33 -0500 Subject: [PATCH] =?UTF-8?q?feat(classroom):=20remote=20classroom=20backend?= =?UTF-8?q?=20=E2=80=94=20lifecycle,=20presence,=20help=20queue,=20dashboa?= =?UTF-8?q?rd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- platform/api/src/admin-classroom-routes.js | 175 +++++++++++++++ platform/api/src/classroom-routes.js | 75 +++++++ platform/api/src/classroom.js | 242 +++++++++++++++++++++ platform/api/src/prepare-labs.js | 27 +++ 4 files changed, 519 insertions(+) create mode 100644 platform/api/src/admin-classroom-routes.js create mode 100644 platform/api/src/classroom-routes.js create mode 100644 platform/api/src/classroom.js create mode 100644 platform/api/src/prepare-labs.js diff --git a/platform/api/src/admin-classroom-routes.js b/platform/api/src/admin-classroom-routes.js new file mode 100644 index 0000000..8173476 --- /dev/null +++ b/platform/api/src/admin-classroom-routes.js @@ -0,0 +1,175 @@ +// 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 }); + } + }); +}; diff --git a/platform/api/src/classroom-routes.js b/platform/api/src/classroom-routes.js new file mode 100644 index 0000000..d2395f3 --- /dev/null +++ b/platform/api/src/classroom-routes.js @@ -0,0 +1,75 @@ +// 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); + })); +}; diff --git a/platform/api/src/classroom.js b/platform/api/src/classroom.js new file mode 100644 index 0000000..fe568af --- /dev/null +++ b/platform/api/src/classroom.js @@ -0,0 +1,242 @@ +// Classroom session lifecycle — Phase 5A §11-§20. A classroom session is a live teaching event, +// distinct from a cohort (standing roster), an assessment session (one exam attempt), a Lab +// environment (disposable infra), or a login session. This module owns the pure +// classroom_sessions/classroom_participants/classroom_events/classroom_announcements/ +// help_requests CRUD and lifecycle rules; route files (admin-classroom-routes.js, +// classroom-routes.js) own RBAC/session/HTTP shape and the dashboard aggregation that pulls in +// Provisioner Lab state and Phase 3B academic records (this module doesn't know about either). + +const PRESENCE_ONLINE_MS = 45 * 1000; // ~1.5x the 20-30s heartbeat interval the frontend uses +const PRESENCE_RECENTLY_ACTIVE_MS = 5 * 60 * 1000; +const HEARTBEAT_WRITE_THROTTLE_MS = 15 * 1000; // §15 "avoid excessive database writes" — coalesce heartbeats closer together than this into a no-op + +async function emitEvent(pool, classroomSessionId, actorType, actorId, eventType, metadata) { + await pool.query( + `INSERT INTO classroom_events (classroom_session_id, actor_type, actor_id, event_type, metadata) + VALUES ($1, $2, $3, $4, $5)`, + [classroomSessionId, actorType, actorId ? String(actorId) : null, eventType, metadata ? JSON.stringify(metadata) : null] + ); +} + +async function createClassroom(pool, { cohortId, title, description, scheduledStart, scheduledEnd, meetingUrl, createdBy }) { + const { rows } = await pool.query( + `INSERT INTO classroom_sessions (cohort_id, title, description, scheduled_start, scheduled_end, meeting_url, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, + [cohortId, title, description || null, scheduledStart || null, scheduledEnd || null, meetingUrl || null, createdBy] + ); + const classroom = rows[0]; + await emitEvent(pool, classroom.classroom_session_id, 'instructor', createdBy, 'created', { title }); + return classroom; +} + +async function getClassroom(pool, classroomSessionId) { + const { rows } = await pool.query(`SELECT * FROM classroom_sessions WHERE classroom_session_id = $1`, [classroomSessionId]); + return rows[0] || null; +} + +async function listClassroomsForCohort(pool, cohortId) { + const { rows } = await pool.query( + `SELECT * FROM classroom_sessions WHERE cohort_id = $1 ORDER BY COALESCE(scheduled_start, created_at) DESC`, + [cohortId] + ); + return rows; +} + +// §13 lifecycle: scheduled -> live -> ended, or -> cancelled from scheduled/live. The DB's +// partial unique index (idx_classroom_sessions_one_live_per_cohort) is the actual enforcement +// for "at most one live classroom per cohort" — this pre-check just gives a clean error +// instead of a raw constraint-violation message. +async function startClassroom(pool, classroomSessionId, instructorId) { + const classroom = await getClassroom(pool, classroomSessionId); + if (!classroom) throw Object.assign(new Error('classroom not found'), { code: 'NOT_FOUND' }); + if (classroom.status !== 'scheduled') throw Object.assign(new Error(`cannot start a classroom in status '${classroom.status}'`), { code: 'INVALID_STATE' }); + try { + const { rows } = await pool.query( + `UPDATE classroom_sessions SET status = 'live', started_at = now(), updated_at = now() WHERE classroom_session_id = $1 RETURNING *`, + [classroomSessionId] + ); + await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'started', {}); + return rows[0]; + } catch (e) { + if (e.code === '23505') throw Object.assign(new Error('this cohort already has a live classroom'), { code: 'ALREADY_LIVE' }); + throw e; + } +} + +// Ending a classroom is purely a status change — never touches Labs, progress, attempts, or +// any academic data (§13/§26). Labs are only ever destroyed by an explicit, separate action +// (the existing cohort Destroy Labs / Prepare Labs machinery), never implicitly by this. +async function endClassroom(pool, classroomSessionId, instructorId) { + const { rows } = await pool.query( + `UPDATE classroom_sessions SET status = 'ended', ended_at = now(), updated_at = now() + WHERE classroom_session_id = $1 AND status = 'live' RETURNING *`, + [classroomSessionId] + ); + if (!rows.length) throw Object.assign(new Error('classroom not found or not live'), { code: 'INVALID_STATE' }); + await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'ended', {}); + return rows[0]; +} + +async function cancelClassroom(pool, classroomSessionId, instructorId) { + const { rows } = await pool.query( + `UPDATE classroom_sessions SET status = 'cancelled', updated_at = now() + WHERE classroom_session_id = $1 AND status IN ('scheduled', 'live') RETURNING *`, + [classroomSessionId] + ); + if (!rows.length) throw Object.assign(new Error('classroom not found or already ended/cancelled'), { code: 'INVALID_STATE' }); + await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'cancelled', {}); + return rows[0]; +} + +// §20 — guidance only. Never redirects a browser, never marks anything complete; students just +// see this value and choose to open that scenario themselves (see classroom-routes.js). +async function setCurrentActivity(pool, classroomSessionId, scenarioId, instructorId) { + const { rows } = await pool.query( + `UPDATE classroom_sessions SET current_scenario_id = $1, updated_at = now() WHERE classroom_session_id = $2 RETURNING *`, + [scenarioId || null, classroomSessionId] + ); + if (!rows.length) throw Object.assign(new Error('classroom not found'), { code: 'NOT_FOUND' }); + await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'activity_changed', { scenario_id: scenarioId }); + return rows[0]; +} + +async function postAnnouncement(pool, classroomSessionId, instructorId, message) { + const { rows } = await pool.query( + `INSERT INTO classroom_announcements (classroom_session_id, instructor_id, message) VALUES ($1, $2, $3) RETURNING *`, + [classroomSessionId, instructorId, message] + ); + await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'announcement_posted', { announcement_id: rows[0].announcement_id }); + return rows[0]; +} + +async function listAnnouncements(pool, classroomSessionId) { + const { rows } = await pool.query( + `SELECT announcement_id, message, created_at FROM classroom_announcements + WHERE classroom_session_id = $1 ORDER BY created_at DESC LIMIT 50`, + [classroomSessionId] + ); + return rows; +} + +// §11/§22 — join is idempotent: re-joining an already-joined classroom just clears left_at and +// bumps last_seen_at, it doesn't create a second row or duplicate joined_at (PRIMARY KEY is +// (classroom_session_id, student_id) — there can only ever be one participation row per +// student per classroom, matching how a student can leave and rejoin the same live session). +async function joinClassroom(pool, classroomSessionId, studentId) { + const { rows } = await pool.query( + `INSERT INTO classroom_participants (classroom_session_id, student_id, last_seen_at) + VALUES ($1, $2, now()) + ON CONFLICT (classroom_session_id, student_id) + DO UPDATE SET last_seen_at = now(), left_at = NULL + RETURNING *`, + [classroomSessionId, studentId] + ); + await emitEvent(pool, classroomSessionId, 'student', studentId, 'joined', {}); + return rows[0]; +} + +async function leaveClassroom(pool, classroomSessionId, studentId) { + await pool.query( + `UPDATE classroom_participants SET left_at = now() WHERE classroom_session_id = $1 AND student_id = $2`, + [classroomSessionId, studentId] + ); + await emitEvent(pool, classroomSessionId, 'student', studentId, 'left', {}); +} + +// §15 presence — throttled: a heartbeat within HEARTBEAT_WRITE_THROTTLE_MS of the last recorded +// one is a silent no-op (still returns success to the client, just skips the write) rather than +// hammering Postgres every 20s per online student. Deliberately NOT a full audit-logged event +// (§25 "do not turn presence heartbeats into audit spam") — no classroom_events row per beat. +async function heartbeat(pool, classroomSessionId, studentId) { + await pool.query( + `UPDATE classroom_participants SET last_seen_at = now() + WHERE classroom_session_id = $1 AND student_id = $2 + AND (last_seen_at IS NULL OR last_seen_at < now() - ($3 || ' milliseconds')::interval)`, + [classroomSessionId, studentId, HEARTBEAT_WRITE_THROTTLE_MS] + ); +} + +function derivePresence(lastSeenAt, leftAt) { + if (leftAt) return 'offline'; + if (!lastSeenAt) return 'offline'; + const age = Date.now() - new Date(lastSeenAt).getTime(); + if (age <= PRESENCE_ONLINE_MS) return 'online'; + if (age <= PRESENCE_RECENTLY_ACTIVE_MS) return 'recently_active'; + return 'offline'; +} + +async function listParticipants(pool, classroomSessionId) { + const { rows } = await pool.query( + `SELECT cp.student_id, s.display_name, cp.joined_at, cp.last_seen_at, cp.left_at + FROM classroom_participants cp JOIN students s ON s.student_id = cp.student_id + WHERE cp.classroom_session_id = $1 ORDER BY s.display_name`, + [classroomSessionId] + ); + return rows.map((r) => ({ ...r, presence: derivePresence(r.last_seen_at, r.left_at) })); +} + +// §27 — a student may be enrolled in more than one cohort eventually (self-paced compatible); +// this returns the FIRST live classroom across any cohort they're enrolled in, not "their one +// cohort's classroom" — matches "My Courses, not My Cohort" (§27) as the eventual shape. +async function getLiveClassroomForStudent(pool, studentId) { + const { rows } = await pool.query( + `SELECT cs.* FROM classroom_sessions cs + JOIN cohort_enrollments e ON e.cohort_id = cs.cohort_id + WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled' AND cs.status = 'live' + ORDER BY cs.started_at DESC LIMIT 1`, + [studentId] + ); + return rows[0] || null; +} + +// §18 help requests +async function createHelpRequest(pool, classroomSessionId, studentId, category, message) { + const { rows } = await pool.query( + `INSERT INTO help_requests (classroom_session_id, student_id, category, message) VALUES ($1, $2, $3, $4) RETURNING *`, + [classroomSessionId, studentId, category || null, message || null] + ); + await emitEvent(pool, classroomSessionId, 'student', studentId, 'help_requested', { help_request_id: rows[0].help_request_id, category }); + return rows[0]; +} + +async function listHelpRequests(pool, classroomSessionId, statusFilter) { + const { rows } = await pool.query( + `SELECT hr.*, s.display_name FROM help_requests hr JOIN students s ON s.student_id = hr.student_id + WHERE hr.classroom_session_id = $1 AND ($2::text IS NULL OR hr.status = $2) + ORDER BY hr.created_at ASC`, + [classroomSessionId, statusFilter || null] + ); + return rows; +} + +async function acknowledgeHelpRequest(pool, helpRequestId, instructorId) { + const { rows } = await pool.query( + `UPDATE help_requests SET status = 'acknowledged', acknowledged_at = now(), acknowledged_by = $1 + WHERE help_request_id = $2 AND status = 'open' RETURNING *`, + [instructorId, helpRequestId] + ); + if (!rows.length) throw Object.assign(new Error('help request not found or not open'), { code: 'INVALID_STATE' }); + await emitEvent(pool, rows[0].classroom_session_id, 'instructor', instructorId, 'help_acknowledged', { help_request_id: helpRequestId }); + return rows[0]; +} + +async function resolveHelpRequest(pool, helpRequestId, instructorId, note) { + const { rows } = await pool.query( + `UPDATE help_requests SET status = 'resolved', resolved_at = now(), instructor_note = COALESCE($1, instructor_note) + WHERE help_request_id = $2 AND status IN ('open', 'acknowledged') RETURNING *`, + [note || null, helpRequestId] + ); + if (!rows.length) throw Object.assign(new Error('help request not found or already resolved'), { code: 'INVALID_STATE' }); + await emitEvent(pool, rows[0].classroom_session_id, 'instructor', instructorId, 'help_resolved', { help_request_id: helpRequestId }); + return rows[0]; +} + +module.exports = { + createClassroom, getClassroom, listClassroomsForCohort, + startClassroom, endClassroom, cancelClassroom, setCurrentActivity, + postAnnouncement, listAnnouncements, + joinClassroom, leaveClassroom, heartbeat, derivePresence, listParticipants, + getLiveClassroomForStudent, + createHelpRequest, listHelpRequests, acknowledgeHelpRequest, resolveHelpRequest, + emitEvent, +}; diff --git a/platform/api/src/prepare-labs.js b/platform/api/src/prepare-labs.js new file mode 100644 index 0000000..8f0bbc9 --- /dev/null +++ b/platform/api/src/prepare-labs.js @@ -0,0 +1,27 @@ +// Shared "Prepare Labs for a cohort's enrolled students" logic — used by both the existing +// cohort-level admin route and the Phase 5A classroom-scoped one (a classroom's Prepare Labs is +// exactly this, just entered from the classroom dashboard instead of the cohort page — see +// docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §21). Extracted so there's one place this +// logic lives, not two copies that could drift. + +async function prepareLabsForCohort(pool, provisioner, cohortId) { + const { rows: students } = await pool.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'`, + [cohortId] + ); + 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 === 503 && body?.code === 'CAPACITY_EXCEEDED') { + return { student_id: s.student_id, display_name: s.display_name, status: 'capacity_waiting' }; + } + 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 }; + })); + return 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 }); +} + +module.exports = { prepareLabsForCohort };