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.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user