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