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:
2026-08-25 01:19:33 -05:00
parent d34ddae458
commit ee8ccb2ab6
4 changed files with 519 additions and 0 deletions
+175
View File
@@ -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 });
}
});
};
+75
View File
@@ -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);
}));
};
+242
View File
@@ -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,
};
+27
View File
@@ -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 };