diff --git a/README.md b/README.md index 0d16215..f23f1eb 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ See [`docs/architecture-overview.md`](docs/architecture-overview.md) for the boo - `kt-intake-full-v2`: Primary snapshot containing the intake form, table, steps, communications log, possible causes, and `notesWorkspace` notes/open preference. Save to File and Load from File include this full snapshot automatically. - `kt-experience-role-v1`: Local-only Standalone / Student / Instructor preference. It is deliberately excluded from `collectAppState()`, Intake file exports, summaries, templates, and **Start Fresh** clearing. -- `kt-classroom-student-session-v1`: Student same-device resume envelope containing class/workspace display metadata, participant display identity, and the issued per-participant workspace capability. It never contains the Student join or assignment code and is excluded from Intake exports/summaries/templates. +- `kt-classroom-student-session-v1`: Student same-device resume envelope. In the normal live-class path it contains the stable Student class-session capability plus public class/participant/current-assignment context; the assignment-specific workspace capability remains memory-only and is reacquired after reload or reassignment. Legacy two-code sessions may still contain the issued workspace capability for compatibility. The envelope never enters Intake exports/summaries/templates. - `kt-classroom-student-local-recovery-v1`: Local recovery snapshot captured immediately before joining a class so **Leave class** can restore the prior local Intake. It is separate from the active Intake snapshot and classroom credentials. - `kt-classroom-instructor-session-v1`: Local-only Instructor same-device resume envelope containing the Instructor class capability, public class metadata, and the last selected public workspace ID. It is never collected into Intake state, files, summaries, templates, or Student workspace credentials. @@ -77,8 +77,8 @@ Coaching feedback is server-side Classroom data, not a local Intake storage key. Experience role is a product-level choice, not an Intake workflow mode. General / IT / Pharma / Major Incident remain controlled by `meta.intakeMode`; Standalone / Student / Instructor are controlled separately by `src/experienceRoles.js` and `src/experienceRoleController.js`. - **Standalone** exposes the normal Intake and current collaboration behavior. Its resource drawer contains **Templates only**. -- **Student** first joins an instructor-preconfigured class workspace using a class code, assignment code, and display name. The two admission codes are used once and discarded; the returned per-participant workspace capability is retained locally for same-device resume and is attached to the existing collaboration engine without entering the URL. Once connected, the Student sees class/workspace/identity context, public **Templates**, and a protected **Case Study** catalog fetched with that issued Classroom capability. Full Case Study state is fetched only on demand when the Student applies a case after the existing pedagogical mode/password step. Switching away from Student pauses the live classroom connection and clears the in-memory protected catalog while preserving class resume; **Leave class** clears resume and restores the local Intake captured before joining. Students also receive Instructor coaching beside the reviewed field, read-only, including optional notes and a **Changed since review** indicator when that field's current evidence no longer matches the reviewed fingerprint. -- **Instructor** opens an instructor-preconfigured class with the Instructor class capability, gets a searchable individual/team workspace rail, and can rapidly switch a **live read-only** view of each Student/team Intake. Observation uses the normal Intake renderer but the Student-owned controls are projected read-only; the Instructor credential is authorized only through the class-scoped GET observer path and never becomes a Student edit capability. The same Instructor class capability authorizes the protected teaching Case Study catalog/payload API. Switching away pauses observation and clears the in-memory protected catalog while preserving same-device class resume; **Leave class** clears the Instructor resume and restores the instructor's prior local Intake. In that read-only view, instructors can mark registered fields **Meets standard** or **Needs improvement**, add an optional note, revise/clear feedback, and see when the reviewed field has changed. +- **Student** normally joins with a display name and one human class code. Admission creates a stable high-entropy Student class-session capability; the learner may remain **Waiting / unassigned** with no workspace edit authority until the Instructor assigns a team or individual workspace. Once assigned, the browser exchanges the class session for a fresh assignment-specific editable workspace capability, keeps that workspace capability memory-only, and attaches it to the existing collaboration engine without entering the URL. Reassignment disconnects old authority before the Student enters the destination team's existing Intake; unassign returns the Student to Waiting. The older class-capability + assignment-capability flow remains under an explicit recovery disclosure. Connected Students see class/workspace/identity context, public **Templates**, protected Classroom Case Studies, and read-only Instructor coaching including optional notes and **Changed since review**. Switching away pauses live classroom sync while preserving class resume; **Leave class** clears resume and restores the local Intake captured before joining. +- **Instructor** normally uses **Start a class**, receives one human Student join code, sees Waiting/assigned participants, creates team or individual workspaces, and assigns/reassigns/unassigns Students through accessible selectors. The Instructor capability is retained locally for same-device resume; **Open an existing class** remains a recovery path. The dashboard can rapidly switch into a **live read-only** view of each Student/team Intake and provide field-level coaching. Observation uses the normal Intake renderer but server authorization keeps the Instructor credential outside Student edit capability. The same Instructor class capability authorizes protected teaching Case Studies. Switching away pauses observation while preserving same-device class resume; **Leave class** clears the Instructor resume and restores the instructor's prior local Intake. - Use **View → Experience** to switch roles without changing or deleting Intake data. ## Notes workspace @@ -110,14 +110,18 @@ Classroom slice #291 layers class organization and authorization over the existi The core class routes are: -- `/api/classes` — create/administer one class with an Instructor bearer capability; -- `/api/classes/workspaces` — Instructor-only creation/listing and assignment lifecycle for individual/group workspaces; -- `/api/classes/join` — Student admission into exactly one assigned workspace; +- `/api/classes` — create/administer one class with an Instructor bearer capability and return the normal human Student join code; +- `/api/classes/workspaces` — Instructor-only creation/listing of individual/group workspaces; +- `/api/classes/participants` — Instructor-only live roster and assign/reassign/unassign transitions; +- `/api/classes/admit` — one-code Student admission into a class-session / Waiting state; +- `/api/classes/student` — represented Student's own assignment status only; +- `/api/classes/student/access` — fresh assignment-specific editable workspace capability while currently assigned; +- `/api/classes/join` — legacy two-code admission into exactly one assigned workspace; - `/api/classes/observe` — Instructor-only read-only live workspace observation; - `/api/classes/coaching` and `/api/classes/coaching/student` — separate Instructor-write / Student-read coaching channel; - `/api/classes/case-studies` and `/api/classes/case-studies/student` — protected Case Study catalog and POST payload delivery after Classroom authorization. -A Student join requires **two independent capabilities**: the class Student-join capability and an assignment capability for one individual/group workspace. The Student join capability by itself cannot list assignments or classmates. A successful join returns a newly minted per-participant workspace capability that works with the existing `/api/workspaces/session` and `/api/workspaces/presence` endpoints. +In the normal live path, the human class code is admission-only. A successful admission returns a high-entropy Student class-session capability that can read only that learner's assignment state. When assigned, the Student exchanges it for a fresh `classroom-student` workspace capability accepted by the existing `/api/workspaces/session` and `/api/workspaces/presence` endpoints. Old workspace authority is revoked on reassignment/unassign. The older Student-join capability + assignment capability pair remains supported only as compatibility/recovery. The database stores only SHA-256 capability hashes. `CLASS_EXPIRY_DAYS` optionally controls class retention and defaults to 30 days. Classroom collaboration workspaces inherit the class's exact absolute expiry, so they cannot outlive it. Instructor, Student-join, and assignment credentials can be rotated; class/assignment revocation invalidates access server-side. diff --git a/SECURITY.md b/SECURITY.md index ae91b49..b5f9384 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,18 +12,18 @@ Never commit, log, paste into issues, or include in test fixtures: - database connection strings or credentials; - raw collaboration workspace tokens or secret links; -- Instructor class capabilities, Student join capabilities, assignment capabilities, or classroom workspace capabilities; +- Instructor class capabilities, Student class-session capabilities, Student join/assignment compatibility capabilities, or classroom workspace capabilities; - authorization headers; - production incident snapshots containing confidential data; - private participant identity data. -The collaboration capability model treats possession of the full secret link as read/write authorization. Classroom uses the same capability principle but separates privileges: Instructor class, Student join, assignment, and Student workspace capabilities are distinct secrets. Server code stores only token hashes and must not log raw capabilities or snapshots. +The collaboration capability model treats possession of the correct secret as authorization. Classroom separates privileges across Instructor class authority, human admission code, Student class session, assignment-specific Student workspace authority, and legacy compatibility credentials. Server code stores only hashes for high-entropy bearer capabilities and must not log raw capabilities or snapshots. -For same-device Student resume, the browser necessarily retains the one issued Student workspace capability under `kt-classroom-student-session-v1`. The Student join and assignment capabilities are never retained after successful admission. The resume envelope must never be copied into Intake state, exports, summaries, templates, URLs, analytics, logs, or error telemetry. +For the normal #312 live path, same-device Student resume stores the high-entropy **Student class-session capability** under `kt-classroom-student-session-v1` together with public class/participant/current-assignment context. The human join code is discarded after admission. The current assignment-specific workspace capability is **memory-only** and must be reacquired after reload or reassignment; it must not be written into the live resume envelope or URL. Legacy two-code sessions may still retain their issued workspace capability for compatibility. No Student resume envelope may enter Intake state, exports, summaries, templates, analytics, logs, or error telemetry. -For same-device Instructor resume, the browser retains the Instructor class capability under `kt-classroom-instructor-session-v1` together with public class metadata and the last selected public workspace ID. This credential may list and observe only its represented class through the Instructor classroom API. It must never enter Intake state, exports, summaries, templates, URLs, analytics, logs, error telemetry, or `collaboration_workspace_capabilities`. +For same-device Instructor resume, the browser retains the Instructor class capability under `kt-classroom-instructor-session-v1` together with public class metadata, the human Student join code, and the last selected public workspace ID. This credential may administer/list/observe only its represented class through Instructor classroom APIs. It must never enter Intake state, exports, summaries, templates, URLs, analytics, logs, error telemetry, or `collaboration_workspace_capabilities`. -A Student join capability alone must never enumerate class workspaces. Cross-class join/assignment combinations must fail without revealing which credential was valid. Instructor workspace listing is scoped to the represented class. +The human Student join code and Student class-session capability must never enumerate class workspaces or edit collaboration. Student own-status returns only the represented participant's assignment. Reassignment/unassign must revoke old workspace authority before destination/current access is issued, and stale old-team tokens must fail rather than map to the new team. Cross-class assignments fail without revealing unrelated class state. Instructor roster/workspace listing is scoped to the represented class. `collaboration_workspace_capabilities` is an editable-alias path. Only explicitly allowed edit kinds may resolve through the existing collaboration PUT/PATCH handlers. Instructor observation therefore uses the separate GET-only `/api/classes/observe` authorization path and the Instructor class capability is never inserted as a workspace alias. Moving Instructor observation into the editable alias table would be a privilege escalation. See `docs/classroom-api.md`. diff --git a/api/AGENTS.md b/api/AGENTS.md index 217b9e4..0cfbe45 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -17,26 +17,35 @@ These rules apply to all server-only modules below `api/`. Read `docs/classroom-api.md` and `docs/classroom-architecture.md` before modifying classroom endpoints. -The classroom authorization chain is: +The primary #312 classroom authorization chain is: ```text Instructor class capability - -> class administration + class-scoped workspace listing - -Student join capability + assignment capability - -> one authorized individual/group workspace - -> newly minted per-participant editable workspace capability + -> class administration + class-scoped roster/workspace listing + -> read-only observation + coaching + +human Student join code + -> admission only + -> high-entropy Student class-session capability + -> own assignment state only + -> fresh assignment-specific classroom-student workspace capability + -> existing collaboration edit APIs ``` +The legacy `Student join capability + assignment capability` chain remains an explicit two-code compatibility path. + Rules: -- Student join capability alone must never enumerate classroom workspaces. -- Assignment capability must resolve only within the active class represented by the Student join capability. +- Human join code and Student class-session capability must never enumerate classroom workspaces. +- Student class-session capability must never edit collaboration directly. +- Legacy Student join capability alone must never enumerate classroom workspaces. +- Legacy assignment capability must resolve only within the active class represented by the Student join capability. - Instructor workspace listing must be scoped to exactly the class represented by the Instructor capability. - Classroom workspaces reuse `collaboration_workspaces`; do not create a parallel snapshot/revision/presence engine. - Raw bearer capabilities are returned only at creation/rotation. Persist only SHA-256 hashes. - Classroom credentials never enter `kt-intake-full-v2`, exported Intake files, summaries, templates, or Case Study payloads. - Individual assignment ownership uses the opaque participant UUID, never the display name. +- Live participants may be Waiting with no workspace authority; assignment is a separate server-authoritative state. ## Editable workspace alias rule @@ -80,13 +89,47 @@ Any runtime change under `api/` is included in the repository test-change guard. Classroom API changes must preserve automated coverage for: -- class-scoped Instructor listing; -- Student non-enumeration; +- class-scoped Instructor roster/workspace listing; +- Student non-enumeration and own-status-only reads; +- Waiting participant with zero workspace edit authority; - cross-class assignment rejection; -- individual claim-once vs group sharing; +- individual occupancy vs group sharing; +- assignment revision/idempotency semantics; +- reassignment/unassign revoking old workspace authority before destination access; +- stale old-team token rejection and no Intake snapshot merge; - credential rotation and revocation; -- classroom workspace token reuse through the existing collaboration session API; +- live and legacy classroom workspace tokens through the existing collaboration session API; - edit-capability kind restrictions; -- legacy Standalone collaboration behavior. -- coaching class scoping, independent feedback revisions, Student read-only access, and zero Student snapshot-revision changes. -- protected Case Study Instructor class scoping, Student membership-bound access, rejection of legacy collaboration capabilities, metadata-only catalogs, POST-only payload selection, and private response headers. +- legacy two-code Classroom and Standalone collaboration behavior; +- coaching class scoping, independent feedback revisions, Student read-only access, and zero Student snapshot-revision changes; +- protected Case Study Instructor class scoping, Student membership-bound access, rejection of legacy Standalone collaboration capabilities, metadata-only catalogs, POST-only payload selection, and private response headers. + + +## #312 live-class capability direction + +Read `docs/classroom-live-management.md` before implementing #312. + +The new capability chain is intentionally different from the legacy two-code admission path: + +```text +human join code (admission locator only) + -> high-entropy Student class-session capability + -> own assignment-status API only + -> assignment-specific classroom-student workspace capability + -> existing collaboration edit APIs +``` + +Rules: +- never accept the human join code on workspace/observer/coaching/resource APIs; +- never accept the Student class-session capability on collaboration edit APIs; +- unassigned participants have no workspace edit capability; +- on assign/reassign/unassign, revoke the previous workspace-access capability before destination access is issued; +- never remap one still-active edit token from an old workspace to a new workspace; +- no assignment operation copies or merges Intake snapshots; +- keep legacy `/api/classes/join` and `classroom_memberships` working during the additive migration; +- Student status endpoints return only that Student's assignment; they never enumerate classmates/workspaces. +- `PATCH /api/classes/participants` must revoke any existing assignment-specific workspace capability before changing the participant's workspace assignment; destination access is issued only later through `POST /api/classes/student/access`. +- live Student coaching/protected-resource authorization must validate that the participant's stored workspace-access hash still corresponds to an active, unrevoked `classroom-student` collaboration capability; the stored hash alone is not sufficient authority. +- assignment changes use optimistic `assignment_revision` checks; a conflicting update may leave old access revoked, but must never leave old access valid or remap it to a new workspace. +- stale presence cleanup after reassignment uses internal workspace identity, not the revoked bearer token. +- individual live workspaces remain single-participant; cross-class destination assignment is rejected generically. diff --git a/api/_classroom.js b/api/_classroom.js index 91040c7..05154fc 100644 --- a/api/_classroom.js +++ b/api/_classroom.js @@ -6,7 +6,7 @@ * collaboration workspace engine; they never duplicate snapshot/revision state. */ -import { randomUUID } from 'node:crypto'; +import { randomInt, randomUUID } from 'node:crypto'; import { DISPLAY_NAME_MAX_LENGTH, generateWorkspaceToken, @@ -22,6 +22,8 @@ import { export const CLASS_TITLE_MAX_LENGTH = 120; export const CLASS_WORKSPACE_LABEL_MAX_LENGTH = 120; export const CLASS_WORKSPACE_KINDS = Object.freeze(['individual', 'group']); +export const CLASS_JOIN_CODE_LENGTH = 8; +export const CLASS_JOIN_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; export const DEFAULT_CLASS_EXPIRY_DAYS = 30; export const COACHING_STATUSES = Object.freeze(['meets-standard', 'needs-improvement']); export const COACHING_NOTE_MAX_LENGTH = 2000; @@ -84,6 +86,50 @@ export function validateClassroomId(value) { return validateParticipantId(value); } + +/** + * Normalize a human-facing live Classroom join code. + * + * Spaces and hyphens are presentation-only. Ambiguous characters such as + * 0/O and 1/I are deliberately excluded from the accepted alphabet. + * + * @param {unknown} value - Candidate join code. + * @returns {string|null} Canonical eight-character code or null. + */ +export function normalizeClassJoinCode(value) { + if (typeof value !== 'string') return null; + const compact = value.trim().toUpperCase().replace(/[\s-]+/gu, ''); + if (compact.length !== CLASS_JOIN_CODE_LENGTH) return null; + for (const character of compact) { + if (!CLASS_JOIN_CODE_ALPHABET.includes(character)) return null; + } + return compact; +} + +/** + * Format a normalized join code for human display. + * + * @param {unknown} value - Candidate join code. + * @returns {string|null} Grouped display form or null. + */ +export function formatClassJoinCode(value) { + const normalized = normalizeClassJoinCode(value); + return normalized ? `${normalized.slice(0, 4)}-${normalized.slice(4)}` : null; +} + +/** + * Generate a random normalized human-facing Classroom join code. + * + * @returns {string} Eight-character unambiguous code. + */ +export function generateClassJoinCode() { + let code = ''; + for (let index = 0; index < CLASS_JOIN_CODE_LENGTH; index += 1) { + code += CLASS_JOIN_CODE_ALPHABET[randomInt(CLASS_JOIN_CODE_ALPHABET.length)]; + } + return code; +} + /** * Normalize a stable coaching target identifier. * @@ -199,6 +245,11 @@ async function initializeClassroomRepository() { )`; await sql`CREATE INDEX IF NOT EXISTS classroom_classes_expiry_idx ON classroom_classes (expires_at)`; + await sql`ALTER TABLE classroom_classes + ADD COLUMN IF NOT EXISTS student_join_code VARCHAR(8)`; + await sql`CREATE UNIQUE INDEX IF NOT EXISTS classroom_classes_student_join_code_idx + ON classroom_classes (student_join_code) + WHERE student_join_code IS NOT NULL`; await sql`CREATE TABLE IF NOT EXISTS classroom_workspaces ( workspace_id BIGINT PRIMARY KEY REFERENCES collaboration_workspaces(id) ON DELETE CASCADE, @@ -216,6 +267,29 @@ async function initializeClassroomRepository() { await sql`CREATE INDEX IF NOT EXISTS classroom_workspaces_class_idx ON classroom_workspaces (class_id, created_at)`; + await sql`CREATE TABLE IF NOT EXISTS classroom_participants ( + class_id BIGINT NOT NULL REFERENCES classroom_classes(id) ON DELETE CASCADE, + participant_id UUID NOT NULL, + display_name VARCHAR(60) NOT NULL, + session_token_hash CHAR(64) UNIQUE NOT NULL, + workspace_id BIGINT, + workspace_access_token_hash CHAR(64) UNIQUE, + assignment_revision INTEGER NOT NULL DEFAULT 0 CHECK (assignment_revision >= 0), + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + assigned_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + PRIMARY KEY (class_id, participant_id), + FOREIGN KEY (class_id, workspace_id) + REFERENCES classroom_workspaces(class_id, workspace_id) + ON DELETE SET NULL (workspace_id) + )`; + await sql`CREATE INDEX IF NOT EXISTS classroom_participants_class_idx + ON classroom_participants (class_id, joined_at)`; + await sql`CREATE INDEX IF NOT EXISTS classroom_participants_workspace_idx + ON classroom_participants (workspace_id) + WHERE workspace_id IS NOT NULL`; + await sql`CREATE TABLE IF NOT EXISTS classroom_memberships ( class_id BIGINT NOT NULL, workspace_id BIGINT NOT NULL, @@ -252,6 +326,7 @@ async function initializeClassroomRepository() { async function classByInstructor(tokenHash) { const rows = await sql`SELECT id AS internal_id, public_id AS id, title, + student_join_code AS "joinCode", joins_enabled AS "joinsEnabled", expires_at AS "expiresAt" FROM classroom_classes WHERE instructor_token_hash = ${tokenHash} @@ -271,21 +346,345 @@ async function initializeClassroomRepository() { return rows[0] || null; } + async function classByJoinCode(joinCode) { + const rows = await sql`SELECT id AS internal_id, public_id AS id, title, + joins_enabled AS "joinsEnabled", expires_at AS "expiresAt" + FROM classroom_classes + WHERE student_join_code = ${joinCode} + AND joins_enabled = TRUE + AND revoked_at IS NULL + AND expires_at > NOW()`; + return rows[0] || null; + } + return { - async createClass({ publicId, title, instructorHash, studentJoinHash, expiryDays }) { + async createClass({ publicId, title, instructorHash, studentJoinHash, studentJoinCode = null, expiryDays }) { const rows = await sql`INSERT INTO classroom_classes - (public_id, title, instructor_token_hash, student_join_token_hash, expires_at) + (public_id, title, instructor_token_hash, student_join_token_hash, student_join_code, expires_at) VALUES ( ${publicId}::uuid, ${title}, ${instructorHash}, ${studentJoinHash}, + ${studentJoinCode}, NOW() + (${expiryDays} * INTERVAL '1 day') ) RETURNING public_id AS id, title, joins_enabled AS "joinsEnabled", expires_at AS "expiresAt"`; return rows[0]; }, + async getClassByJoinCode(joinCode) { + return classByJoinCode(joinCode); + }, + + async admitParticipant({ joinCode, participantId, displayName, sessionHash }) { + const classroom = await classByJoinCode(joinCode); + if (!classroom) return null; + + const rows = await sql`INSERT INTO classroom_participants + (class_id, participant_id, display_name, session_token_hash) + VALUES ( + ${classroom.internal_id}, + ${participantId}::uuid, + ${displayName}, + ${sessionHash} + ) + ON CONFLICT (class_id, participant_id) DO UPDATE + SET display_name = EXCLUDED.display_name, + session_token_hash = EXCLUDED.session_token_hash, + updated_at = NOW() + WHERE classroom_participants.revoked_at IS NULL + RETURNING + participant_id AS id, + display_name AS "displayName", + workspace_id AS "workspaceInternalId", + assignment_revision AS "assignmentRevision", + joined_at AS "joinedAt", + updated_at AS "updatedAt"`; + const participant = rows[0]; + if (!participant) return null; + + let assignment = null; + if (participant.workspaceInternalId) { + const assignments = await sql`SELECT + public_id AS id, + workspace_kind AS kind, + label + FROM classroom_workspaces + WHERE class_id = ${classroom.internal_id} + AND workspace_id = ${participant.workspaceInternalId} + AND revoked_at IS NULL`; + assignment = assignments[0] || null; + } + + return { classroom, participant, assignment }; + }, + + async getParticipantBySession(sessionHash) { + const rows = await sql`SELECT + c.id AS "classInternalId", + c.public_id AS "classId", + c.title AS "classTitle", + c.expires_at AS "classExpiresAt", + cp.participant_id AS id, + cp.display_name AS "displayName", + cp.workspace_id AS "workspaceInternalId", + cp.assignment_revision AS "assignmentRevision", + cp.joined_at AS "joinedAt", + cw.public_id AS "workspaceId", + cw.workspace_kind AS "workspaceKind", + cw.label AS "workspaceLabel" + FROM classroom_participants cp + JOIN classroom_classes c ON c.id = cp.class_id + LEFT JOIN classroom_workspaces cw + ON cw.class_id = cp.class_id + AND cw.workspace_id = cp.workspace_id + AND cw.revoked_at IS NULL + WHERE cp.session_token_hash = ${sessionHash} + AND cp.revoked_at IS NULL + AND c.revoked_at IS NULL + AND c.expires_at > NOW()`; + const row = rows[0]; + if (!row) return null; + return { + classroom: { id: row.classId, title: row.classTitle, expiresAt: row.classExpiresAt }, + participant: { + id: row.id, + displayName: row.displayName, + assignmentRevision: row.assignmentRevision, + joinedAt: row.joinedAt + }, + assignment: row.workspaceId + ? { id: row.workspaceId, kind: row.workspaceKind, label: row.workspaceLabel } + : null, + internal: { classId: row.classInternalId, workspaceId: row.workspaceInternalId || null } + }; + }, + + async listParticipants(instructorHash) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + const participants = await sql`SELECT + cp.participant_id AS id, + cp.display_name AS "displayName", + cp.assignment_revision AS "assignmentRevision", + cp.joined_at AS "joinedAt", + cp.updated_at AS "updatedAt", + cw.public_id AS "workspaceId", + cw.workspace_kind AS "workspaceKind", + cw.label AS "workspaceLabel" + FROM classroom_participants cp + LEFT JOIN classroom_workspaces cw + ON cw.class_id = cp.class_id + AND cw.workspace_id = cp.workspace_id + AND cw.revoked_at IS NULL + WHERE cp.class_id = ${classroom.internal_id} + AND cp.revoked_at IS NULL + ORDER BY cp.joined_at, cp.participant_id`; + return { + classroom, + participants: participants.map(participant => ({ + id: participant.id, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt, + updatedAt: participant.updatedAt, + assignment: participant.workspaceId + ? { id: participant.workspaceId, kind: participant.workspaceKind, label: participant.workspaceLabel } + : null + })) + }; + }, + + async assignParticipant(instructorHash, { participantId, workspacePublicId, workspaceRepository }) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + + const participantRows = await sql`SELECT + workspace_id AS "workspaceInternalId", + workspace_access_token_hash AS "workspaceAccessHash", + assignment_revision AS "assignmentRevision" + FROM classroom_participants + WHERE class_id = ${classroom.internal_id} + AND participant_id = ${participantId}::uuid + AND revoked_at IS NULL`; + const participant = participantRows[0]; + if (!participant) return null; + + let destination = null; + if (workspacePublicId !== null) { + const destinationRows = await sql`SELECT + cw.workspace_id AS "internalId", + cw.public_id AS id, + cw.workspace_kind AS kind, + cw.label + FROM classroom_workspaces cw + JOIN collaboration_workspaces w ON w.id = cw.workspace_id + WHERE cw.class_id = ${classroom.internal_id} + AND cw.public_id = ${workspacePublicId}::uuid + AND cw.revoked_at IS NULL + AND w.expires_at > NOW()`; + destination = destinationRows[0] || null; + if (!destination) return null; + + if (destination.kind === 'individual') { + const claimed = await sql`UPDATE classroom_workspaces + SET individual_participant_id = COALESCE(individual_participant_id, ${participantId}::uuid), + updated_at = NOW() + WHERE class_id = ${classroom.internal_id} + AND workspace_id = ${destination.internalId} + AND (individual_participant_id IS NULL OR individual_participant_id = ${participantId}::uuid) + RETURNING workspace_id`; + if (!claimed[0]) { + return { status: 'occupied', classroom }; + } + } + } + + const nextWorkspaceInternalId = destination?.internalId || null; + if ((participant.workspaceInternalId || null) === nextWorkspaceInternalId) { + return { + status: 'unchanged', + classroom, + participant: { + id: participantId, + assignmentRevision: participant.assignmentRevision + }, + assignment: destination + ? { id: destination.id, kind: destination.kind, label: destination.label } + : null + }; + } + + if (participant.workspaceAccessHash) { + await workspaceRepository.revokeCapability(participant.workspaceAccessHash); + } + if (participant.workspaceInternalId) { + await workspaceRepository.removePresenceByWorkspaceId(participant.workspaceInternalId, participantId); + } + + const updatedRows = await sql`UPDATE classroom_participants + SET workspace_id = ${nextWorkspaceInternalId}, + workspace_access_token_hash = NULL, + assignment_revision = assignment_revision + 1, + assigned_at = CASE WHEN ${nextWorkspaceInternalId} IS NULL THEN NULL ELSE NOW() END, + updated_at = NOW() + WHERE class_id = ${classroom.internal_id} + AND participant_id = ${participantId}::uuid + AND assignment_revision = ${participant.assignmentRevision} + AND revoked_at IS NULL + RETURNING + participant_id AS id, + display_name AS "displayName", + assignment_revision AS "assignmentRevision", + joined_at AS "joinedAt", + updated_at AS "updatedAt"`; + const updated = updatedRows[0]; + + if (!updated) { + if (destination?.kind === 'individual') { + const currentRows = await sql`SELECT workspace_id AS "workspaceInternalId" + FROM classroom_participants + WHERE class_id = ${classroom.internal_id} + AND participant_id = ${participantId}::uuid + AND revoked_at IS NULL`; + if (currentRows[0]?.workspaceInternalId !== destination.internalId) { + await sql`UPDATE classroom_workspaces + SET individual_participant_id = NULL, updated_at = NOW() + WHERE class_id = ${classroom.internal_id} + AND workspace_id = ${destination.internalId} + AND individual_participant_id = ${participantId}::uuid`; + } + } + return { status: 'conflict', classroom }; + } + + if (participant.workspaceInternalId && participant.workspaceInternalId !== nextWorkspaceInternalId) { + await sql`UPDATE classroom_workspaces + SET individual_participant_id = NULL, updated_at = NOW() + WHERE class_id = ${classroom.internal_id} + AND workspace_id = ${participant.workspaceInternalId} + AND workspace_kind = 'individual' + AND individual_participant_id = ${participantId}::uuid`; + } + + return { + status: 'updated', + classroom, + participant: updated, + assignment: destination + ? { id: destination.id, kind: destination.kind, label: destination.label } + : null + }; + }, + + async issueParticipantWorkspaceAccess({ sessionHash, accessHash, workspaceRepository }) { + const context = await this.getParticipantBySession(sessionHash); + if (!context) return null; + if (!context.internal.workspaceId || !context.assignment) { + return { ...context, waiting: true }; + } + + const assignments = await sql`SELECT + cw.workspace_id AS "internalId", + cw.public_id AS id, + cw.workspace_kind AS kind, + cw.label, + w.expires_at AS "expiresAt" + FROM classroom_workspaces cw + JOIN collaboration_workspaces w ON w.id = cw.workspace_id + WHERE cw.class_id = ${context.internal.classId} + AND cw.workspace_id = ${context.internal.workspaceId} + AND cw.revoked_at IS NULL + AND w.expires_at > NOW()`; + const assignment = assignments[0]; + if (!assignment) return null; + + const previousRows = await sql`SELECT workspace_access_token_hash AS "accessHash" + FROM classroom_participants + WHERE session_token_hash = ${sessionHash} + AND revoked_at IS NULL`; + const previousHash = previousRows[0]?.accessHash || null; + + const capabilityCreated = await workspaceRepository.createCapability( + assignment.internalId, + accessHash, + CLASSROOM_STUDENT_CAPABILITY_KIND, + context.classroom.expiresAt + ); + if (!capabilityCreated) throw new Error('Unable to create live Student workspace capability'); + + const updated = await sql`UPDATE classroom_participants + SET workspace_access_token_hash = ${accessHash}, + updated_at = NOW() + WHERE session_token_hash = ${sessionHash} + AND class_id = ${context.internal.classId} + AND workspace_id = ${context.internal.workspaceId} + AND assignment_revision = ${context.participant.assignmentRevision} + AND revoked_at IS NULL + RETURNING participant_id`; + if (!updated[0]) { + await workspaceRepository.revokeCapability(accessHash); + return null; + } + + if (previousHash && previousHash !== accessHash) { + await workspaceRepository.revokeCapability(previousHash); + } + + return { + classroom: context.classroom, + participant: context.participant, + assignment: { + id: assignment.id, + kind: assignment.kind, + label: assignment.label, + expiresAt: assignment.expiresAt + }, + waiting: false + }; + }, + async getClassByInstructor(instructorHash) { return classByInstructor(instructorHash); }, @@ -365,7 +764,10 @@ async function initializeClassroomRepository() { cw.workspace_kind AS kind, cw.label, cw.created_at AS "createdAt", - COUNT(DISTINCT cm.participant_id)::int AS "participantCount", + ( + COUNT(DISTINCT cm.participant_id) + + COUNT(DISTINCT live_cp.participant_id) + )::int AS "participantCount", COUNT(DISTINCT cp.participant_id) FILTER ( WHERE cp.last_seen_at >= NOW() - 30 * INTERVAL '1 second' )::int AS "activeParticipantCount", @@ -376,6 +778,10 @@ async function initializeClassroomRepository() { MAX(cp.last_seen_at) AS "lastSeenAt" FROM classroom_workspaces cw LEFT JOIN classroom_memberships cm ON cm.workspace_id = cw.workspace_id + LEFT JOIN classroom_participants live_cp + ON live_cp.class_id = cw.class_id + AND live_cp.workspace_id = cw.workspace_id + AND live_cp.revoked_at IS NULL LEFT JOIN collaboration_participants cp ON cp.workspace_id = cw.workspace_id JOIN collaboration_workspaces w ON w.id = cw.workspace_id WHERE cw.class_id = ${classroom.internal_id} @@ -486,7 +892,7 @@ async function initializeClassroomRepository() { }, async getStudentContext(accessHash) { - const rows = await sql`SELECT + const liveRows = await sql`SELECT c.id AS "classInternalId", c.public_id AS "classId", c.title AS "classTitle", @@ -495,17 +901,48 @@ async function initializeClassroomRepository() { cw.public_id AS "workspaceId", cw.workspace_kind AS "workspaceKind", cw.label AS "workspaceLabel" - FROM classroom_memberships cm - JOIN classroom_classes c ON c.id = cm.class_id + FROM classroom_participants cp + JOIN classroom_classes c ON c.id = cp.class_id JOIN classroom_workspaces cw - ON cw.class_id = cm.class_id AND cw.workspace_id = cm.workspace_id + ON cw.class_id = cp.class_id AND cw.workspace_id = cp.workspace_id JOIN collaboration_workspaces w ON w.id = cw.workspace_id - WHERE cm.access_token_hash = ${accessHash} + JOIN collaboration_workspace_capabilities cap + ON cap.token_hash = cp.workspace_access_token_hash + AND cap.workspace_id = cw.workspace_id + AND cap.capability_kind = ${CLASSROOM_STUDENT_CAPABILITY_KIND} + AND cap.revoked_at IS NULL + AND (cap.expires_at IS NULL OR cap.expires_at > NOW()) + WHERE cp.workspace_access_token_hash = ${accessHash} + AND cp.revoked_at IS NULL AND c.revoked_at IS NULL AND c.expires_at > NOW() AND cw.revoked_at IS NULL AND w.expires_at > NOW()`; - const scope = rows[0]; + let scope = liveRows[0]; + + if (!scope) { + const legacyRows = await sql`SELECT + c.id AS "classInternalId", + c.public_id AS "classId", + c.title AS "classTitle", + c.expires_at AS "classExpiresAt", + cw.workspace_id AS "workspaceInternalId", + cw.public_id AS "workspaceId", + cw.workspace_kind AS "workspaceKind", + cw.label AS "workspaceLabel" + FROM classroom_memberships cm + JOIN classroom_classes c ON c.id = cm.class_id + JOIN classroom_workspaces cw + ON cw.class_id = cm.class_id AND cw.workspace_id = cm.workspace_id + JOIN collaboration_workspaces w ON w.id = cw.workspace_id + WHERE cm.access_token_hash = ${accessHash} + AND c.revoked_at IS NULL + AND c.expires_at > NOW() + AND cw.revoked_at IS NULL + AND w.expires_at > NOW()`; + scope = legacyRows[0]; + } + if (!scope) return null; return { classroom: { id: scope.classId, title: scope.classTitle, expiresAt: scope.classExpiresAt }, @@ -705,6 +1142,7 @@ function requireBearer(req) { export function classHandler({ getRepository = getClassroomRepository, tokenFactory = generateWorkspaceToken, + joinCodeFactory = generateClassJoinCode, idFactory = randomUUID } = {}) { return async (req, res) => { @@ -720,14 +1158,22 @@ export function classHandler({ const repository = await getRepository(); const instructorToken = tokenFactory(); const studentJoinToken = tokenFactory(); + const joinCode = normalizeClassJoinCode(joinCodeFactory()); + if (!joinCode) throw new Error('Join-code generator returned an invalid code'); const classroom = await repository.createClass({ publicId: idFactory(), title, instructorHash: hashWorkspaceToken(instructorToken), studentJoinHash: hashWorkspaceToken(studentJoinToken), + studentJoinCode: joinCode, expiryDays: getClassExpiryDays() }); - return send(res, 201, { class: classroom, instructorToken, studentJoinToken }); + return send(res, 201, { + class: classroom, + instructorToken, + studentJoinToken, + joinCode: formatClassJoinCode(joinCode) + }); } const authorization = requireBearer(req); @@ -740,7 +1186,10 @@ export function classHandler({ if (req.method === 'GET') { const classroom = await repository.getClassByInstructor(instructorHash); return classroom - ? send(res, 200, { class: classroom }) + ? send(res, 200, { + class: classroom, + joinCode: formatClassJoinCode(classroom.joinCode) + }) : send(res, 404, { error: 'Class not found.' }); } @@ -1053,6 +1502,201 @@ export function classStudentCoachingHandler({ getRepository = getClassroomReposi } }; } +/** + * Create the one-code live Student admission handler. + * + * The human join code is only an admission locator. Successful admission mints + * a high-entropy Student class-session capability that cannot edit collaboration + * directly and may represent a waiting/unassigned Student. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {Function} Vercel handler. + */ +export function classAdmitHandler({ + getRepository = getClassroomRepository, + tokenFactory = generateWorkspaceToken +} = {}) { + return async (req, res) => { + if (req.method !== 'POST') return methodNotAllowed(res, 'POST'); + + const joinCode = normalizeClassJoinCode(req.body?.joinCode); + const participantId = req.body?.participantId; + const displayName = normalizeCollaborationName(req.body?.displayName, DISPLAY_NAME_MAX_LENGTH); + if (!joinCode || !validateParticipantId(participantId) || displayName === null) { + return send(res, 400, { error: 'Invalid class admission request.' }); + } + + try { + const repository = await getRepository(); + const studentSessionToken = tokenFactory(); + const result = await repository.admitParticipant({ + joinCode, + participantId, + displayName, + sessionHash: hashWorkspaceToken(studentSessionToken) + }); + if (!result) { + return send(res, 404, { error: 'Class not available.' }); + } + return send(res, 200, { + class: { + id: result.classroom.id, + title: result.classroom.title, + expiresAt: result.classroom.expiresAt + }, + participant: result.participant, + assignment: result.assignment, + studentSessionToken + }); + } catch { + return send(res, 500, { error: 'Unable to enter class.' }); + } + }; +} + +/** + * Create the Instructor-only live participant roster handler. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {Function} Vercel handler. + */ +export function classParticipantsHandler({ + getRepository = getClassroomRepository, + getWorkspaceRepo = getWorkspaceRepository +} = {}) { + return async (req, res) => { + if (!['GET', 'PATCH'].includes(req.method)) return methodNotAllowed(res, 'GET, PATCH'); + const authorization = requireBearer(req); + if (!authorization.ok) return send(res, authorization.status, { error: authorization.error }); + + const instructorHash = hashWorkspaceToken(authorization.token); + + try { + const repository = await getRepository(); + if (req.method === 'GET') { + const result = await repository.listParticipants(instructorHash); + return result + ? send(res, 200, { class: result.classroom, participants: result.participants }) + : send(res, 404, { error: 'Class not found.' }); + } + + const participantId = req.body?.participantId; + const workspaceId = req.body?.workspaceId ?? null; + if (!validateParticipantId(participantId) + || (workspaceId !== null && !validateClassroomId(workspaceId))) { + return send(res, 400, { error: 'Invalid participant assignment.' }); + } + + const workspaceRepository = await getWorkspaceRepo(); + const result = await repository.assignParticipant(instructorHash, { + participantId, + workspacePublicId: workspaceId, + workspaceRepository + }); + if (!result) { + return send(res, 404, { error: 'Participant or workspace not found.' }); + } + if (result.status === 'occupied') { + return send(res, 409, { error: 'Individual workspace is already assigned.' }); + } + if (result.status === 'conflict') { + return send(res, 409, { error: 'Participant assignment changed. Retry.' }); + } + return send(res, 200, { + class: result.classroom, + participant: result.participant, + assignment: result.assignment, + changed: result.status === 'updated' + }); + } catch { + return send(res, 500, { error: 'Unable to update class participant.' }); + } + }; +} + +/** + * Create the Student own-assignment status handler. + * + * A Student class-session capability resolves only the represented participant. + * It never returns a class roster, workspace catalog, or editable capability. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {Function} Vercel handler. + */ +export function classStudentHandler({ getRepository = getClassroomRepository } = {}) { + return async (req, res) => { + if (req.method !== 'GET') return methodNotAllowed(res, 'GET'); + const authorization = requireBearer(req); + if (!authorization.ok) return send(res, authorization.status, { error: authorization.error }); + + try { + const repository = await getRepository(); + const result = await repository.getParticipantBySession(hashWorkspaceToken(authorization.token)); + return result + ? send(res, 200, { + class: result.classroom, + participant: result.participant, + assignment: result.assignment + }) + : send(res, 404, { error: 'Student class session not found.' }); + } catch { + return send(res, 500, { error: 'Unable to load Student class status.' }); + } + }; +} + +/** + * Create the Student current-workspace access handler. + * + * The stable Student class-session capability is exchanged for a fresh + * assignment-specific edit capability only while the represented participant is + * currently assigned to a live workspace. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {Function} Vercel handler. + */ +export function classStudentAccessHandler({ + getRepository = getClassroomRepository, + getWorkspaceRepo = getWorkspaceRepository, + tokenFactory = generateWorkspaceToken +} = {}) { + return async (req, res) => { + if (req.method !== 'POST') return methodNotAllowed(res, 'POST'); + const authorization = requireBearer(req); + if (!authorization.ok) return send(res, authorization.status, { error: authorization.error }); + + try { + const repository = await getRepository(); + const workspaceRepository = await getWorkspaceRepo(); + const workspaceToken = tokenFactory(); + const result = await repository.issueParticipantWorkspaceAccess({ + sessionHash: hashWorkspaceToken(authorization.token), + accessHash: hashWorkspaceToken(workspaceToken), + workspaceRepository + }); + if (!result) { + return send(res, 404, { error: 'Student class session not found.' }); + } + if (result.waiting) { + return send(res, 409, { + status: 'waiting', + class: result.classroom, + participant: result.participant, + assignment: null + }); + } + return send(res, 200, { + class: result.classroom, + participant: result.participant, + assignment: result.assignment, + workspaceToken + }); + } catch { + return send(res, 500, { error: 'Unable to issue Student workspace access.' }); + } + }; +} + /** * Create the student class-join handler. * diff --git a/api/_workspace.js b/api/_workspace.js index 695eefe..61e281a 100644 --- a/api/_workspace.js +++ b/api/_workspace.js @@ -309,6 +309,13 @@ async function initializeRepository() { RETURNING participant_id`; return rows.length > 0; }, + async removePresenceByWorkspaceId(workspaceId, participantId) { + const rows = await sql`DELETE FROM collaboration_participants + WHERE workspace_id = ${workspaceId} + AND participant_id = ${participantId}::uuid + RETURNING participant_id`; + return rows.length > 0; + }, async createCapability(workspaceId, tokenHash, capabilityKind, expiresAt = null) { if (!isWorkspaceEditCapabilityKind(capabilityKind)) { return false; diff --git a/api/classes/admit.js b/api/classes/admit.js new file mode 100644 index 0000000..478c4d4 --- /dev/null +++ b/api/classes/admit.js @@ -0,0 +1,7 @@ +/** + * @module api/classes/admit + * @description One-code Student admission into a live class waiting/assigned session. + */ +import { classAdmitHandler } from '../_classroom.js'; + +export default classAdmitHandler(); diff --git a/api/classes/participants.js b/api/classes/participants.js new file mode 100644 index 0000000..1dbb459 --- /dev/null +++ b/api/classes/participants.js @@ -0,0 +1,7 @@ +/** + * @module api/classes/participants + * @description Instructor-only live Classroom participant roster. + */ +import { classParticipantsHandler } from '../_classroom.js'; + +export default classParticipantsHandler(); diff --git a/api/classes/student.js b/api/classes/student.js new file mode 100644 index 0000000..826e591 --- /dev/null +++ b/api/classes/student.js @@ -0,0 +1,7 @@ +/** + * @module api/classes/student + * @description Student class-session endpoint for own assignment status only. + */ +import { classStudentHandler } from '../_classroom.js'; + +export default classStudentHandler(); diff --git a/api/classes/student/access.js b/api/classes/student/access.js new file mode 100644 index 0000000..aab6b6e --- /dev/null +++ b/api/classes/student/access.js @@ -0,0 +1,7 @@ +/** + * @module api/classes/student/access + * @description Exchanges one Student class-session capability for current assignment-specific workspace access. + */ +import { classStudentAccessHandler } from '../../_classroom.js'; + +export default classStudentAccessHandler(); diff --git a/docs/AI-ONBOARDING.md b/docs/AI-ONBOARDING.md index 6ca7fee..26c89b1 100644 --- a/docs/AI-ONBOARDING.md +++ b/docs/AI-ONBOARDING.md @@ -21,8 +21,8 @@ For any work in the Standalone / Student / Instructor program (#288), also read | `src/appState.js` | `collectAppState()`, `applyAppState()`, `getSummaryState()` for round-trip UI testing and summary hydration. | | `src/experienceRoles.js` | Canonical Standalone / Student / Instructor IDs, labels, and declarative product-surface policy. | | `src/experienceRoleController.js` | First-run chooser, role resume/switching, backward-compatible Standalone migration, and local-only `kt-experience-role-v1` preference. | -| `src/classroomStudent.js` | Student class admission/resume/exit plus coaching-connect lifecycle hooks. | -| `src/classroomInstructor.js` | Instructor class resume, roster, read-only observation, and coaching-observation lifecycle hooks. | +| `src/classroomStudent.js` | Normal one-code Student admission, Waiting/own-status polling, class-session resume, assignment-specific workspace access/reassignment/unassign, legacy two-code recovery, and coaching/resource lifecycle hooks. | +| `src/classroomInstructor.js` | Start Class, human join-code display, same-device Instructor resume, live roster/team assignment management, read-only observation, and coaching lifecycle hooks. | | `src/coachableFields.js` | Stable coaching target IDs and versioned field fingerprints; DOM placement is deliberately separate from persistence identity. | | `src/classroomCoaching.js` | Instructor coaching controls and Student read-only feedback rendering through the separate coaching API. | | `src/classroomCaseStudies.js` | Authorized in-memory protected Case Study catalog/payload client. Receives active Classroom capabilities from Student/Instructor lifecycle controllers, never persists them, and clears context on disconnect. | @@ -37,8 +37,8 @@ For any work in the Standalone / Student / Instructor program (#288), also read | `src/summary.js` | `generateSummary()`, `setSummaryStateProvider()`, helpers that compose both clipboard output and AI prompts. | | `src/toast.js` | `showToast()` for lightweight notifications reused by comms and bootstrapping. | `api/_workspace.js` | Existing collaboration persistence/handlers plus explicitly edit-capable workspace aliases. Only `classroom-student` aliases may resolve through legacy read/write collaboration endpoints. | -| `api/_classroom.js` | Class schema/capabilities, class-scoped observation, Student admission, rotation/revocation, Student membership lookup, and separate Instructor-write/Student-read coaching feedback APIs. | -| `api/_protectedCaseStudies.js` | Server-only protected Case Study catalog/payload delivery. Instructor access is class-scoped; Student access resolves active membership from the issued Student workspace capability. | +| `api/_classroom.js` | Class/live-participant schema and capabilities, one-code admission, own-status/current-access exchange, Instructor assign/reassign/unassign, class-scoped observation, legacy admission compatibility, and separate coaching APIs. | +| `api/_protectedCaseStudies.js` | Server-only protected Case Study catalog/payload delivery. Instructor access is class-scoped; Student access resolves only the currently active Classroom workspace authority. #313 staged release must extend this boundary rather than shipping unreleased case content to the browser. | ## Working Agreement for AI Agents - **Stay modular:** Add new behaviour by creating a file under `src/` and exporting named helpers. Only touch `main.js` to import and wire these helpers. diff --git a/docs/classroom-api.md b/docs/classroom-api.md index e725723..4678d53 100644 --- a/docs/classroom-api.md +++ b/docs/classroom-api.md @@ -1,6 +1,6 @@ # Classroom API and Capability Contract -This document is the canonical server contract for Classroom Experience slices #291–#295. It complements `docs/classroom-architecture.md` so future human or AI sessions can extend Student, Instructor, coaching, and protected Case Study work without reconstructing authorization decisions from code. +This document is the canonical server contract for Classroom Experience slices #291–#295 and the #312 live-class management layer. It complements `docs/classroom-architecture.md` so future human or AI sessions can extend Student, Instructor, coaching, protected Case Study, live roster, and staged-simulation work without reconstructing authorization decisions from code. ## Core model @@ -20,12 +20,13 @@ The classroom tables contain organization, assignment, retention, and authorizat | Capability | Purpose | Workspace enumeration | Server persistence | | --- | --- | --- | --- | -| Instructor class capability | Administer one class and list that class's workspaces | Yes, own class only | SHA-256 hash | -| Student join capability | Admit a join attempt into one class | **No** | SHA-256 hash | -| Assignment capability | Select one individual/group assignment inside the admitted class | **No** | SHA-256 hash | -| Student workspace capability | Edit/sync the resolved collaboration workspace | N/A | SHA-256 alias hash | +| Instructor class capability | Administer one class, roster, assignments, observation, coaching, and protected teaching resources | Yes, own class only | SHA-256 hash | +| Human Student join code | Admission locator for the normal #312 live path; never edit authority | **No** | normalized code on class row | +| Student class-session capability | Resume one admitted participant and read only that participant's current assignment | **No** | SHA-256 hash | +| Student workspace capability | Edit/sync only the participant's currently assigned collaboration workspace | N/A | SHA-256 alias hash | +| Legacy Student join capability | Compatibility admission into one class for the older two-code path | **No** | SHA-256 hash | +| Legacy assignment capability | Compatibility selector for one individual/group workspace | **No** | SHA-256 hash | | Legacy Standalone workspace capability | Existing secret-link collaboration | N/A | SHA-256 hash | -| Instructor class capability | Read-only live observation and coaching administration for its class | Yes, own class only | SHA-256 hash | Possession of a capability is authority. Display name is not identity. @@ -121,11 +122,25 @@ On success the server: That returned capability is accepted by the existing `/api/workspaces/session` and `/api/workspaces/presence` handlers. -### Student client consumption (#292) +### Student client consumption (#292 legacy + #312 live) -The browser sends the Student join capability only in the Authorization header and the assignment capability only in the POST body. On success, both admission codes are discarded. The raw returned `workspaceToken` is retained under `kt-classroom-student-session-v1` solely for same-device resume. +The legacy two-code compatibility path still sends the Student join capability in the Authorization header and the assignment capability in the POST body. On legacy success, both admission codes are discarded and the returned `workspaceToken` remains in the dedicated `kt-classroom-student-session-v1` resume envelope exactly as before. -The collaboration client attaches that workspace capability programmatically; classroom workspace capabilities are **not** written into `?workspace=` URLs. Classroom mode disables collaboration-link copying and legacy shared-session leaving so the Student controller remains the owner of resume/Leave-class lifecycle. +The normal #312 live-class path is different: + +1. the browser sends display name + human join code to `POST /api/classes/admit`; +2. the human join code is discarded after admission; +3. the browser persists only the high-entropy `studentSessionToken` plus public class/participant/assignment context; +4. an unassigned Student stays in Waiting and does not connect collaboration; +5. the browser polls only `GET /api/classes/student` with the class-session capability; +6. while assigned, it exchanges that class-session capability at `POST /api/classes/student/access` for the current assignment-specific editable workspace capability; +7. that workspace capability is kept **memory-only** and is never written into the Student resume envelope or `?workspace=` URL; +8. on reload or assignment change, the browser reacquires fresh current-workspace access from the server; +9. on reassignment or unassign, the old collaboration session is left before destination/current access is obtained. + +This means same-device live resume survives team movement without persisting stale team authority. The server remains authoritative for assignment revision and revokes the old workspace alias before the Student receives destination access. + +Classroom mode continues to disable collaboration-link copying and legacy shared-session leaving so the Student controller owns resume, automatic reassignment, Waiting, and Leave-class lifecycle. ### `GET /api/classes/observe?workspaceId=` @@ -240,7 +255,7 @@ This prevents a future `classroom-observer` token from accidentally inheriting P ## Additive Neon schema -Slice #291 adds: +Slices #291–#294 establish: - `collaboration_workspace_capabilities` - `classroom_classes` @@ -248,9 +263,11 @@ Slice #291 adds: - `classroom_memberships` - `classroom_coaching_feedback` -The membership table has a composite foreign key to `classroom_workspaces(class_id, workspace_id)`, so class/workspace isolation is enforced by the database as well as by handler authorization. +#312 adds the live-class participant/session layer through additive columns plus `classroom_participants`. The participant record is class-scoped and carries the current optional workspace assignment, monotonic `assignment_revision`, Student class-session capability hash, and current assignment-specific workspace-access hash. A participant may therefore exist safely in Waiting with no workspace authority. -Existing collaboration tables and secret links remain valid. +The legacy membership table remains supported for two-code compatibility. Live assignment does not copy or merge collaboration snapshots; `classroom_workspaces` still points at the existing collaboration workspace that owns snapshot/revision/presence state. + +Database foreign keys and handler authorization jointly enforce class/workspace isolation. Existing collaboration tables, Standalone secret links, and the legacy Classroom path remain valid. No classroom credential is stored in browser Intake state. @@ -290,3 +307,43 @@ No classroom credential is stored in browser Intake state. The repository test-change guard treats `api/` as runtime code, so future server changes require test changes. For current execution state, read `docs/classroom-workstream.md`. + + +## #312 live-class management contract + +The next-generation live-class contract is defined in `docs/classroom-live-management.md`. + +Key direction: +- the human-facing join code is an admission locator, not a workspace bearer capability; +- admitted Students receive a stable high-entropy **class-session** capability; +- Students may exist in a waiting/unassigned state; +- Instructor assignment/reassignment is server-authoritative; +- editable workspace capabilities are assignment-specific and revoked on move/unassign; +- the Student class-session capability is never accepted by collaboration edit endpoints; +- destination workspace state wins on reassignment; no automatic Intake merge occurs; +- legacy two-code admission remains supported during the additive migration. + +Implemented live-class endpoints are `POST /api/classes/admit`, `GET/PATCH /api/classes/participants`, `GET /api/classes/student`, and `POST /api/classes/student/access`. Existing `POST /api/classes/join` remains a compatibility path until a later explicit migration. + +Student live-client semantics now implemented: +- normal Student entry is one human class code + display name; +- legacy two-code access remains available under an explicit recovery disclosure; +- Waiting retains the class-session capability but holds no collaboration edit authority; +- assignment-specific workspace tokens are memory-only and reacquired on reload/move; +- assignment revision changes trigger old-workspace disconnect before destination access; +- destination snapshot is loaded as authoritative; no old-team/local Intake merge occurs; +- unassign returns the Student to Waiting without requiring another human join code; +- terminal class-session failure clears the invalid session and restores pre-class local recovery where available; +- real-browser coverage proves Waiting -> Team Alpha -> same-device resume -> Team Beta -> Waiting and verifies stale Alpha/Beta workspace tokens return 404 after move/unassign. + +Assignment semantics: +- `PATCH /api/classes/participants` is Instructor-only and accepts `participantId` plus a same-class `workspaceId`, or `workspaceId: null` to return the Student to waiting; +- assign/reassign/unassign increments `assignmentRevision` exactly when the assignment changes; +- repeating the same assignment is idempotent and does not increment the revision; +- the previous assignment-specific edit capability is revoked before assignment metadata changes; +- stale presence is removed from the old workspace by internal workspace ID after revocation; +- cross-class destinations are rejected without changing the participant assignment; +- individual workspaces remain single-participant; +- no assignment operation reads, copies, or merges Intake snapshots; +- the Student must exchange the stable class-session capability for a fresh destination workspace token after an assignment change; +- a live Student coaching/protected-resource context is valid only while the stored workspace-access hash also resolves to an active, unrevoked `classroom-student` collaboration alias. diff --git a/docs/classroom-architecture.md b/docs/classroom-architecture.md index 4b9a961..1d9a3f1 100644 --- a/docs/classroom-architecture.md +++ b/docs/classroom-architecture.md @@ -41,33 +41,43 @@ Do not collapse these concepts into one mode. Class id title - instructor capability(s) - student join capability / join code policy + instructor capability hash + human Student join code + legacy Student join capability hash status / expiry / retention metadata - case-study assignments (later) - -> Classroom Workspace [1..n] + -> Classroom Participant [0..n] + opaque participant id + display name + Student class-session capability hash + current workspace assignment (optional / Waiting) + assignment revision + current assignment-specific workspace-access hash (optional) + + -> Classroom Workspace [0..n] id kind: individual | group - display name + display label existing collaboration workspace reference - membership / assignment metadata - - -> Participant [1..n] - device/profile identity + display name - existing collaboration presence/activity - - -> Coaching Feedback [0..n] - coachable target id - status - note - reviewed workspace revision - reviewed field fingerprint - independent feedback revision - created/updated metadata + legacy assignment capability hash + + -> existing collaboration snapshot / revision / presence + + -> Coaching Feedback [0..n] + class + workspace + coachable target id + status / note + reviewed workspace revision + reviewed field fingerprint + independent feedback revision + created/updated metadata + + -> Classroom Exercise / Stage State [future #313] + selected protected Case Study + exercise status / current stage + released Student content + Instructor-only facilitation/debrief material ``` -The **Classroom Workspace** should reference or extend the existing collaboration workspace rather than duplicate its snapshot, revision, participant, or presence model. +The **Classroom Workspace** references the existing collaboration workspace rather than duplicating its snapshot, revision, participant, or presence engine. #312 deliberately separates stable class participation from mutable workspace assignment so a Student can wait unassigned, move between teams, or be unassigned again without changing identity or merging team Intake state. ## Experience role lifecycle @@ -99,18 +109,20 @@ Standalone is the compatibility anchor. Student mode adds class context around the normal Intake. -Slice #292 implements a join gate before the Student Intake becomes editable. A Student supplies a **class join capability + assignment capability + display name**. The client never lists classes/workspaces; the server resolves exactly one authorized individual/team workspace. +The normal #312 path is **display name + one human class code**. The code is admission-only: `POST /api/classes/admit` resolves one active class and mints a high-entropy Student class-session capability for exactly one participant. The client never lists classes, classmates, teams, or workspaces. + +A Student may be admitted while **Waiting / unassigned**. In that state: +- `kt-classroom-student-session-v1` stores only the Student class-session capability plus public class/participant/current-assignment context; +- there is no collaboration workspace token and Intake remains hidden; +- the browser polls only its own assignment state. + +When assigned, the Student exchanges the class-session capability through `POST /api/classes/student/access` for a fresh assignment-specific editable workspace capability. That workspace capability is memory-only, is attached programmatically to the existing collaboration controller, and is never placed in `?workspace=` or persisted in the live resume envelope. -After successful admission: -- the class join and assignment capabilities are discarded; -- the returned per-participant workspace capability is persisted under `kt-classroom-student-session-v1` for same-device resume; -- that capability is attached programmatically to the existing collaboration controller and never placed in `?workspace=`; -- class/workspace/participant context is rendered as local-only Student chrome; -- a pre-class local snapshot is retained separately under `kt-classroom-student-local-recovery-v1` so **Leave class** can restore prior local work. +On assignment revision change, the client leaves/discards old collaboration authority before requesting destination access. The destination team's current Intake is authoritative; old local/team work is never merged into it. Unassign disconnects collaboration and returns the Student to Waiting while retaining the stable class session. -Switching away from Student disconnects live classroom presence/sync but keeps the resume envelope. Switching back resumes the same authorized workspace. Terminal invalid/expired/revoked workspace access clears the resume capability and restores the pre-class local recovery. +A pre-class local snapshot remains separately recoverable under `kt-classroom-student-local-recovery-v1`. Terminal invalid/expired class-session access clears Classroom credentials and restores local recovery where appropriate. -Student workspace editing continues through the existing collaboration synchronization engine. No classroom metadata or capability belongs in the Intake snapshot. +The #292 **class join capability + assignment capability** flow remains available only as explicit legacy/recovery compatibility. No classroom metadata or capability belongs in the Intake snapshot. ### Instructor @@ -136,36 +148,42 @@ Field-specific fingerprints, not whole-workspace revision comparisons, determine ## Authentication and capability model -The current collaboration model is capability-based: possession of a secret workspace token grants access, and only a hash is persisted server-side. Slice #291 preserves that simplicity while separating privileges. +The collaboration/Classroom model is capability-based: possession of the correct secret grants only the authority represented by that capability, while raw secrets are never persisted where a hash suffices. -Implemented credential types: +Primary #312 credential chain: -- **Instructor class capability** — administers exactly one class and may enumerate that class's classroom workspace metadata. -- **Student class join capability** — admits a join attempt into one class but cannot enumerate assignments/workspaces. -- **Assignment capability** — identifies exactly one individual/group workspace inside the admitted class. -- **Student workspace capability** — minted after successful join and accepted by the existing editable collaboration session/presence APIs. -- **Case Study unlock password** — instructional gating only; never authentication. +- **Instructor class capability** — administers exactly one class; may list that class's roster/workspaces, assign/reassign Students, observe read-only, coach, and access Instructor-scoped protected teaching resources. +- **Human Student join code** — low-entropy admission locator only; cannot enumerate or edit anything. +- **Student class-session capability** — high-entropy stable participant credential; may read only that Student's assignment state and request current assignment access. +- **Student workspace capability** — assignment-specific `classroom-student` editable alias into exactly one collaboration workspace; revoked/rotated on reassignment or unassign. +- **Case Study unlock password** — instructional progression control only; never server authentication. -A Student must present the matching **class join + assignment** pair. Cross-class pairs fail generically. +Legacy compatibility chain: +- **Student class join capability + assignment capability** still authorize the older two-code admission endpoint and mint a per-participant editable workspace capability. -Raw bearer capabilities are never persisted where a hash suffices. Capabilities travel in Authorization headers or request bodies as appropriate, not API query strings. Responses remain no-store/no-referrer. +The Student class-session capability is never accepted by collaboration edit, Instructor observer, or Instructor coaching endpoints. The human join code is never accepted outside admission. Cross-class assignment attempts fail generically. -`collaboration_workspace_capabilities` is deliberately an **editable alias** path. Only explicitly allowed edit kinds may resolve there; slice #291 allows `classroom-student` only. Slice #293 keeps the Instructor class capability outside that alias table and authorizes observation through the separate GET-only `/api/classes/observe` path so Instructor access cannot inherit snapshot PUT/PATCH capability. +Raw bearer capabilities travel in Authorization headers or request bodies as appropriate, never API query strings. Private responses remain no-store/no-referrer. -## Student assignment - -The API, not the client, decides which workspace a student may join. +`collaboration_workspace_capabilities` is deliberately an **editable alias** path. Only explicitly allowed edit kinds may resolve there; `classroom-student` is the Classroom edit kind. Instructor authority stays outside that alias table and uses separate server-enforced administration/GET-only observation paths so Instructor access cannot inherit snapshot PUT/PATCH capability. -Slice #291 implements: +## Student assignment -- individual: the first opaque participant UUID to claim an assignment binds that assignment; another participant UUID cannot claim it; -- group: multiple participants can use the same assignment capability and receive distinct editable workspace capabilities for one shared collaboration workspace. +The server, not the browser, owns live assignment. -The class join endpoint has no list operation. A Student receives workspace metadata only after the matching class-join and assignment capabilities authorize one workspace. +#312 models assignment on the class participant separately from admission: +- an admitted Student may have `workspace_id = null` and remain Waiting; +- Instructor `PATCH /api/classes/participants` assigns, reassigns, or unassigns only within the represented class; +- `assignment_revision` increments only on a real assignment change and gives the Student a monotonic change signal; +- old assignment-specific workspace authority is revoked before assignment metadata changes; +- destination access is minted only later when the Student requests current access; +- a stale old-team token must fail rather than being remapped to the destination workspace; +- no assignment operation reads, copies, or merges Intake snapshots; +- individual live workspaces remain single-participant; group workspaces support multiple distinct participant capabilities. -Display name remains presentation metadata, not identity. This is still a capability system: deliberately sharing an already-issued workspace capability delegates that capability. +The Student own-status endpoint has no roster/workspace enumeration. Display name remains presentation metadata, not identity. -Slice #292 makes that resume contract concrete: the browser retains only the issued Student workspace capability plus public class/workspace context and participant display metadata. Student join and assignment capabilities are not retained after success. Resume/recovery keys stay outside `kt-intake-full-v2`, file export, templates, and summaries. +The legacy #291/#292 assignment-capability semantics remain supported behind the explicit two-code recovery path. Resume/recovery keys for both models stay outside `kt-intake-full-v2`, file export, templates, and summaries. ## Instructor observation @@ -233,16 +251,18 @@ Implemented state (#295): Continue the existing Neon/Vercel approach unless evidence justifies a platform migration. -Slice #291 adds these domain tables: +The Classroom persistence model is additive: -- `collaboration_workspace_capabilities` for explicitly editable aliases into existing collaboration workspaces; -- `classroom_classes` for class metadata, retention, Instructor capability hash, and Student-join capability hash; -- `classroom_workspaces` for individual/group assignment metadata and assignment capability hashes; -- `classroom_memberships` for participant-to-workspace membership and per-participant workspace capability hashes. +- `collaboration_workspace_capabilities` — explicitly editable aliases into existing collaboration workspaces; +- `classroom_classes` — class metadata/retention, Instructor capability hash, legacy Student-join hash, and #312 human join code; +- `classroom_workspaces` — individual/group workspace metadata plus legacy assignment capability hashes; +- `classroom_memberships` — legacy two-code participant-to-workspace compatibility; +- `classroom_participants` — #312 live participant identity, optional current assignment, `assignment_revision`, Student class-session capability hash, and current workspace-access capability hash; +- `classroom_coaching_feedback` — separate class/workspace/target coaching channel. -Slice #294 adds `classroom_coaching_feedback` as a separate class/workspace/target channel. Slice #295 adds no new database table: protected Case Study access reuses the existing Instructor class capability and Student classroom membership capability. Case-level assignment metadata remains a possible future narrowing layer. +Protected Case Study delivery (#295) adds no public/browser data table and remains server-gated. #313 should add exercise/stage state separately from Intake snapshots and from participant authorization; it must not overload collaboration revision or coaching state. -Schema initialization/migration must be idempotent and documented. Avoid making browser boot depend on schema creation for Standalone. +Schema initialization/migration must remain idempotent and documented. Browser boot for Standalone must never depend on Classroom schema creation. ## Security boundaries diff --git a/docs/classroom-live-management.md b/docs/classroom-live-management.md new file mode 100644 index 0000000..e539895 --- /dev/null +++ b/docs/classroom-live-management.md @@ -0,0 +1,715 @@ +# Classroom Live Management Architecture + +## Purpose + +This is the implementation contract for #312, the first post-foundation Classroom slice. + +The #288 foundation proved secure Student/Instructor roles, collaboration, observation, coaching, protected Case Studies, and required real-browser CI. #312 changes the **operating model** from capability-oriented setup to an Instructor-run live class: + +```text +Instructor starts class + -> product shows one human-friendly Student join code + -> Students enter the class and may wait unassigned + -> Instructor creates teams / individual workspaces + -> Instructor assigns or reassigns Students live + -> Student automatically reconnects to the assigned workspace + -> Instructor observes/coaches teams from one dashboard +``` + +#313 staged Case Study simulation builds on this substrate. Do not implement staged exercise state inside #312. + +## Product invariants + +1. **Start Class is the normal Instructor path.** A normal Instructor should not have to invent or type an Instructor bearer capability. +2. **Students type one human join code plus a display name.** The current class-capability + assignment-capability UI is transitional. +3. **The human join code is not workspace authorization.** It may admit a participant into one class, but cannot enumerate the class or edit an Intake. +4. **An admitted Student may be unassigned.** Late arrivals must be able to wait safely while a class/exercise is already running. +5. **Instructor assignment is server-authoritative.** Moving a Student changes the workspace they are authorized to edit. +6. **Reassignment never merges Intake snapshots.** The destination team's current Intake is authoritative; the old team's Intake stays with the old team. +7. **Old edit authority stops on reassignment.** A Student must not retain indefinite write access to a previous team. +8. **Instructor observation remains read-only.** Team management does not route Instructor authority through editable collaboration aliases. +9. **Coaching stays workspace-scoped.** Existing feedback remains attached to the workspace/reasoning it reviewed; reassignment does not silently copy it. +10. **Student credentials cannot enumerate classmates, teams, or other workspaces.** +11. **Standalone remains backend-optional and unchanged.** +12. **Legacy Classroom sessions remain compatible during migration.** Do not break existing saved Student/Instructor sessions in the first #312 server tranche. + +## Why one stable workspace edit token is unsafe + +Do **not** implement reassignment by simply moving one active Student edit token from Workspace A to Workspace B. + +An old in-flight PUT may have been created from Workspace A state. If the same token suddenly resolves to Workspace B, that stale request could overwrite Team B with Team A's snapshot. + +The safe contract is: + +- Student **class session** capability is stable across assignment changes and is not accepted by collaboration edit endpoints. +- Student **workspace edit** capability is assignment-specific. +- Instructor reassignment revokes the old workspace edit capability before a new destination capability can be used. +- The Student obtains a fresh workspace capability after observing the new assignment. +- A late old request can therefore affect only its old workspace before revocation, or fail after revocation. It can never be reinterpreted as a write to the destination workspace. + +## Human-facing join code + +### Format + +Target an 8-character unambiguous uppercase code, displayed in two groups such as: + +```text +K7FM-P4Q2 +``` + +Use an alphabet that avoids easily confused characters such as `0/O` and `1/I`. Normalize case and separators on input. + +The exact alphabet is an implementation detail; the security properties are not: + +- code is random and class-scoped; +- code is unique among active classes; +- code expires/revokes with the class; +- code can be rotated by the Instructor; +- joins can still be globally enabled/disabled per class. + +### Security classification + +The join code is an **admission locator**, not a privileged bearer capability. + +It may be stored server-side in normalized form so the authorized Instructor dashboard can redisplay it. Possession of the code can at most attempt class admission; it cannot: + +- list participants; +- list workspaces; +- observe Student Intake; +- retrieve protected Case Studies; +- write coaching; +- edit a workspace. + +Public exposure may eventually justify rate limiting / abuse controls. That operational hardening is compatible with this contract. + +## Capability model + +### Instructor capability + +High-entropy bearer returned once by class creation and persisted only in the Instructor's separate same-device Classroom session envelope. + +Authority: +- administer exactly one class; +- read join code; +- list participants/workspaces; +- create/rename workspaces; +- assign/reassign/unassign participants; +- use existing observer/coaching/protected-teaching APIs. + +It is never an editable collaboration alias. + +### Student class-session capability + +High-entropy bearer minted at admission. + +Authority: +- read only that participant's class/assignment status; +- request a workspace access capability for the participant's **current** assignment; +- leave/revoke own class session if implemented. + +It cannot enumerate participants or workspaces and is not accepted by `/api/workspaces/*`. + +The Student client persists this token for same-device class resume. + +### Student workspace capability + +High-entropy, assignment-specific editable collaboration alias using the existing `classroom-student` capability kind. + +Authority: +- existing collaboration session/presence APIs for exactly one current workspace; +- existing Student coaching/protected Case Study reads that resolve active membership. + +On reassignment/unassignment, this capability is revoked before the Student can receive destination edit access. + +## Additive persistence model + +Do not mutate the legacy two-code membership contract destructively in the first tranche. Add the live-class model beside it, then move the UI after server behavior is proven. + +### `classroom_classes` + +Add a human-facing join code field, conceptually: + +```text +student_join_code VARCHAR(...) UNIQUE +``` + +The existing high-entropy `student_join_token_hash` remains temporarily for the legacy `/api/classes/join` path. + +### `classroom_participants` + +New canonical live-class admission record: + +```text +class_id BIGINT +participant_id UUID +display_name VARCHAR(60) +session_token_hash CHAR(64) UNIQUE +workspace_id BIGINT NULL +workspace_access_token_hash CHAR(64) UNIQUE NULL +assignment_revision INTEGER NOT NULL DEFAULT 0 +joined_at TIMESTAMPTZ +updated_at TIMESTAMPTZ +assigned_at TIMESTAMPTZ NULL +revoked_at TIMESTAMPTZ NULL + +PRIMARY KEY (class_id, participant_id) +FOREIGN KEY (class_id, workspace_id) + -> classroom_workspaces(class_id, workspace_id) +``` + +Interpretation: + +- `workspace_id IS NULL` = waiting/unassigned; +- `assignment_revision` increments on every assign, reassign, or unassign; +- `session_token_hash` authorizes only Student class-session APIs; +- `workspace_access_token_hash` records the currently issued assignment-specific collaboration capability, if any; +- raw capabilities are never stored. + +Existing `classroom_memberships` remains valid for legacy sessions until a later explicit migration/removal. + +## Implementation checkpoint — additive persistence + +Completed on #314 before any Student UI cutover: + +- human join-code helpers enforce the eight-character unambiguous alphabet and normalize display separators/case; +- `classroom_classes.student_join_code` is additive and uniquely indexed when present; +- new `classroom_participants` persists admitted live-class participants separately from legacy `classroom_memberships`; +- a participant may exist with `workspace_id = NULL` as a first-class waiting/unassigned state; +- Student class-session capability hashes are independent from assignment-specific workspace-access hashes; +- re-admitting the same participant may rotate the class-session capability and update display name without inventing a workspace assignment; +- Instructor-scoped participant listing and Student-session lookup repository primitives are in place; +- legacy two-code admission and `classroom_memberships` remain unchanged. + +Durable implementation commits: +- `99c31f2e9c59180ed297db96512b62b310692b4c` — additive schema/repository primitives; +- `fa6180ada0183dfc4b19790925e61deea60bdfbf` — participant composite FK preserves required class identity when a workspace is deleted; +- `5665637e6f550bebb861c73109e991b6e96a73c9` — deterministic in-memory participant/session model; +- `df3633d008bb7e352509e15c1964b7d27ebf441f` — focused join-code/waiting-roster/session-rotation tests. + +Validation on `df3633d...`: +- canonical repository quality: **241 tests, 1 intentional skip**; +- required Browser E2E: **22 passed, 6 intentional project-scoped skips, 0 failed**; +- CodeQL, Dependency Review, and Template Manifest Guard: green. + +**Exact next tranche:** implement the new live-class HTTP layer around these primitives: +- Start Class emits a generated human join code while preserving the legacy high-entropy join token for compatibility; +- `POST /api/classes/admit` creates/rotates a waiting Student class session; +- `GET /api/classes/student` returns only the represented Student's own assignment state; +- `POST /api/classes/student/access` issues assignment-specific edit authority only when assigned; +- `GET /api/classes/participants` gives only the Instructor their live roster. + +Do not implement assign/reassign UI or cut over the current Student client until these APIs and their authorization tests are green. + +## API contract + +Names below are the intended #312 contract. Keep route files thin and handlers injectable. + +### Existing `POST /api/classes` — Start Class + +Normal Instructor Start Class calls the existing create endpoint with a title. + +New response adds a human join code: + +```json +{ + "class": { "id": "...", "title": "...", "joinsEnabled": true, "expiresAt": "..." }, + "instructorToken": "", + "joinCode": "K7FM-P4Q2" +} +``` + +During compatibility migration, the server may also return the existing legacy `studentJoinToken`; the new Instructor UI must not depend on or present it. + +Instructor client: +- persists `instructorToken` in the existing Instructor session envelope; +- immediately enters the dashboard; +- displays/copies the Student join code; +- supports join-code rotation and joins on/off. + +### `POST /api/classes/admit` — Student admission + +No Instructor/workspace capability is accepted. + +Request: + +```json +{ + "joinCode": "K7FM-P4Q2", + "participantId": "", + "displayName": "Alex" +} +``` + +Success returns only the represented Student's class/session context: + +```json +{ + "class": { "id": "...", "title": "...", "expiresAt": "..." }, + "participant": { + "id": "...", + "displayName": "Alex", + "assignmentRevision": 0 + }, + "assignment": null, + "studentSessionToken": "" +} +``` + +Admission creates the waiting participant immediately. Re-admission by the same class + participant ID may update display name and rotate that participant's Student session capability while preserving current assignment. + +Failures remain generic enough to avoid class enumeration. + +### `GET /api/classes/participants` — Instructor roster + +Instructor capability only. + +Returns admitted participants including unassigned/late arrivals and current assignment metadata, but no raw capability values. + +Representative record: + +```json +{ + "id": "...", + "displayName": "Alex", + "joinedAt": "...", + "assignmentRevision": 3, + "assignment": { + "id": "...", + "kind": "group", + "label": "Team B" + }, + "activity": { + "state": "editing", + "lastSeenAt": "..." + } +} +``` + +Activity is derived from the currently assigned collaboration workspace when available. + +### `PATCH /api/classes/participants` — assign / reassign / unassign + +Instructor capability only. + +Request: + +```json +{ + "participantId": "...", + "workspaceId": "..." +} +``` + +Use `workspaceId: null` to return a Student to the waiting/unassigned state. + +Server transaction/ordering requirements: + +1. prove participant and destination belong to the Instructor's class; +2. capture the old workspace + old workspace-access hash; +3. revoke the old `classroom-student` workspace capability; +4. remove stale presence from the old workspace; +5. update participant `workspace_id`; +6. clear `workspace_access_token_hash`; +7. increment `assignment_revision`; +8. commit the new assignment metadata. + +Do **not** copy or merge snapshots. + +### `GET /api/classes/student` — Student assignment status + +Requires Student class-session capability. + +Returns only that Student's own status: + +```json +{ + "class": { "id": "...", "title": "...", "expiresAt": "..." }, + "participant": { + "id": "...", + "displayName": "Alex", + "assignmentRevision": 3 + }, + "assignment": { + "id": "...", + "kind": "group", + "label": "Team B" + } +} +``` + +For waiting Students, `assignment` is `null`. + +A later optimization may support `afterAssignmentRevision` / 204 long-poll semantics. It must never add a workspace list. + +### `POST /api/classes/student/access` — obtain current workspace access + +Requires Student class-session capability. + +If unassigned, return a clear authenticated waiting/unassigned response. + +If assigned: + +1. generate a fresh high-entropy workspace capability; +2. create a `classroom-student` alias for the current workspace; +3. store only its hash as the participant's current `workspace_access_token_hash`; +4. revoke any prior active workspace-access capability; +5. return the raw token once with assignment metadata/revision. + +If a response is lost, retry may safely rotate again. No raw token needs server persistence. + +The Student client requests access: +- after initial assignment; +- after assignment revision changes; +- on resume when its locally cached workspace access is missing/stale. + +## Implementation checkpoint — live HTTP admission/status/access + +Completed on #314 after the additive persistence tranche and before any Student UI cutover. + +Implemented: +- Start Class now generates and returns a formatted human Student join code while preserving the legacy high-entropy `studentJoinToken` for compatibility; +- `POST /api/classes/admit` accepts only join code + participant ID + display name and returns a high-entropy Student class-session token; +- admitted Students may remain waiting/unassigned with no collaboration edit authority; +- `GET /api/classes/participants` is Instructor-only and lists only the represented class; +- `GET /api/classes/student` returns only the represented Student's own assignment state and never a roster/workspace catalog; +- `POST /api/classes/student/access` exchanges a Student class-session token for a fresh assignment-specific `classroom-student` workspace capability only when assigned; +- rotating current workspace access revokes the prior edit token; +- the class-session token itself is not accepted by collaboration workspace APIs; +- live workspace-access tokens now resolve through the existing Student coaching/protected-resource membership boundary, while legacy membership tokens continue to work; +- workspace-access issuance checks class/workspace/assignment revision before persisting the token so a concurrent assignment change cannot silently bind stale access to the wrong team. + +Durable implementation commits: +- `746559e67620aab3457b6cda90e42168d5350dde` — Start Class join code + live workspace-access repository path + live/legacy Student context resolution; +- `1b81d4a4c80a7fe16cee5523d6c13811f51f20b3` — deterministic repository parity for live access; +- `2d6f92d05c693c35818e4457685c311c66a65807` — live admission/roster/status/access handlers; +- `11a83da6d96588022b8879b3c5ca1e6961e75ed8` through `dc698c1103285efe95932c253f6cdbd1172b3227` — Vercel route entrypoints; +- `04f95f24dd63d2ce75260034d75c9c514d07e84e` — focused HTTP authorization tests. + +Validation on `04f95f2...`: +- repository quality: **244 tests / 243 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **22 passed / 6 intentional project-scoped skips / 0 failed**; +- CodeQL, Dependency Review, and Template Manifest Guard: green. + +## Implementation checkpoint — assign / reassign / unassign authority + +Completed on #314 before any live team-management UI cutover. + +Implemented: +- Instructor-only `PATCH /api/classes/participants` assigns, reassigns, or unassigns one admitted Student; +- assignment is class-scoped: participant and destination workspace must both belong to the Instructor's represented class; +- `workspaceId: null` returns the Student to waiting/unassigned; +- assignment changes increment `assignmentRevision`; repeating the same assignment is idempotent and does not increment; +- any current assignment-specific edit capability is revoked **before** participant assignment metadata changes; +- stale collaboration presence is removed from the old workspace by internal workspace ID after revocation; +- the participant's stored workspace-access hash is cleared on assignment change; +- destination access is never issued by the Instructor assignment operation; the Student must later call `POST /api/classes/student/access`; +- cross-class destinations are rejected without changing assignment; +- individual live workspaces enforce one participant; +- leaving an individual workspace releases its individual claim for later reassignment; +- no assignment operation reads, copies, or merges Intake snapshots; +- live Student coaching/protected-resource context now additionally requires an active, unrevoked `classroom-student` collaboration alias, closing the stale stored-hash authorization window; +- optimistic assignment-revision protection means a concurrent conflict can fail closed with old authority revoked rather than risk stale authority surviving or being retargeted. + +Durable implementation commits: +- `742240e92156a9d43a96752a2cb6acc5908f4172` — server-only presence cleanup by workspace ID; +- `2c1d6fdf6872eb1390424503f08692a581da6322` — deterministic presence-cleanup parity; +- `b48e6bbb71c2d6ec818ca1f69b656b3bffbed473` — live assignment repository/HTTP semantics plus active-alias validation for Student context; +- `a382acad42de25f4fa1ae217acf34d69a047606b` — deterministic assignment transition model; +- `7bfd662e2dcbe5511a4065b7b19222129485d08a` — full Waiting -> Team A -> Team B -> Waiting authority-cutoff and individual-workspace tests. + +Validation on `7bfd662...`: +- repository quality: **246 tests / 245 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **22 passed / 6 intentional project-scoped skips / 0 failed**; +- CodeQL, Dependency Review, and Template Manifest Guard: green. + +**Exact next tranche:** build the Instructor live-management client on this proven server contract: +1. Start Class as the primary Instructor action; +2. persist the Instructor capability for same-device resume; +3. display/copy the human Student join code; +4. show Waiting/Unassigned plus team/individual workspace roster; +5. create team/individual workspaces; +6. assign/reassign/unassign through accessible button/select controls first; +7. keep drag-and-drop optional/progressive enhancement, never the only assignment path; +8. wire observer/coaching navigation from each workspace; +9. extend the deterministic browser fixture and real-browser E2E for Instructor start/roster/team assignment before cutting over the Student client. + +Do not implement Student one-code waiting/automatic reassignment UI until the Instructor management surface is browser-green. + +## Implementation checkpoint — Instructor live-management client + +Completed on #314 after the server assignment-authority tranche and before Student UI cutover. + +Implemented: +- Instructor entry now makes **Start a class** the primary path while retaining **Open an existing class** as a collapsed recovery/advanced path; +- Start Class creates the class from a title, persists only the Instructor capability plus public class/join-code context for same-device resume, and enters the dashboard immediately; +- the dashboard displays the human Student join code with a copy affordance; +- live polling combines class workspaces with the Instructor-only participant roster; +- Waiting/Unassigned Students are visible before they have any workspace authority; +- Instructor can create Team or Individual workspaces from the management panel; +- accessible per-Student assignment selectors support assign, reassign, and unassign without requiring drag-and-drop; +- workspace cards count live `classroom_participants` as well as legacy memberships; +- selecting a workspace continues to use the existing server-enforced read-only observer and coaching integration; +- the Instructor client ignores the legacy workspace `assignmentToken` returned for compatibility and never stores or reuses it; +- same-device reload resumes the live class, current join code, participant roster, assignment state, and selected observer workspace; +- the old existing-class recovery path remains covered and explicitly opens its disclosure before accepting the Instructor capability; +- dynamic management controls are explicitly marked local-only so they never enter Intake persistence/export state. + +Browser fixture / regression coverage: +- deterministic fixture now supports Start Class, live workspaces, live participant roster, assignment PATCH, and live observation; +- real-browser journey proves: Start Class -> join-code display -> Waiting Student -> create Team Alpha -> assign Student -> read-only observe Team Alpha -> reload/resume; +- the journey asserts no legacy `/api/classes/join` use and no Instructor request body contains an assignment token; +- serious/critical axe violations and uncaught browser errors remain fatal; +- existing Instructor observer/coaching/negative-authorization journeys continue through the intentional collapsed existing-class recovery path. + +Durable implementation commits: +- `0d004552db82660dc723fd37a79ef4e46d197083` — live Instructor shell / primary Start Class UX; +- `9c26bdf1a546428092af44dcf342cb2eae0d44a4` — live-management layout and responsive controls; +- `f2306021eaa4cb95fd34e72940233fc0a99bcb3e` through `a2ae981e0a902f3c143446fec2fa1e77ef346d1f` — join-code/session, live roster, Start Class, workspace creation, assignment, copy, and lifecycle controller logic; +- `40f6ece94c47d026ac201e0f434f51329d38c604` + `8fe9e4438b18684377382c7e7cadd68208da1c53` — count live participants in server/deterministic workspace rosters; +- `09daa729f4b2a32e0c913985b741771ace34dc5f` + `709cd910de22189b1d362f58aa58c3c5abbf2642` — Instructor feature/unit coverage; +- `2a76541675217aff46f90c255c33e40968db718f` + `e23b4cd8cf998b313418706bc36b7cf2d175b1db` — live-management browser fixture; +- `692c81aa9c0007d94f2d91fe36bb34160ff9173c` — live Instructor Playwright journey; +- `6122ba21719ae6823095331f429e9c337bf3713d` — source-level local-only annotations required by the persistence guard; +- `205b0edc504e9978eb23e7f637ab14d811d9f088` through `d3e2a397cd76fc75e62516ed63ad76231bb4812c` — legacy Instructor browser journeys explicitly use the advanced existing-class recovery disclosure. + +Validation on `d3e2a397...`: +- repository quality: **248 tests / 247 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **23 passed / 7 intentional project-scoped skips / 0 failed**; +- CI, CodeQL, Dependency Review, and Template Manifest Guard: green. + +## Implementation checkpoint — Student live-class client + +Completed on #314 after the Instructor live-management surface. + +Implemented: +- normal Student entry is now **display name + one human class code**; +- legacy class + assignment capability entry remains available under **Use legacy two-code access** for additive compatibility; +- `POST /api/classes/admit` creates the live Student session and the human code is discarded after admission; +- the same-device live resume envelope persists only the high-entropy Student class-session capability plus public class/participant/assignment context; +- assignment-specific workspace edit capabilities are **memory-only** and intentionally rejected if present in a live resume envelope; +- an admitted unassigned Student renders a dedicated Waiting state, hides Intake, does not connect collaboration, and polls only own assignment status; +- assignment exchanges the class-session capability through `POST /api/classes/student/access`, then attaches the existing collaboration controller; +- reassignment disconnects/discards old workspace authority before requesting destination access; destination Intake is authoritative and no snapshot merge occurs; +- unassign disconnects collaboration and returns the Student to Waiting while retaining class-session authority; +- role changes pause/disconnect live workspace activity without destroying the resumable class session; +- terminal class-session failure clears invalid Classroom authority and restores pre-class local recovery where available; +- coaching and protected-resource Student integrations continue to receive only the currently active assignment-specific workspace token. + +Compatibility: +- legacy two-code Student join/resume remains supported; +- current legacy browser authorization, coaching, protected Case Study, and team-collaboration journeys explicitly open the recovery disclosure and remain green; +- no legacy routes/tables were removed. + +Deterministic/browser coverage: +- focused tests prove one-code Waiting and Waiting -> Team Alpha -> Team Beta -> Waiting lifecycle; +- live resume tests prove no assignment-specific `workspaceToken` is persisted; +- the isolated browser fixture rotates fresh workspace capabilities while preserving canonical Team A/B snapshots; +- real-browser desktop + mobile coverage proves one-code join -> Waiting -> Team Alpha -> edit -> reload/resume Alpha -> Team Beta -> Waiting; +- the browser test proves destination Team Beta does not inherit Team Alpha Intake; +- stale Alpha access returns 404 after reassignment and stale Beta access returns 404 after unassign; +- serious/critical axe scanning caught the Waiting eyebrow contrast at 3.72:1; the Waiting surface now uses the existing accessible `var(--accent-text)` foreground; +- all legacy two-code browser journeys remain available through the explicit recovery disclosure. + +Durable implementation commits: +- `47485439c34fc7237c28f93619924e2da87b084a` / `73792b71db9ec7965c36c2b905ebc09bb699ea78` — primary one-code Student UX, Waiting surface, and legacy recovery disclosure; +- `d32c7ec331daa5010f9617c6b332756e9395d35b` — live Student admission/status/access/resume/reassignment lifecycle; +- `db54b1aa84dc4d43802aa568b811b6974a84c02c` / `a3d520655c928abbeb4cb63e592593a38a969cd8` — live session-isolation and lifecycle tests; +- `8485b2ca1a5ba59fb98d4aae0398a9052fab91b6` / `2fd0d53c0fd8e4e4cac0298aca9f5ed7ad930a94` — isolated live Student browser fixture and side-effect-free Waiting behavior; +- `50f6260a251793c0794945d941ad4ac1c97d4e8d` — live Student Playwright journey; +- `30dbb0d807793fc503b4933c24ffa7e2ff50d8ba` through `1b90c0f40238769d6ab17ce9bfe7d44fc391a212` — legacy Student browser journeys explicitly enter two-code recovery; +- `0c8692c7486ecb50c730d0757c41426b81830250` — Waiting-state WCAG contrast repair and green implementation gate. + +Validation on `0c8692c...`: +- repository quality: **252 tests / 251 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **25 passed / 7 intentional project-scoped skips / 0 failed**; +- CI, CodeQL, Dependency Review, and Template Manifest Guard: green. + +## Implementation checkpoint — integrated live-class acceptance + +Completed after the Instructor and Student live clients were independently browser-green. + +The deterministic browser server now has a parallel-safe integrated classroom namespace with its own Instructor capability, human join code, workspace IDs, participant roster, and shared workspace-state maps. This keeps the integrated acceptance journey isolated from the existing Instructor-only and Student-only fixtures when Playwright runs specs concurrently. + +The real-browser journey in `tests/e2e/classroom-live-integration.spec.mjs` drives four browser contexts against one class: +- Instructor starts **Integrated Browser Classroom** and the test reads the actual join code rendered by the Instructor UI; +- Instructor creates Team Alpha and Team Beta; +- two Students join through that displayed one-code path and appear Waiting in the Instructor roster; +- both Students are assigned to Team Alpha through the accessible assignment selectors; +- one Student edits Team Alpha and the second Student converges on the same Intake; +- a late Student joins after Team Alpha work has begun, appears Waiting, and is assigned to Team Beta; +- Team Beta edits remain isolated while both Team Alpha Students retain Team Alpha state; +- Instructor opens Team Alpha read-only and observes the actual shared Student snapshot; +- Instructor saves field-level **Needs improvement** coaching and a Team Alpha Student receives it read-only; +- Instructor moves Student Alpha One from Team Alpha to Team Beta; +- the moved Student lands on Team Beta's existing snapshot rather than carrying Team Alpha content across; +- the remaining Team Alpha Student stays on Team Alpha unchanged; +- the moved Student's old Team Alpha workspace token returns 404; +- Instructor unassigns that Student, the Student returns to Waiting, and the old Team Beta token also returns 404; +- both destination-team and remaining-team state remain intact; +- serious/critical axe violations and uncaught page errors remain fatal. + +Fixture implementation: +- `5f1404921eeec8b2aba3103672be547af35b6865` shares managed Instructor workspace state with Student collaboration/observation; +- `4f36af2d5221234a9ebf35d574f287f7aa52bb39` connects one-code Student admission/assignment to the Instructor-managed class; +- `e7b8d06111b8dbda6ba6a27372956b3d012899c3` enables coaching on managed live workspaces; +- `2262490c9de7da5a9b675932fda2c84475977168` adds the integrated four-browser acceptance journey; +- `971898cb4eca725ad079be5c8ba5c97484fdab2f`, `6c712e47383079d5aa50869be4e9d9a26def28e9`, and `9315210dd15ed583cfbd3201302699d025ded3a1` isolate and route managed fixtures by capability so parallel specs cannot reset or mutate each other's class state. + +Validation on `9315210...`: +- repository quality: **252 tests / 251 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**; +- CI, CodeQL, Dependency Review, and Template Manifest Guard: green. + +This closes the browser acceptance matrix listed below for #312 without adding #313 staged exercise/case state. + +**Exact next tranche:** final #312 security/docs/merge-readiness review. Reconcile the canonical API/architecture docs with the implemented one-code live lifecycle, review the complete `main -> #314` diff and unresolved review/security state, verify rollback/legacy compatibility language, and make the draft PR ready for review only if the documented head remains green. Keep #313 as the next separate product slice. + + + +## Student client lifecycle + +### Waiting + +After admission: +- retain Student class-session capability; +- discard the human join code; +- show class/participant context and a waiting-for-assignment state; +- do not connect collaboration; +- poll only the Student assignment-status endpoint. + +### Assigned + +When assignment appears: +- request current workspace access; +- connect the existing collaboration controller with the returned assignment-specific token; +- show the normal Student Intake and class/team chrome. + +### Reassigned + +When `assignmentRevision` changes: + +1. stop/leave old collaboration locally; +2. discard old local workspace access token; +3. request current workspace access; +4. connect to destination workspace from a clean collaboration revision; +5. render destination snapshot as authoritative; +6. notify the learner that the Instructor moved them. + +Never merge old local/team Intake into the destination. + +### Unassigned after work has begun + +Disconnect collaboration and return to waiting state. Keep the Student class-session capability so the Instructor can assign them again without a new join code. + +### Terminal class/session failure + +Retain the existing safe recovery philosophy: clear invalid Classroom credentials and restore pre-class local Intake where appropriate. + +## Instructor experience contract + +### Start Class + +Instructor experience should offer: +- **Start a class** as the primary path; +- optional **Resume/Open existing class** as recovery/advanced path; +- title input; +- immediate dashboard after creation; +- prominent Student join code with copy/share affordance. + +A QR/share link can be added after the code contract is stable. If a join code is included in a URL later, prefer a client-side fragment or another design that avoids accidental referrer/log leakage. + +### Live roster and teams + +Dashboard should expose: +- Waiting / Unassigned; +- each team/individual workspace; +- participant count and names; +- active/editing state; +- coaching / needs-improvement / changed-since-review signals where available. + +Instructor can: +- create/rename workspaces; +- assign a waiting Student; +- move a Student between teams; +- unassign a Student; +- observe/coach the selected team. + +Drag-and-drop may be offered, but accessible buttons/keyboard interaction are the canonical contract. + +## Reassignment content policy + +When moving Student A from Team A to Team B: + +- Team A keeps its existing Intake unchanged; +- Team B keeps its existing Intake unchanged; +- Student A begins seeing/editing Team B after reconnect; +- Student A's local copy does not overwrite Team B; +- no automatic field merge is attempted; +- coaching records remain attached to the workspace they reviewed. + +This is intentionally predictable for a facilitated exercise. + +## Compatibility plan + +The first #312 server tranche is additive. + +Keep working: +- legacy `POST /api/classes/join`; +- legacy `classroom_memberships`; +- existing Student saved workspace sessions; +- existing Instructor bearer resume; +- current observation/coaching/protected-resource paths. + +The normal Student UI now uses admit/status/access because the repository/API safety matrix, coaching/protected-resource authorization, and browser waiting/reassignment coverage are green. The legacy two-code path remains available under an explicit recovery disclosure during the additive migration. + +Do not delete legacy tables/routes in #312 unless a separate migration explicitly proves no supported client depends on them. + +## Security tests required before UI cutover + +Server/API: +- join-code normalization and active-class uniqueness; +- invalid/expired/rotated join code rejected generically; +- join code cannot enumerate participants/workspaces; +- admitted unassigned Student cannot access collaboration; +- Student class-session token cannot access collaboration directly; +- Instructor lists only own class participants; +- cross-class workspace assignment rejected; +- assign -> workspace access works; +- reassign -> old workspace token fails and new destination token works; +- unassign -> old workspace token fails and no new access is issued; +- stale old-team request can never resolve as destination-team write; +- another Student's session token cannot fetch this Student's status; +- legacy two-code admission still passes; +- new workspace-access token still works with coaching and protected Case Study Student reads. + +Browser: +- Instructor starts class and sees one Student-facing code; +- Student joins and waits unassigned; +- late Student joins after class work begins; +- Instructor creates Team A / Team B and assigns Students; +- two Team A Students sync one Intake; Team B remains isolated; +- Instructor moves Student A -> B; Student reconnects to B and old A edit access stops; +- accessible non-drag assignment path works; +- Instructor observer/coaching continues across team changes. + +## Rollback boundary + +Because the first implementation is additive: +- old class/join/workspace APIs remain available; +- existing collaboration schema remains authoritative for Intake snapshots; +- new participant/join-code structures can be left unused if the new UI must be rolled back; +- Student UI should not cut over until new API tests and browser fixture are green. + +Schema/data cleanup, if ever required, is a later migration—not part of an emergency rollback. + +## Relationship to staged simulation + +#313 consumes this contract: + +- class exists and has a human Student join path; +- Instructor owns a live roster; +- teams/workspaces can change during class; +- Students have a stable class session independent of current workspace; +- assignment changes are observable and safe. + +Only after those are stable should #313 add exercise/stage state and progressive protected Case Study release. diff --git a/docs/classroom-roadmap.md b/docs/classroom-roadmap.md index 75df2b7..f92f38e 100644 --- a/docs/classroom-roadmap.md +++ b/docs/classroom-roadmap.md @@ -221,7 +221,7 @@ The eight-slice #288 program establishes the secure Classroom foundation; it doe - **#312 — Instructor-run live class, roster, and dynamic team management:** Instructor-created classes, one human-friendly Student join path, waiting/late roster, accessible team formation, live reassignment, automatic Student workspace switching, and high-signal team monitoring. - **#313 — Instructor-orchestrated case simulation and staged debrief:** staged protected Case Study release, case reference beside Intake, Instructor pause/debrief/advance controls, progressive disclosure, team progress, and server-gated future-stage / Instructor-only material. -#312 is the next product slice after #308 closes; #313 should build on #312 rather than bypassing the live-class/team-management substrate. +#312 is implemented on draft PR #314 (`feature/classroom-live-management`): Start Class, one human Student join code, Waiting/late roster, stable Student class sessions, accessible team creation/assignment, safe reassignment/unassign with stale-authority cutoff, observer/coaching integration, and integrated multi-browser acceptance are green. The final #312 gate is documentation/security/merge readiness. #313 remains the next dependent product slice and must start from the merged #312 substrate. ## Stacked PR rules diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md index 0ddf627..79bf91e 100644 --- a/docs/classroom-workstream.md +++ b/docs/classroom-workstream.md @@ -313,6 +313,74 @@ Continue #295 on draft PR #307 from the current branch head. - First required-gate validation on `c9b03f30...`: repository quality succeeded and browser regression reported **22 passed, 6 intentional skips**; CodeQL, Dependency Review, and Template Manifest Guard also green. - Exact next action: synchronize final docs/PR/issues, review the complete `main → #308` diff and unresolved review/security state, then make #308 ready for review/merge if the documented head remains green. After #308 / #296 / #279 close, begin #312 as the next product slice; #313 follows on the live-class/team-management substrate. +## Active #312 implementation + +- Issue: #312. +- Branch: `feature/classroom-live-management`. +- Base: `main` at #308 merge `6c58336ac1677764641c09eadb201abcad8eeac1`. +- First tranche: architecture + server contract only; no schema/runtime mutation until the admission/assignment/reassignment security contract is documented. +- Canonical design: `docs/classroom-live-management.md`. +- Decided model: + - Instructor normal path is **Start a class**; + - one human-friendly Student join code; + - join code is admission-only, not workspace authority; + - admission mints a stable high-entropy Student class-session capability; + - Students may wait unassigned; + - workspace edit capabilities are assignment-specific and revoked on move/unassign; + - Student polls only own assignment status and never receives workspace lists; + - reassignment never merges snapshots; destination team state wins; + - legacy two-code admission remains supported during additive migration. +- Critical race protection: never remap one active workspace edit token from Team A to Team B. Old edit capability is revoked; Student obtains fresh destination access after assignment revision changes. +- Persistence/repository checkpoint: + - `99c31f2e9c59180ed297db96512b62b310692b4c` adds the human join-code column and additive `classroom_participants` model plus live participant/session repository primitives; + - `fa6180ada0183dfc4b19790925e61deea60bdfbf` corrects the participant/workspace FK deletion behavior so required class identity is preserved; + - `5665637e6f550bebb861c73109e991b6e96a73c9` mirrors the model in the deterministic test repository; + - `df3633d008bb7e352509e15c1964b7d27ebf441f` adds focused join-code normalization, waiting-roster, class-scoping, and class-session-rotation tests. + - validation: canonical quality **241 tests / 1 intentional skip**; required Browser E2E **22 passed / 6 intentional skips / 0 failed**; CodeQL, Dependency Review, and Template Manifest Guard green. + - current Student UI and legacy `POST /api/classes/join` remain unchanged. +- HTTP admission/status/access checkpoint: + - `746559e67620aab3457b6cda90e42168d5350dde` adds Start Class join-code return, race-safe live workspace-access issuance, and live/legacy Student-context resolution; + - `1b81d4a4c80a7fe16cee5523d6c13811f51f20b3` mirrors live access in the deterministic repository; + - `2d6f92d05c693c35818e4457685c311c66a65807` adds one-code admission, Instructor participant roster, Student own-status, and Student current-workspace access handlers; + - route entrypoints land in `11a83da6...` through `dc698c110...`; + - `04f95f24dd63d2ce75260034d75c9c514d07e84e` adds the focused authorization matrix. + - validation: repository quality **244 tests / 243 pass / 0 fail / 1 intentional skip**; required Browser E2E **22 passed / 6 intentional skips / 0 failed**; CodeQL, Dependency Review, and Template Manifest Guard green. + - current Student UI remains on the legacy two-code path; no live team-management UI has been cut over yet. +- Assign/reassign/unassign authority checkpoint: + - `742240e92156a9d43a96752a2cb6acc5908f4172` adds server-only stale-presence cleanup by internal workspace ID; + - `2c1d6fdf6872eb1390424503f08692a581da6322` mirrors that cleanup in the deterministic workspace harness; + - `b48e6bbb71c2d6ec818ca1f69b656b3bffbed473` adds Instructor-only assignment transitions, optimistic revision protection, individual-workspace occupancy enforcement, old-authority revocation before assignment mutation, and active-alias validation for live Student coaching/resource context; + - `a382acad42de25f4fa1ae217acf34d69a047606b` mirrors live assignment transitions in the deterministic Classroom repository; + - `7bfd662e2dcbe5511a4065b7b19222129485d08a` proves Waiting -> Team A -> Team B -> Waiting, idempotent same-team assignment, cross-class rejection, stale-token write rejection, old-presence cleanup, no snapshot merge, and individual-workspace single occupancy. + - validation: repository quality **246 tests / 245 pass / 0 fail / 1 intentional skip**; required Browser E2E **22 passed / 6 intentional skips / 0 failed**; CodeQL, Dependency Review, and Template Manifest Guard green. + - current Instructor and Student UIs are still on the pre-#312 experience; the new management surface has not been cut over. +- Instructor live-management client checkpoint: + - `0d004552db82660dc723fd37a79ef4e46d197083` / `9c26bdf1a546428092af44dcf342cb2eae0d44a4` establish the primary Start Class shell, join-code panel, live roster/team controls, and responsive layout while keeping Open existing class as recovery. + - `f2306021eaa4cb95fd34e72940233fc0a99bcb3e` through `a2ae981e0a902f3c143446fec2fa1e77ef346d1f` wire Instructor session/join-code state, combined workspace+participant polling, Start Class, workspace creation, assignment, copy, resume, and observer lifecycle. + - `40f6ece94c47d026ac201e0f434f51329d38c604` / `8fe9e4438b18684377382c7e7cadd68208da1c53` make live participants count in workspace member totals. + - `09daa729f4b2a32e0c913985b741771ace34dc5f` / `709cd910de22189b1d362f58aa58c3c5abbf2642` add Instructor feature/unit coverage. + - `2a76541675217aff46f90c255c33e40968db718f` / `e23b4cd8cf998b313418706bc36b7cf2d175b1db` extend the deterministic browser fixture; `692c81aa9c0007d94f2d91fe36bb34160ff9173c` adds the real-browser Start Class -> Waiting -> create Team -> assign -> observe -> reload/resume journey. + - the first browser run exposed only an intentional-UX test mismatch: old Instructor tests tried to type into the now-collapsed existing-class recovery disclosure. `205b0edc...` through `d3e2a397cd76fc75e62516ed63ad76231bb4812c` update those journeys to explicitly open recovery first. + - validation on `d3e2a397...`: repository quality **248 tests / 247 pass / 0 fail / 1 intentional skip**; required Browser E2E **23 passed / 7 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. + - the normal Instructor flow no longer depends on exposing legacy Student join/assignment capabilities; compatibility routes remain intact. +- Student live-class client checkpoint: + - `47485439c34fc7237c28f93619924e2da87b084a` / `73792b71db9ec7965c36c2b905ebc09bb699ea78` make display name + human class code the normal Student path, add Waiting, and retain the legacy assignment code under an explicit recovery disclosure. + - `d32c7ec331daa5010f9617c6b332756e9395d35b` implements live admission, own-status polling, class-session resume, memory-only workspace access, safe reassignment, unassign, and terminal recovery while preserving the legacy controller path. + - `db54b1aa84dc4d43802aa568b811b6974a84c02c` / `a3d520655c928abbeb4cb63e592593a38a969cd8` prove the live resume envelope never persists assignment-specific workspace authority and cover Waiting -> A -> B -> Waiting. + - `8485b2ca1a5ba59fb98d4aae0398a9052fab91b6` / `2fd0d53c0fd8e4e4cac0298aca9f5ed7ad930a94` add an isolated live Student browser fixture with canonical Team A/B state and revocable fresh workspace aliases. + - `50f6260a251793c0794945d941ad4ac1c97d4e8d` adds desktop/mobile one-code -> Waiting -> Team Alpha -> edit -> reload/resume -> Team Beta -> Waiting, including stale-token 404 checks and no snapshot merge. + - the first browser run exposed two bounded issues: four older tests needed to open the intentional legacy recovery disclosure, and the Waiting eyebrow inherited a 3.72:1 blue-on-pale-blue contrast. `30dbb0d...` through `1b90c0f...` update the legacy journeys; `0c8692c7486ecb50c730d0757c41426b81830250` applies the existing accessible accent-text foreground. + - validation on `0c8692c...`: repository quality **252 tests / 251 pass / 0 fail / 1 intentional skip**; required Browser E2E **25 passed / 7 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. + - live same-device resume persists the stable Student class-session capability, never the current team workspace token; reload/move reacquires fresh server-authorized access. +- Integrated live-class acceptance checkpoint: + - `5f1404921eeec8b2aba3103672be547af35b6865` / `4f36af2d5221234a9ebf35d574f287f7aa52bb39` share managed Instructor workspace state with live Student collaboration and bind one-code admission to the same Instructor-owned class. + - `e7b8d06111b8dbda6ba6a27372956b3d012899c3` carries existing field-level coaching across managed live workspaces. + - `2262490c9de7da5a9b675932fda2c84475977168` adds the four-browser acceptance journey: displayed Instructor join code -> two Waiting Students -> Team Alpha shared sync -> late Student -> Team Beta isolation -> Instructor observe/coach -> A -> B move -> stale-A 404 -> unassign -> stale-B 404. + - before accepting the gate, `971898cb4eca725ad079be5c8ba5c97484fdab2f` / `6c712e47383079d5aa50869be4e9d9a26def28e9` / `9315210dd15ed583cfbd3201302699d025ded3a1` isolate the integrated class capability/join-code/workspace namespace so fully parallel Playwright specs cannot reset one another. + - validation on `9315210...`: repository quality **252 tests / 251 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. + - #312 browser acceptance now covers the complete live-class lifecycle without adding #313 staged simulation state. +- Exact next action: perform the final #312 docs/security/merge-readiness review: reconcile API/architecture docs with the implemented one-code lifecycle, review the full `main -> #314` diff and unresolved review/security state, confirm additive rollback/legacy compatibility, and move #314 out of draft only if the final documented head remains green. Keep #313 as the next separate product slice. + ## Approved Classroom follow-on direction The current #288 program establishes the secure Classroom foundation, but it is **not** the final Instructor-led teaching experience. diff --git a/index.html b/index.html index 461f5e4..389a0f9 100644 --- a/index.html +++ b/index.html @@ -284,26 +284,32 @@

How are you using Intake?

Student -

Join your class workspace

-

Enter the class and assignment codes your instructor gave you. Intake uses both codes only to resolve your assigned individual or team workspace; they are not stored after a successful join.

+

Join your class

+

Enter your name and the short class code from your instructor. You can join before a team is assigned; Intake will move you into the right workspace automatically.

-
+
- +
+
+ +
+ Use legacy two-code access
+ Only use this when your instructor gave you separate long class and assignment codes.
-
-

Your class codes are used for this join request and then discarded. Only the issued workspace capability is kept on this device for resume.

+ + +

The human class code is discarded after admission. This device keeps a private class-session capability so assignment changes can happen without asking you to join again.

@@ -311,10 +317,24 @@

Join your class workspace

+ +
+
+
+
+ Live class management + 0 students +
+
+ +
+ + + + + +
+ +
+ Students + Assign with the selector; Waiting leaves the student unassigned. +
+
+
+
diff --git a/scripts/serve-browser-tests.mjs b/scripts/serve-browser-tests.mjs index 8d723fe..80121c5 100644 --- a/scripts/serve-browser-tests.mjs +++ b/scripts/serve-browser-tests.mjs @@ -27,6 +27,38 @@ const INSTRUCTOR_WORKSPACE_IDS = Object.freeze({ INDIVIDUAL: '11111111-1111-4111-8111-111111111111', GROUP: '22222222-2222-4222-8222-222222222222' }); +const LIVE_INSTRUCTOR_TOKEN = `${'i'.repeat(42)}s`; +const LIVE_JOIN_CODE = 'K7FM-P4Q2'; +const LIVE_WORKSPACE_IDS = Object.freeze([ + '66666666-6666-4666-8666-666666666666', + '77777777-7777-4777-8777-777777777777', + '88888888-8888-4888-8888-888888888888' +]); +const INTEGRATED_INSTRUCTOR_TOKEN = `${'i'.repeat(42)}t`; +const INTEGRATED_JOIN_CODE = 'J8NP-C5R3'; +const INTEGRATED_WORKSPACE_IDS = Object.freeze([ + 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' +]); +const LIVE_PARTICIPANT_ID = '99999999-9999-4999-8999-999999999999'; +const STUDENT_LIVE_JOIN_CODE = 'M7QR-T4P2'; +const STUDENT_LIVE_CLASS = Object.freeze({ + id: 'browser-student-live-class', + title: 'Browser Student Live Classroom', + expiresAt: CLASSROOM_EXPIRY +}); +let liveClassState = null; +let liveInstructorWorkspaces = []; +let liveInstructorParticipants = []; +const liveInstructorWorkspaceStates = new Map(); +let integratedClassState = null; +let integratedInstructorWorkspaces = []; +let integratedInstructorParticipants = []; +const integratedInstructorWorkspaceStates = new Map(); +let studentLiveCounter = 0; +const studentLiveSessions = new Map(); +const studentLiveAccessContexts = new Map(); const classroomWorkspaces = new Map(); const classroomCoachingFeedback = new Map(); @@ -73,6 +105,71 @@ function workspaceTokenForAssignment(assignmentToken) { return `w${assignmentToken.slice(1)}`; } +function fixtureCapability(prefix, counter) { + const suffix = counter.toString(36).padStart(6, '0'); + return `${prefix}${'x'.repeat(42 - suffix.length)}${suffix}`; +} + +function studentLiveWorkspaceId(counter, teamNumber) { + const tail = String(counter * 10 + teamNumber).padStart(12, '0'); + return `${teamNumber === 1 ? 'aaaaaaaa' : 'bbbbbbbb'}-${teamNumber === 1 ? 'aaaa' : 'bbbb'}-4${teamNumber === 1 ? 'aaa' : 'bbb'}-8${teamNumber === 1 ? 'aaa' : 'bbb'}-${tail}`; +} + +function revokeStudentLiveAccess(session) { + if (!session?.activeWorkspaceToken) return; + classroomWorkspaces.delete(session.activeWorkspaceToken); + studentLiveAccessContexts.delete(session.activeWorkspaceToken); + session.activeWorkspaceToken = ''; +} + +function setStudentLiveAssignment(session, assignment, revision) { + const previousId = session.assignment?.id || null; + const nextId = assignment?.id || null; + if (previousId !== nextId) revokeStudentLiveAccess(session); + session.assignment = assignment; + session.assignmentRevision = revision; +} + +function studentLiveStatus(session) { + session.statusReads += 1; + if (!session.integrated) { + if (session.assignmentRevision === 0 && session.statusReads >= 2) { + setStudentLiveAssignment(session, session.teamA, 1); + } else if (session.assignment?.id === session.teamA.id && session.accessCount >= 2) { + setStudentLiveAssignment(session, session.teamB, 2); + } else if (session.assignment?.id === session.teamB.id && session.accessCount >= 3) { + setStudentLiveAssignment(session, null, 3); + } + } + return { + class: session.classContext || STUDENT_LIVE_CLASS, + participant: { + id: session.participantId, + displayName: session.displayName, + assignmentRevision: session.assignmentRevision + }, + assignment: session.assignment ? structuredClone(session.assignment) : null + }; +} + +function issueStudentLiveAccess(session) { + revokeStudentLiveAccess(session); + session.accessCount += 1; + const workspaceToken = fixtureCapability('u', ++studentLiveCounter); + const assignment = session.assignment; + const workspaceState = session.workspaceStates.get(assignment.id); + classroomWorkspaces.set(workspaceToken, workspaceState); + studentLiveAccessContexts.set(workspaceToken, { + class: session.classContext || STUDENT_LIVE_CLASS, + workspace: { + ...assignment, + expiresAt: CLASSROOM_EXPIRY + } + }); + session.activeWorkspaceToken = workspaceToken; + return workspaceToken; +} + function freshClassroomSnapshot() { const template = TEMPLATE_MANIFEST.find(entry => entry.id === 'checkout-latency'); return structuredClone(template?.state || {}); @@ -100,10 +197,51 @@ function instructorClass() { return { id: 'browser-test-class', title: 'Browser Test Classroom', + joinCode: 'K7FMP4Q2', expiresAt: CLASSROOM_EXPIRY }; } +function liveInstructorClass() { + return liveClassState || { + id: 'browser-live-class', + title: 'Browser Live Classroom', + joinCode: 'K7FMP4Q2', + expiresAt: CLASSROOM_EXPIRY + }; +} + +function integratedInstructorClass() { + return integratedClassState || { + id: 'browser-integrated-class', + title: 'Integrated Browser Classroom', + joinCode: 'J8NPC5R3', + expiresAt: CLASSROOM_EXPIRY + }; +} + +function managedInstructorFixture(token) { + if (token === INTEGRATED_INSTRUCTOR_TOKEN) { + return { + classContext: integratedInstructorClass(), + workspaces: integratedInstructorWorkspaces, + participants: integratedInstructorParticipants, + workspaceStates: integratedInstructorWorkspaceStates, + workspaceIds: INTEGRATED_WORKSPACE_IDS + }; + } + if (token === LIVE_INSTRUCTOR_TOKEN) { + return { + classContext: liveInstructorClass(), + workspaces: liveInstructorWorkspaces, + participants: liveInstructorParticipants, + workspaceStates: liveInstructorWorkspaceStates, + workspaceIds: LIVE_WORKSPACE_IDS + }; + } + return null; +} + function instructorRoster() { return { class: instructorClass(), @@ -128,6 +266,85 @@ function instructorRoster() { }; } +function instructorParticipants() { + return { + class: instructorClass(), + participants: [ + { + id: '33333333-3333-4333-8333-333333333333', + displayName: 'Alex Student', + assignmentRevision: 1, + joinedAt: '2099-12-31T20:00:00.000Z', + updatedAt: '2099-12-31T20:00:00.000Z', + assignment: { + id: INSTRUCTOR_WORKSPACE_IDS.INDIVIDUAL, + kind: 'individual', + label: 'Alex Student' + } + }, + { + id: '44444444-4444-4444-8444-444444444444', + displayName: 'Waiting Student', + assignmentRevision: 0, + joinedAt: '2099-12-31T20:01:00.000Z', + updatedAt: '2099-12-31T20:01:00.000Z', + assignment: null + } + ] + }; +} + +function managedInstructorRoster(token) { + const fixture = managedInstructorFixture(token); + if (!fixture) return null; + return { + class: fixture.classContext, + workspaces: fixture.workspaces.map(workspace => ({ + ...workspace, + participantCount: fixture.participants.filter(participant => participant.assignment?.id === workspace.id).length, + activeParticipantCount: 0, + editingParticipantCount: 0 + })) + }; +} + +function managedInstructorParticipantRoster(token) { + const fixture = managedInstructorFixture(token); + if (!fixture) return null; + return { + class: fixture.classContext, + participants: structuredClone(fixture.participants) + }; +} + +function managedInstructorObservation(token, workspaceId) { + const fixture = managedInstructorFixture(token); + if (!fixture) return null; + const workspace = fixture.workspaces.find(item => item.id === workspaceId); + const state = fixture.workspaceStates.get(workspaceId); + if (!workspace || !state) return null; + return { + class: fixture.classContext, + workspace: { + id: workspace.id, + kind: workspace.kind, + label: workspace.label, + teamName: state.teamName, + revision: state.revision, + expiresAt: CLASSROOM_EXPIRY, + updatedAt: '2099-12-31T23:00:00.000Z' + }, + participants: fixture.participants + .filter(participant => participant.assignment?.id === workspaceId) + .map(participant => ({ + id: participant.id, + displayName: participant.displayName, + activityState: 'active' + })), + snapshot: structuredClone(state.snapshot) + }; +} + function instructorObservation(workspaceId) { const roster = instructorRoster().workspaces; const workspaceMeta = roster.find(item => item.id === workspaceId); @@ -197,6 +414,8 @@ function instructorWorkspaceIdForToken(workspaceToken) { } function classroomContext(workspaceToken) { + const liveContext = studentLiveAccessContexts.get(workspaceToken); + if (liveContext) return structuredClone(liveContext); const suffix = workspaceToken.slice(-8); return { class: { @@ -214,17 +433,323 @@ function classroomContext(workspaceToken) { } async function handleClassroomApi(request, response, url) { - if (url.pathname === '/api/classes/workspaces') { + if (url.pathname === '/api/classes/admit') { + if (request.method !== 'POST') { + sendJson(response, 405, { error: 'Method not allowed.' }); + return true; + } + const body = await readJson(request).catch(() => null); + const joinCode = typeof body?.joinCode === 'string' + ? body.joinCode.trim().toUpperCase().replace(/[\s-]+/gu, '') + : ''; + const isolatedCode = STUDENT_LIVE_JOIN_CODE.replace('-', ''); + const integratedCode = INTEGRATED_JOIN_CODE.replace('-', ''); + const participantId = typeof body?.participantId === 'string' ? body.participantId : ''; + const displayName = typeof body?.displayName === 'string' ? body.displayName.trim() : ''; + if (![isolatedCode, integratedCode].includes(joinCode) || !participantId || !displayName) { + sendJson(response, 404, { error: 'Class not available.' }); + return true; + } + + const sequence = ++studentLiveCounter; + const studentSessionToken = fixtureCapability('l', sequence); + let session; + + if (joinCode === integratedCode && integratedClassState) { + session = { + participantId, + displayName, + assignmentRevision: 0, + assignment: null, + workspaceStates: integratedInstructorWorkspaceStates, + classContext: integratedInstructorClass(), + integrated: true, + statusReads: 0, + accessCount: 0, + activeWorkspaceToken: '' + }; + integratedInstructorParticipants.push({ + id: participantId, + displayName, + assignmentRevision: 0, + joinedAt: '2099-12-31T20:05:00.000Z', + updatedAt: '2099-12-31T20:05:00.000Z', + assignment: null + }); + } else if (joinCode === isolatedCode) { + const teamA = { + id: studentLiveWorkspaceId(sequence, 1), + kind: 'group', + label: 'Team Alpha' + }; + const teamB = { + id: studentLiveWorkspaceId(sequence, 2), + kind: 'group', + label: 'Team Beta' + }; + const makeWorkspaceState = label => { + const snapshot = freshClassroomSnapshot(); + if (!snapshot.pre || typeof snapshot.pre !== 'object') snapshot.pre = {}; + snapshot.pre.oneLine = `${label} destination Intake.`; + return { + snapshot, + revision: 1, + teamName: label, + participants: new Map() + }; + }; + session = { + participantId, + displayName, + assignmentRevision: 0, + assignment: null, + teamA, + teamB, + workspaceStates: new Map([ + [teamA.id, makeWorkspaceState(teamA.label)], + [teamB.id, makeWorkspaceState(teamB.label)] + ]), + statusReads: 0, + accessCount: 0, + activeWorkspaceToken: '' + }; + } else { + sendJson(response, 404, { error: 'Class not available.' }); + return true; + } + + studentLiveSessions.set(studentSessionToken, session); + sendJson(response, 200, { + class: session.classContext || STUDENT_LIVE_CLASS, + participant: { + id: participantId, + displayName, + assignmentRevision: 0 + }, + assignment: null, + studentSessionToken + }); + return true; + } + + if (url.pathname === '/api/classes/student') { if (request.method !== 'GET') { sendJson(response, 405, { error: 'Method not allowed.' }); return true; } + const studentSessionToken = bearerToken(request); + const session = studentLiveSessions.get(studentSessionToken); + if (!session) { + sendJson(response, 404, { error: 'Student class session not found.' }); + return true; + } + sendJson(response, 200, studentLiveStatus(session)); + return true; + } + + if (url.pathname === '/api/classes/student/access') { + if (request.method !== 'POST') { + sendJson(response, 405, { error: 'Method not allowed.' }); + return true; + } + const studentSessionToken = bearerToken(request); + const session = studentLiveSessions.get(studentSessionToken); + if (!session) { + sendJson(response, 404, { error: 'Student class session not found.' }); + return true; + } + if (!session.assignment) { + sendJson(response, 409, { + status: 'waiting', + class: session.classContext || STUDENT_LIVE_CLASS, + participant: { + id: session.participantId, + displayName: session.displayName, + assignmentRevision: session.assignmentRevision + }, + assignment: null + }); + return true; + } + const workspaceToken = issueStudentLiveAccess(session); + sendJson(response, 200, { + class: session.classContext || STUDENT_LIVE_CLASS, + participant: { + id: session.participantId, + displayName: session.displayName, + assignmentRevision: session.assignmentRevision + }, + assignment: structuredClone(session.assignment), + workspaceToken + }); + return true; + } + + if (url.pathname === '/api/classes') { + if (request.method !== 'POST') { + sendJson(response, 405, { error: 'Method not allowed.' }); + return true; + } + const body = await readJson(request).catch(() => null); + const title = typeof body?.title === 'string' ? body.title.trim() : ''; + if (!title) { + sendJson(response, 400, { error: 'Invalid class title.' }); + return true; + } + if (title === 'Integrated Browser Classroom') { + integratedClassState = { + id: 'browser-integrated-class', + title, + joinCode: INTEGRATED_JOIN_CODE.replace('-', ''), + expiresAt: CLASSROOM_EXPIRY + }; + integratedInstructorWorkspaces = []; + integratedInstructorWorkspaceStates.clear(); + integratedInstructorParticipants = []; + sendJson(response, 201, { + class: integratedInstructorClass(), + instructorToken: INTEGRATED_INSTRUCTOR_TOKEN, + studentJoinToken: `${'c'.repeat(42)}t`, + joinCode: INTEGRATED_JOIN_CODE + }); + return true; + } + + liveClassState = { + id: 'browser-live-class', + title, + joinCode: LIVE_JOIN_CODE.replace('-', ''), + expiresAt: CLASSROOM_EXPIRY + }; + liveInstructorWorkspaces = []; + liveInstructorWorkspaceStates.clear(); + liveInstructorParticipants = [{ + id: LIVE_PARTICIPANT_ID, + displayName: 'Waiting Student', + assignmentRevision: 0, + joinedAt: '2099-12-31T20:00:00.000Z', + updatedAt: '2099-12-31T20:00:00.000Z', + assignment: null + }]; + sendJson(response, 201, { + class: liveInstructorClass(), + instructorToken: LIVE_INSTRUCTOR_TOKEN, + studentJoinToken: `${'c'.repeat(42)}s`, + joinCode: LIVE_JOIN_CODE + }); + return true; + } + + if (url.pathname === '/api/classes/workspaces') { + const instructorToken = bearerToken(request); + if (!activeInstructorCapability(instructorToken)) { + sendJson(response, 401, { error: 'Missing or invalid instructor authorization.' }); + return true; + } + + if (request.method === 'GET') { + const managedRoster = managedInstructorRoster(instructorToken); + sendJson(response, 200, managedRoster || instructorRoster()); + return true; + } + + const managedFixture = managedInstructorFixture(instructorToken); + if (request.method === 'POST' && managedFixture) { + const body = await readJson(request).catch(() => null); + const kind = ['group', 'individual'].includes(body?.kind) ? body.kind : ''; + const label = typeof body?.label === 'string' ? body.label.trim() : ''; + if (!kind || !label || !body?.snapshot || typeof body.snapshot !== 'object') { + sendJson(response, 400, { error: 'Invalid workspace request.' }); + return true; + } + const id = managedFixture.workspaceIds[managedFixture.workspaces.length]; + if (!id) { + sendJson(response, 409, { error: 'Browser fixture workspace limit reached.' }); + return true; + } + const workspace = { + id, + kind, + label, + createdAt: '2099-12-31T21:00:00.000Z', + participantCount: 0, + activeParticipantCount: 0, + editingParticipantCount: 0 + }; + managedFixture.workspaces.push(workspace); + const snapshot = freshClassroomSnapshot(); + if (!snapshot.pre || typeof snapshot.pre !== 'object') snapshot.pre = {}; + snapshot.pre.oneLine = `${workspace.label} live-class Intake.`; + managedFixture.workspaceStates.set(workspace.id, { + snapshot, + revision: 1, + teamName: workspace.label, + participants: new Map() + }); + sendJson(response, 201, { + workspace, + assignmentToken: `${'a'.repeat(42)}${String(managedFixture.workspaces.length).slice(-1)}` + }); + return true; + } + + sendJson(response, 405, { error: 'Method not allowed.' }); + return true; + } + + if (url.pathname === '/api/classes/participants') { const instructorToken = bearerToken(request); if (!activeInstructorCapability(instructorToken)) { sendJson(response, 401, { error: 'Missing or invalid instructor authorization.' }); return true; } - sendJson(response, 200, instructorRoster()); + + if (request.method === 'GET') { + const managedRoster = managedInstructorParticipantRoster(instructorToken); + sendJson(response, 200, managedRoster || instructorParticipants()); + return true; + } + + const managedFixture = managedInstructorFixture(instructorToken); + if (request.method === 'PATCH' && managedFixture) { + const body = await readJson(request).catch(() => null); + const participant = managedFixture.participants.find(item => item.id === body?.participantId); + const workspace = body?.workspaceId === null + ? null + : managedFixture.workspaces.find(item => item.id === body?.workspaceId); + if (!participant || (body?.workspaceId !== null && !workspace)) { + sendJson(response, 404, { error: 'Participant or workspace not found.' }); + return true; + } + const unchanged = (participant.assignment?.id || null) === (workspace?.id || null); + if (!unchanged) { + participant.assignmentRevision += 1; + participant.assignment = workspace + ? { id: workspace.id, kind: workspace.kind, label: workspace.label } + : null; + participant.updatedAt = '2099-12-31T22:00:00.000Z'; + + const liveSession = [...studentLiveSessions.values()].find(session => ( + session.integrated && session.participantId === participant.id + )); + if (liveSession) { + setStudentLiveAssignment( + liveSession, + participant.assignment ? structuredClone(participant.assignment) : null, + participant.assignmentRevision + ); + } + } + sendJson(response, 200, { + class: managedFixture.classContext, + participant: structuredClone(participant), + assignment: structuredClone(participant.assignment), + changed: !unchanged + }); + return true; + } + + sendJson(response, 405, { error: 'Method not allowed.' }); return true; } @@ -238,7 +763,9 @@ async function handleClassroomApi(request, response, url) { sendJson(response, 401, { error: 'Missing or invalid instructor authorization.' }); return true; } - const observation = instructorObservation(url.searchParams.get('workspaceId') || ''); + const workspaceId = url.searchParams.get('workspaceId') || ''; + const observation = managedInstructorObservation(instructorToken, workspaceId) + || instructorObservation(workspaceId); if (!observation) { sendJson(response, 404, { error: 'Workspace not found.' }); return true; @@ -254,7 +781,11 @@ async function handleClassroomApi(request, response, url) { return true; } const workspaceId = url.searchParams.get('workspaceId') || ''; - if (!Object.values(INSTRUCTOR_WORKSPACE_IDS).includes(workspaceId)) { + const legacyWorkspace = instructorRoster().workspaces.find(item => item.id === workspaceId) || null; + const managedFixture = managedInstructorFixture(instructorToken); + const liveWorkspace = managedFixture?.workspaces.find(item => item.id === workspaceId) || null; + const workspace = liveWorkspace || legacyWorkspace; + if (!workspace) { sendJson(response, 404, { error: 'Workspace not found.' }); return true; } @@ -262,8 +793,8 @@ async function handleClassroomApi(request, response, url) { if (request.method === 'GET') { sendJson(response, 200, { - class: instructorClass(), - workspace: instructorRoster().workspaces.find(item => item.id === workspaceId), + class: liveWorkspace ? managedFixture.classContext : instructorClass(), + workspace, feedback: [...workspaceFeedback.values()] }); return true; diff --git a/src/classroomInstructor.js b/src/classroomInstructor.js index 83e86c7..fff204a 100644 --- a/src/classroomInstructor.js +++ b/src/classroomInstructor.js @@ -13,10 +13,13 @@ import { EXPERIENCE_ROLE_IDS } from './experienceRoles.js'; export const INSTRUCTOR_SESSION_STORAGE_KEY = 'kt-classroom-instructor-session-v1'; export const INSTRUCTOR_SESSION_VERSION = 1; +export const INSTRUCTOR_CLASSES_ENDPOINT = '/api/classes'; export const INSTRUCTOR_WORKSPACES_ENDPOINT = '/api/classes/workspaces'; +export const INSTRUCTOR_PARTICIPANTS_ENDPOINT = '/api/classes/participants'; export const INSTRUCTOR_OBSERVE_ENDPOINT = '/api/classes/observe'; const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const JOIN_CODE_PATTERN = /^[A-HJ-NP-Z2-9]{8}$/; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const ROSTER_POLL_MS = 5000; const OBSERVE_POLL_MS = 1200; @@ -25,6 +28,17 @@ export function validateInstructorCapability(value) { return typeof value === 'string' && CAPABILITY_PATTERN.test(value.trim()); } +export function normalizeInstructorJoinCode(value) { + if (typeof value !== 'string') return ''; + const normalized = value.trim().toUpperCase().replace(/[\s-]+/gu, ''); + return JOIN_CODE_PATTERN.test(normalized) ? normalized : ''; +} + +export function formatInstructorJoinCode(value) { + const normalized = normalizeInstructorJoinCode(value); + return normalized ? `${normalized.slice(0, 4)}-${normalized.slice(4)}` : ''; +} + export function readInstructorSession(storage = globalThis.localStorage) { if (!storage) return null; try { @@ -35,6 +49,7 @@ export function readInstructorSession(storage = globalThis.localStorage) { || !validateInstructorCapability(parsed.instructorToken) || typeof parsed.class?.id !== 'string' || typeof parsed.class?.title !== 'string' + || (parsed.joinCode && !normalizeInstructorJoinCode(parsed.joinCode)) || (parsed.selectedWorkspaceId && !UUID_PATTERN.test(parsed.selectedWorkspaceId)) ) return null; return parsed; @@ -109,6 +124,7 @@ export function createInstructorClassroomController({ }) { let activeSession = null; let workspaces = []; + let participants = []; let selectedWorkspaceId = ''; let rosterTimer = null; let observerTimer = null; @@ -140,8 +156,18 @@ export function createInstructorClassroomController({ }; const setBusy = value => { busy = value; - if (element('instructorClassOpenBtn')) element('instructorClassOpenBtn').disabled = value; - if (element('instructorClassRetryBtn')) element('instructorClassRetryBtn').disabled = value; + [ + 'instructorClassStartBtn', + 'instructorClassOpenBtn', + 'instructorClassRetryBtn', + 'instructorWorkspaceCreateBtn', + 'instructorCopyJoinCodeBtn' + ].forEach(id => { + if (element(id)) element(id).disabled = value; + }); + element('instructorParticipantList')?.querySelectorAll?.('select')?.forEach(select => { + select.disabled = value; + }); }; const abort = controller => { try { controller?.abort?.(); } catch {} }; const clearTimer = timer => { @@ -178,7 +204,9 @@ export function createInstructorClassroomController({ setStatus(retry ? 'retry' : 'disconnected'); if (element('instructorClassEntryCard')) element('instructorClassEntryCard').hidden = false; if (element('instructorClassDashboard')) element('instructorClassDashboard').hidden = true; - if (element('instructorClassForm')) element('instructorClassForm').hidden = retry; + if (element('instructorClassStartForm')) element('instructorClassStartForm').hidden = retry; + if (element('instructorExistingClass')) element('instructorExistingClass').hidden = retry; + if (element('instructorClassForm')) element('instructorClassForm').hidden = false; if (element('instructorClassResumePanel')) element('instructorClassResumePanel').hidden = !retry; if (element('instructorClassResumeTitle')) { element('instructorClassResumeTitle').textContent = activeSession?.class?.title || 'Saved instructor class'; @@ -202,6 +230,9 @@ export function createInstructorClassroomController({ if (element('instructorClassDashboard')) element('instructorClassDashboard').hidden = false; if (element('instructorClassTitle')) element('instructorClassTitle').textContent = activeSession.class.title; renderExpiry(activeSession.class.expiresAt); + const joinCode = formatInstructorJoinCode(activeSession.joinCode || activeSession.class?.joinCode || ''); + if (element('instructorJoinCode')) element('instructorJoinCode').textContent = joinCode || 'Unavailable'; + if (element('instructorCopyJoinCodeBtn')) element('instructorCopyJoinCodeBtn').disabled = !joinCode || busy; setError(''); }; @@ -281,6 +312,68 @@ export function createInstructorClassroomController({ } }; + const renderParticipants = () => { + const list = element('instructorParticipantList'); + if (!list) return; + list.replaceChildren(); + + if (element('instructorParticipantSummary')) { + const waiting = participants.filter(participant => !participant.assignment).length; + element('instructorParticipantSummary').textContent = + `${participants.length} student${participants.length === 1 ? '' : 's'} · ${waiting} waiting`; + } + + participants.forEach(participant => { + const row = documentRef.createElement('div'); + row.className = 'instructor-participant-row'; + row.dataset.participantId = participant.id; + row.setAttribute('data-persistence', 'local-only'); + row.setAttribute('data-summary', 'exclude'); + + const identity = documentRef.createElement('div'); + identity.className = 'instructor-participant-row__identity'; + const name = documentRef.createElement('strong'); + name.textContent = participant.displayName || 'Student'; + const status = documentRef.createElement('span'); + status.textContent = participant.assignment + ? `${participant.assignment.kind === 'group' ? 'Team' : 'Individual'} · ${participant.assignment.label}` + : 'Waiting / unassigned'; + identity.append(name, status); + + const select = documentRef.createElement('select'); // data-persistence="local-only" + select.setAttribute('aria-label', `Assignment for ${participant.displayName || 'student'}`); + select.setAttribute('data-persistence', 'local-only'); + select.setAttribute('data-summary', 'exclude'); + select.disabled = busy; + + const waitingOption = documentRef.createElement('option'); // data-persistence="local-only" + waitingOption.value = ''; + waitingOption.textContent = 'Waiting / unassigned'; + select.append(waitingOption); + + workspaces.forEach(workspace => { + const option = documentRef.createElement('option'); // data-persistence="local-only" + option.value = workspace.id; + option.textContent = `${workspace.kind === 'group' ? 'Team' : 'Individual'} · ${workspace.label}`; + select.append(option); + }); + select.value = participant.assignment?.id || ''; + select.addEventListener('change', event => { + void assignParticipant(participant.id, event.target.value || null); + }); + + row.append(identity, select); + list.append(row); + }); + + if (!participants.length) { + const empty = documentRef.createElement('p'); + empty.className = 'instructor-participant-empty'; + empty.textContent = 'No students have joined yet. Share the Student join code; late arrivals will appear here automatically.'; + list.append(empty); + } + }; + const renderObservation = body => { if (element('instructorObservationNotice')) element('instructorObservationNotice').hidden = false; if (element('instructorObservedWorkspace')) element('instructorObservedWorkspace').textContent = body.workspace?.label || 'Selected workspace'; @@ -437,14 +530,21 @@ export function createInstructorClassroomController({ const fetchRoster = async token => { abort(rosterAbort); rosterAbort = typeof AbortControllerImpl === 'function' ? new AbortControllerImpl() : null; - const response = await fetchImpl(INSTRUCTOR_WORKSPACES_ENDPOINT, { + const options = { method: 'GET', headers: { Authorization: `Bearer ${token}` }, signal: rosterAbort?.signal - }); - const body = await responseJson(response); + }; + const [workspaceResponse, participantResponse] = await Promise.all([ + fetchImpl(INSTRUCTOR_WORKSPACES_ENDPOINT, options), + fetchImpl(INSTRUCTOR_PARTICIPANTS_ENDPOINT, options) + ]); + const [workspaceBody, participantBody] = await Promise.all([ + responseJson(workspaceResponse), + responseJson(participantResponse) + ]); rosterAbort = null; - return { response, body }; + return { workspaceResponse, workspaceBody, participantResponse, participantBody }; }; const validWorkspace = workspace => ( @@ -454,11 +554,32 @@ export function createInstructorClassroomController({ && typeof workspace.label === 'string' ); - const acceptRoster = body => { - if (typeof body.class?.id !== 'string' || typeof body.class?.title !== 'string' || !Array.isArray(body.workspaces)) { + const validParticipant = participant => ( + participant + && UUID_PATTERN.test(participant.id || '') + && typeof participant.displayName === 'string' + && Number.isInteger(participant.assignmentRevision) + && ( + participant.assignment === null + || ( + validWorkspace(participant.assignment) + ) + ) + ); + + const acceptRoster = (workspaceBody, participantBody) => { + if ( + typeof workspaceBody.class?.id !== 'string' + || typeof workspaceBody.class?.title !== 'string' + || !Array.isArray(workspaceBody.workspaces) + || typeof participantBody.class?.id !== 'string' + || participantBody.class.id !== workspaceBody.class.id + || !Array.isArray(participantBody.participants) + ) { return false; } - workspaces = body.workspaces.filter(validWorkspace); + workspaces = workspaceBody.workspaces.filter(validWorkspace); + participants = participantBody.participants.filter(validParticipant); return true; }; @@ -486,6 +607,7 @@ export function createInstructorClassroomController({ clearInstructorSession(storage); activeSession = null; workspaces = []; + participants = []; selectedWorkspaceId = ''; restoreLocal(); renderEntry(); @@ -557,16 +679,34 @@ export function createInstructorClassroomController({ async function refreshRoster() { if (!activeSession || destroyed) return false; try { - const { response, body } = await fetchRoster(activeSession.instructorToken); - if (response.status === 404 || response.status === 401) { + const { + workspaceResponse, + workspaceBody, + participantResponse, + participantBody + } = await fetchRoster(activeSession.instructorToken); + if ( + [workspaceResponse.status, participantResponse.status].some(status => status === 404 || status === 401) + ) { return terminalClass('This instructor class is no longer available. Use the current Instructor access code.'); } - if (!response.ok || !acceptRoster(body)) { + if ( + !workspaceResponse.ok + || !participantResponse.ok + || !acceptRoster(workspaceBody, participantBody) + ) { setError('Could not refresh the class roster. Live observation will keep retrying.'); scheduleRoster(); return false; } - activeSession.class = { id: body.class.id, title: body.class.title, expiresAt: body.class.expiresAt || null }; + activeSession.class = { + id: workspaceBody.class.id, + title: workspaceBody.class.title, + expiresAt: workspaceBody.class.expiresAt || null + }; + activeSession.joinCode = formatInstructorJoinCode( + workspaceBody.class.joinCode || activeSession.joinCode || '' + ); if (selectedWorkspaceId && !workspaces.some(item => item.id === selectedWorkspaceId)) { selectedWorkspaceId = ''; latestObservation = null; @@ -574,6 +714,7 @@ export function createInstructorClassroomController({ } saveSession(); renderDashboard(); + renderParticipants(); renderRoster(); if (!selectedWorkspaceId && workspaces[0]) await selectWorkspace(workspaces[0].id); scheduleRoster(); @@ -590,18 +731,29 @@ export function createInstructorClassroomController({ if (state.token) collaboration?.leave?.({ silent: true }); }; - const activateClass = async (token, preferredWorkspaceId = '') => { + const activateClass = async (token, preferredWorkspaceId = '', sessionHints = {}) => { onClassDisconnected(); disconnectStandaloneCollaboration(); setBusy(true); setStatus('connecting'); setError(''); try { - const { response, body } = await fetchRoster(token); - if (response.status === 404 || response.status === 401) { + const { + workspaceResponse, + workspaceBody, + participantResponse, + participantBody + } = await fetchRoster(token); + if ( + [workspaceResponse.status, participantResponse.status].some(status => status === 404 || status === 401) + ) { return terminalClass('The Instructor access code was not accepted or has expired.'); } - if (!response.ok || !acceptRoster(body)) { + if ( + !workspaceResponse.ok + || !participantResponse.ok + || !acceptRoster(workspaceBody, participantBody) + ) { setError('The class service is unavailable right now. Your local Intake is unchanged.'); renderEntry({ retry: Boolean(activeSession), message: 'The class service is unavailable right now.' }); return false; @@ -609,9 +761,19 @@ export function createInstructorClassroomController({ activeSession = { version: INSTRUCTOR_SESSION_VERSION, instructorToken: token, - class: { id: body.class.id, title: body.class.title, expiresAt: body.class.expiresAt || null }, + joinCode: formatInstructorJoinCode( + sessionHints.joinCode + || workspaceBody.class.joinCode + || activeSession?.joinCode + || '' + ), + class: { + id: workspaceBody.class.id, + title: workspaceBody.class.title, + expiresAt: workspaceBody.class.expiresAt || null + }, selectedWorkspaceId: null, - openedAt: new Date(now()).toISOString() + openedAt: sessionHints.openedAt || activeSession?.openedAt || new Date(now()).toISOString() }; selectedWorkspaceId = workspaces.some(item => item.id === preferredWorkspaceId) ? preferredWorkspaceId @@ -619,15 +781,17 @@ export function createInstructorClassroomController({ saveSession(); onClassConnected(token); renderDashboard(); + renderParticipants(); renderRoster(); if (element('instructorClassCode')) element('instructorClassCode').value = ''; + if (element('instructorClassTitleInput')) element('instructorClassTitleInput').value = ''; if (selectedWorkspaceId) await selectWorkspace(selectedWorkspaceId); else restoreLocal(); scheduleRoster(); return true; } catch { setError('Could not reach the class service. Your local Intake is unchanged.'); - renderEntry({ retry: Boolean(activeSession), message: 'Could not reach the class service.' }); + renderEntry({ retry: Boolean(activeSession), message: 'The class service is unavailable right now.' }); return false; } finally { setBusy(false); @@ -643,12 +807,178 @@ export function createInstructorClassroomController({ return activateClass(token); }; + const startClass = async titleValue => { + const title = typeof titleValue === 'string' ? titleValue.trim() : ''; + if (!title || title.length > 120) { + setError('Enter a class title before starting the class.'); + return false; + } + if (!fetchImpl) { + setError('The class service is unavailable right now.'); + return false; + } + + setBusy(true); + setStatus('creating'); + setError(''); + disconnectStandaloneCollaboration(); + try { + const response = await fetchImpl(INSTRUCTOR_CLASSES_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }) + }); + const body = await responseJson(response); + if ( + response.status !== 201 + || !validateInstructorCapability(body.instructorToken) + || typeof body.class?.id !== 'string' + || typeof body.class?.title !== 'string' + ) { + setError(body.error || 'Could not start the class.'); + return false; + } + + const createdSession = { + version: INSTRUCTOR_SESSION_VERSION, + instructorToken: body.instructorToken, + joinCode: formatInstructorJoinCode(body.joinCode || ''), + class: { + id: body.class.id, + title: body.class.title, + expiresAt: body.class.expiresAt || null + }, + selectedWorkspaceId: null, + openedAt: new Date(now()).toISOString() + }; + activeSession = createdSession; + saveSession(); + + return activateClass(body.instructorToken, '', { + joinCode: createdSession.joinCode, + openedAt: createdSession.openedAt + }); + } catch { + setError('Could not start the class. Check your connection.'); + return false; + } finally { + setBusy(false); + } + }; + + const createWorkspace = async (kindValue, labelValue) => { + if (!activeSession || busy) return false; + const kind = ['group', 'individual'].includes(kindValue) ? kindValue : ''; + const label = typeof labelValue === 'string' ? labelValue.trim() : ''; + if (!kind || !label || label.length > 80) { + setError('Enter a workspace name and choose Team or Individual.'); + return false; + } + + setBusy(true); + setError(''); + try { + const response = await fetchImpl(INSTRUCTOR_WORKSPACES_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${activeSession.instructorToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ kind, label, snapshot: {} }) + }); + const body = await responseJson(response); + if (!response.ok || !validWorkspace(body.workspace)) { + setError(body.error || 'Could not create the workspace.'); + return false; + } + if (element('instructorWorkspaceLabel')) element('instructorWorkspaceLabel').value = ''; + toast(`${kind === 'group' ? 'Team' : 'Individual workspace'} created.`); + await refreshRoster(); + return true; + } catch { + setError('Could not create the workspace. Check your connection.'); + return false; + } finally { + setBusy(false); + renderParticipants(); + } + }; + + const assignParticipant = async (participantId, workspaceId) => { + if (!activeSession || busy || !UUID_PATTERN.test(participantId || '')) return false; + if (workspaceId !== null && !workspaces.some(workspace => workspace.id === workspaceId)) { + renderParticipants(); + return false; + } + + setBusy(true); + setError(''); + try { + const response = await fetchImpl(INSTRUCTOR_PARTICIPANTS_ENDPOINT, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${activeSession.instructorToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ participantId, workspaceId }) + }); + const body = await responseJson(response); + if (!response.ok) { + setError(body.error || 'Could not update the Student assignment.'); + renderParticipants(); + return false; + } + toast(body.changed === false + ? 'Student assignment is already current.' + : workspaceId + ? 'Student assignment updated.' + : 'Student returned to Waiting.'); + await refreshRoster(); + return true; + } catch { + setError('Could not update the Student assignment. Check your connection.'); + renderParticipants(); + return false; + } finally { + setBusy(false); + renderParticipants(); + } + }; + + const copyJoinCode = async () => { + const joinCode = formatInstructorJoinCode(activeSession?.joinCode || ''); + if (!joinCode) return false; + try { + if (windowRef?.navigator?.clipboard?.writeText) { + await windowRef.navigator.clipboard.writeText(joinCode); + toast('Student join code copied.'); + return true; + } + const temporary = documentRef?.createElement?.('textarea'); + if (!temporary) return false; + temporary.value = joinCode; + temporary.setAttribute('readonly', ''); + temporary.style.position = 'fixed'; + temporary.style.opacity = '0'; + documentRef.body?.append(temporary); + temporary.select(); + const copied = documentRef.execCommand?.('copy') === true; + temporary.remove(); + if (copied) toast('Student join code copied.'); + return copied; + } catch { + toast(`Student join code: ${joinCode}`); + return false; + } + }; + const resume = async () => { if (destroyed || getActiveExperienceRole() !== EXPERIENCE_ROLE_IDS.INSTRUCTOR) return false; const stored = readInstructorSession(storage); if (!stored) { activeSession = null; workspaces = []; + participants = []; selectedWorkspaceId = ''; renderEntry(); return false; @@ -658,7 +988,8 @@ export function createInstructorClassroomController({ if (isInstructorSessionExpired(stored, now())) { return terminalClass('Your saved Instructor class access has expired. Use the current Instructor access code.'); } - if (element('instructorClassForm')) element('instructorClassForm').hidden = true; + if (element('instructorClassStartForm')) element('instructorClassStartForm').hidden = true; + if (element('instructorExistingClass')) element('instructorExistingClass').hidden = true; if (element('instructorClassResumePanel')) element('instructorClassResumePanel').hidden = false; if (element('instructorClassResumeTitle')) element('instructorClassResumeTitle').textContent = stored.class.title; if (element('instructorClassResumeMessage')) element('instructorClassResumeMessage').textContent = 'Reconnecting to your class roster…'; @@ -671,6 +1002,7 @@ export function createInstructorClassroomController({ clearInstructorSession(storage); activeSession = null; workspaces = []; + participants = []; selectedWorkspaceId = ''; restoreLocal(); renderEntry(); @@ -686,10 +1018,22 @@ export function createInstructorClassroomController({ setConnectedLayout(false); }; + const handleStartSubmit = event => { + event.preventDefault(); + void startClass(element('instructorClassTitleInput')?.value || ''); + }; const handleSubmit = event => { event.preventDefault(); void openClass(element('instructorClassCode')?.value || ''); }; + const handleWorkspaceCreate = event => { + event.preventDefault(); + void createWorkspace( + element('instructorWorkspaceKind')?.value || 'group', + element('instructorWorkspaceLabel')?.value || '' + ); + }; + const handleCopyJoinCode = () => { void copyJoinCode(); }; const handleRetry = () => { void resume(); }; const handleLeave = () => leaveClass(); const handleSearch = event => { @@ -716,7 +1060,10 @@ export function createInstructorClassroomController({ }; const init = () => { + element('instructorClassStartForm')?.addEventListener('submit', handleStartSubmit); element('instructorClassForm')?.addEventListener('submit', handleSubmit); + element('instructorWorkspaceCreateForm')?.addEventListener('submit', handleWorkspaceCreate); + element('instructorCopyJoinCodeBtn')?.addEventListener('click', handleCopyJoinCode); element('instructorClassRetryBtn')?.addEventListener('click', handleRetry); element('instructorClassLeaveBtn')?.addEventListener('click', handleLeave); element('instructorWorkspaceSearch')?.addEventListener('input', handleSearch); @@ -734,7 +1081,10 @@ export function createInstructorClassroomController({ stopLive(); onObservationEnd(); restoreReadonlyProjection(); + element('instructorClassStartForm')?.removeEventListener('submit', handleStartSubmit); element('instructorClassForm')?.removeEventListener('submit', handleSubmit); + element('instructorWorkspaceCreateForm')?.removeEventListener('submit', handleWorkspaceCreate); + element('instructorCopyJoinCodeBtn')?.removeEventListener('click', handleCopyJoinCode); element('instructorClassRetryBtn')?.removeEventListener('click', handleRetry); element('instructorClassLeaveBtn')?.removeEventListener('click', handleLeave); element('instructorWorkspaceSearch')?.removeEventListener('input', handleSearch); @@ -747,12 +1097,16 @@ export function createInstructorClassroomController({ init, destroy, openClass, + startClass, + createWorkspace, + assignParticipant, + copyJoinCode, resume, refreshRoster, selectWorkspace, leaveClass, getState: () => ({ - activeSession, workspaces, selectedWorkspaceId, observerEpoch, + activeSession, workspaces, participants, selectedWorkspaceId, observerEpoch, latestObservation, busy, searchQuery, kindFilter, lastError }) }; diff --git a/src/classroomStudent.js b/src/classroomStudent.js index 7eb4937..40ba2ae 100644 --- a/src/classroomStudent.js +++ b/src/classroomStudent.js @@ -1,12 +1,13 @@ /** * @module classroomStudent - * @summary Owns Student class admission, same-device resume, class context, and explicit class exit. + * @summary Owns Student class admission, waiting, assignment switching, same-device resume, and explicit class exit. * @description - * Classroom credentials remain separate from SerializedAppState. Student join - * and assignment capabilities are used once and discarded; only the issued - * per-participant workspace capability is retained in a dedicated local-only - * resume envelope. The existing collaboration controller owns all snapshot, - * revision, presence, conflict, and field-activity behavior after admission. + * Live Classroom credentials remain separate from SerializedAppState. The + * human join code is admission-only and discarded after use. A stable Student + * class-session capability is persisted locally, while assignment-specific + * workspace edit capabilities remain memory-only and are reacquired from the + * server after reload, reassignment, or reconnect. Legacy two-code Student + * sessions remain readable during the additive migration. */ import { getActiveExperienceRole } from './experienceRoleController.js'; @@ -15,10 +16,15 @@ import { EXPERIENCE_ROLE_IDS } from './experienceRoles.js'; export const STUDENT_SESSION_STORAGE_KEY = 'kt-classroom-student-session-v1'; export const STUDENT_RECOVERY_STORAGE_KEY = 'kt-classroom-student-local-recovery-v1'; export const STUDENT_SESSION_VERSION = 1; +export const STUDENT_ADMIT_ENDPOINT = '/api/classes/admit'; +export const STUDENT_STATUS_ENDPOINT = '/api/classes/student'; +export const STUDENT_ACCESS_ENDPOINT = '/api/classes/student/access'; export const STUDENT_JOIN_ENDPOINT = '/api/classes/join'; const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const JOIN_CODE_PATTERN = /^[A-HJ-NP-Z2-9]{8}$/; const DISPLAY_NAME_MAX_LENGTH = 60; +const LIVE_STATUS_POLL_MS = 2500; /** * Normalize a required Student display name for client-side validation. @@ -45,6 +51,57 @@ export function validateStudentCapability(value) { return typeof value === 'string' && CAPABILITY_PATTERN.test(value.trim()); } +/** + * Normalize the human Student class code. + * + * @param {unknown} value - Candidate join code. + * @returns {string} Normalized eight-character code or an empty string. + */ +export function normalizeStudentJoinCode(value) { + if (typeof value !== 'string') return ''; + const normalized = value.trim().toUpperCase().replace(/[\s-]+/gu, ''); + return JOIN_CODE_PATTERN.test(normalized) ? normalized : ''; +} + +function validClassContext(value) { + return value && typeof value.id === 'string' && typeof value.title === 'string'; +} + +function validParticipantContext(value) { + return value && typeof value.id === 'string' && typeof value.displayName === 'string'; +} + +function validWorkspaceContext(value) { + return value + && typeof value.id === 'string' + && ['individual', 'group'].includes(value.kind) + && typeof value.label === 'string'; +} + +function validLegacySession(parsed) { + return validateStudentCapability(parsed?.workspaceToken) + && validClassContext(parsed?.class) + && validWorkspaceContext(parsed?.workspace) + && validParticipantContext(parsed?.participant); +} + +function validLiveSession(parsed) { + return parsed?.mode === 'live' + && validateStudentCapability(parsed.studentSessionToken) + && validClassContext(parsed.class) + && validParticipantContext(parsed.participant) + && Number.isInteger(parsed.assignmentRevision) + && parsed.assignmentRevision >= 0 + && (parsed.assignment === null || validWorkspaceContext(parsed.assignment)) + && !parsed.workspaceToken; +} + +function validateStudentSessionEnvelope(parsed) { + return parsed + && parsed.version === STUDENT_SESSION_VERSION + && (validLegacySession(parsed) || validLiveSession(parsed)); +} + /** * Read a validated Student resume envelope. * @@ -55,21 +112,7 @@ export function readStudentSession(storage = globalThis.localStorage) { if (!storage) return null; try { const parsed = JSON.parse(storage.getItem(STUDENT_SESSION_STORAGE_KEY) || 'null'); - if ( - !parsed - || parsed.version !== STUDENT_SESSION_VERSION - || !validateStudentCapability(parsed.workspaceToken) - || typeof parsed.class?.id !== 'string' - || typeof parsed.class?.title !== 'string' - || typeof parsed.workspace?.id !== 'string' - || typeof parsed.workspace?.label !== 'string' - || !['individual', 'group'].includes(parsed.workspace?.kind) - || typeof parsed.participant?.id !== 'string' - || typeof parsed.participant?.displayName !== 'string' - ) { - return null; - } - return parsed; + return validateStudentSessionEnvelope(parsed) ? parsed : null; } catch { return null; } @@ -79,11 +122,11 @@ export function readStudentSession(storage = globalThis.localStorage) { * Persist a Student resume envelope outside Intake state. * * @param {Storage|null} storage - Local storage implementation. - * @param {object} session - Validated session. + * @param {object} session - Validated legacy or live session. * @returns {boolean} Whether persistence succeeded. */ export function persistStudentSession(storage, session) { - if (!storage || !session || !validateStudentCapability(session.workspaceToken)) return false; + if (!storage || !validateStudentSessionEnvelope(session)) return false; try { storage.setItem(STUDENT_SESSION_STORAGE_KEY, JSON.stringify(session)); return true; @@ -111,10 +154,13 @@ export function clearStudentSession(storage = globalThis.localStorage) { */ export function isStudentSessionExpired(session, nowMs = Date.now()) { if (!session) return false; - const expiries = [session.class?.expiresAt, session.workspace?.expiresAt] + const expiries = session.mode === 'live' + ? [session.class?.expiresAt] + : [session.class?.expiresAt, session.workspace?.expiresAt]; + const timestamps = expiries .map(value => Date.parse(value || '')) .filter(Number.isFinite); - return expiries.length > 0 && Math.min(...expiries) <= nowMs; + return timestamps.length > 0 && Math.min(...timestamps) <= nowMs; } /** @@ -135,16 +181,33 @@ export function createStudentClassroomController({ now = () => Date.now(), toast = () => {}, onClassConnected = () => {}, - onClassDisconnected = () => {} + onClassDisconnected = () => {}, + setTimeoutImpl = globalThis.setTimeout?.bind(globalThis), + clearTimeoutImpl = globalThis.clearTimeout?.bind(globalThis), + AbortControllerImpl = globalThis.AbortController }) { let activeSession = null; + let activeWorkspaceToken = ''; let connecting = false; let destroyed = false; let lastError = ''; + let liveEpoch = 0; + let statusTimer = null; + let statusAbort = null; const element = id => documentRef?.getElementById(id) || null; const intakeWrap = () => documentRef?.querySelector?.('.wrap[data-experience-surface="intake"]') || null; + const clearTimer = timer => { + if (timer !== null && typeof clearTimeoutImpl === 'function') clearTimeoutImpl(timer); + }; + + const abort = controller => { + try { controller?.abort?.(); } catch {} + }; + + const responseJson = async response => response.json().catch(() => ({})); + const setError = message => { lastError = message || ''; const error = element('studentClassJoinError'); @@ -167,16 +230,21 @@ export function createStudentClassroomController({ if (documentRef?.body) documentRef.body.dataset.studentClassStatus = status; }; + const sessionAssignment = session => ( + session?.mode === 'live' ? session.assignment : session?.workspace + ); + const renderContext = session => { + const assignment = sessionAssignment(session); const title = element('studentClassContextTitle'); const workspace = element('studentClassWorkspace'); const identity = element('studentClassIdentity'); const kind = element('studentClassWorkspaceKind'); const expiry = element('studentClassExpiry'); if (title) title.textContent = session?.class?.title || 'Student class'; - if (workspace) workspace.textContent = session?.workspace?.label || 'Assigned workspace'; + if (workspace) workspace.textContent = assignment?.label || 'Assigned workspace'; if (identity) identity.textContent = session?.participant?.displayName || 'Student'; - if (kind) kind.textContent = session?.workspace?.kind === 'group' ? 'Team workspace' : 'Individual workspace'; + if (kind) kind.textContent = assignment?.kind === 'group' ? 'Team workspace' : 'Individual workspace'; if (expiry) { const stamp = Date.parse(session?.class?.expiresAt || ''); expiry.textContent = Number.isFinite(stamp) @@ -185,29 +253,47 @@ export function createStudentClassroomController({ } }; + const setEntryPanels = ({ form = false, waiting = false, resume = false } = {}) => { + if (element('studentClassJoinForm')) element('studentClassJoinForm').hidden = !form; + if (element('studentClassWaitingPanel')) element('studentClassWaitingPanel').hidden = !waiting; + if (element('studentClassResumePanel')) element('studentClassResumePanel').hidden = !resume; + }; + const renderEntry = ({ retry = false, message = '' } = {}) => { setStatus(retry ? 'retry' : 'disconnected'); - const form = element('studentClassJoinForm'); - const resume = element('studentClassResumePanel'); - if (form) form.hidden = retry; - if (resume) resume.hidden = !retry; + setEntryPanels({ form: !retry, resume: retry }); const resumeTitle = element('studentClassResumeTitle'); - if (resumeTitle) resumeTitle.textContent = activeSession?.class?.title || 'Your class workspace'; + if (resumeTitle) resumeTitle.textContent = activeSession?.class?.title || 'Your class'; const resumeMessage = element('studentClassResumeMessage'); - if (resumeMessage) resumeMessage.textContent = message || 'We could not reconnect to your saved class workspace.'; + if (resumeMessage) resumeMessage.textContent = message || 'We could not reconnect to your saved class.'; const wrap = intakeWrap(); if (wrap && getActiveExperienceRole() === EXPERIENCE_ROLE_IDS.STUDENT) wrap.hidden = true; }; + const renderHolding = (session, { + status = 'waiting', + title = 'Waiting for your instructor', + message = 'You are in the class. Your instructor has not assigned you to a team or individual workspace yet.' + } = {}) => { + setStatus(status); + setEntryPanels({ waiting: true }); + if (element('studentClassWaitingTitle')) element('studentClassWaitingTitle').textContent = title; + if (element('studentClassWaitingMessage')) element('studentClassWaitingMessage').textContent = message; + if (element('studentClassWaitingClass')) element('studentClassWaitingClass').textContent = session?.class?.title || 'Student class'; + if (element('studentClassWaitingIdentity')) element('studentClassWaitingIdentity').textContent = session?.participant?.displayName || 'Student'; + const wrap = intakeWrap(); + if (wrap && getActiveExperienceRole() === EXPERIENCE_ROLE_IDS.STUDENT) wrap.hidden = true; + setError(''); + }; + + const renderWaiting = session => renderHolding(session); + const renderConnected = session => { setStatus('connected'); renderContext(session); + setEntryPanels({ form: true }); const wrap = intakeWrap(); if (wrap && getActiveExperienceRole() === EXPERIENCE_ROLE_IDS.STUDENT) wrap.hidden = false; - const form = element('studentClassJoinForm'); - const resume = element('studentClassResumePanel'); - if (form) form.hidden = false; - if (resume) resume.hidden = true; setError(''); }; @@ -249,7 +335,7 @@ export function createStudentClassroomController({ } }; - const buildSession = body => ({ + const buildLegacySession = body => ({ version: STUDENT_SESSION_VERSION, class: { id: body.class.id, @@ -270,12 +356,80 @@ export function createStudentClassroomController({ joinedAt: new Date(now()).toISOString() }); + const normalizeAssignment = assignment => ( + validWorkspaceContext(assignment) + ? { id: assignment.id, kind: assignment.kind, label: assignment.label } + : null + ); + + const buildLiveSession = body => ({ + version: STUDENT_SESSION_VERSION, + mode: 'live', + class: { + id: body.class.id, + title: body.class.title, + expiresAt: body.class.expiresAt || null + }, + participant: { + id: body.participant.id, + displayName: body.participant.displayName + }, + studentSessionToken: body.studentSessionToken, + assignmentRevision: body.participant.assignmentRevision, + assignment: normalizeAssignment(body.assignment), + joinedAt: new Date(now()).toISOString() + }); + const isTerminalCollaborationFailure = () => { const state = collaboration?.getState?.() || {}; return Boolean(state.pollingStopped || /invalid|missing|expired/i.test(state.terminalStatus || '')); }; - const attachSession = async session => { + const disconnectWorkspace = () => { + const state = collaboration?.getState?.() || {}; + if (activeWorkspaceToken || state.sessionKind === 'classroom') { + collaboration?.leave?.({ silent: true }); + onClassDisconnected(); + } + activeWorkspaceToken = ''; + }; + + const stopLivePolling = () => { + clearTimer(statusTimer); + statusTimer = null; + abort(statusAbort); + statusAbort = null; + }; + + const scheduleLiveStatus = (epoch, delay = LIVE_STATUS_POLL_MS) => { + clearTimer(statusTimer); + statusTimer = null; + if ( + destroyed + || activeSession?.mode !== 'live' + || epoch !== liveEpoch + || getActiveExperienceRole() !== EXPERIENCE_ROLE_IDS.STUDENT + || typeof setTimeoutImpl !== 'function' + ) return; + statusTimer = setTimeoutImpl(() => { + statusTimer = null; + void syncLiveStatus(epoch); + }, delay); + }; + + const terminalLiveSession = message => { + liveEpoch += 1; + stopLivePolling(); + disconnectWorkspace(); + clearStudentSession(storage); + activeSession = null; + restoreLocalRecovery(); + renderEntry(); + setError(message || 'This class session is no longer available. Ask your instructor for the current class code.'); + return false; + }; + + const attachLegacySession = async session => { activeSession = session; setBusy(true); setStatus('connecting'); @@ -286,6 +440,7 @@ export function createStudentClassroomController({ }); setBusy(false); if (connected) { + activeWorkspaceToken = session.workspaceToken; renderConnected(session); onClassConnected(session.workspaceToken); return true; @@ -293,6 +448,7 @@ export function createStudentClassroomController({ if (isTerminalCollaborationFailure()) { collaboration?.leave?.({ silent: true }); onClassDisconnected(); + activeWorkspaceToken = ''; clearStudentSession(storage); activeSession = null; restoreLocalRecovery(); @@ -307,6 +463,237 @@ export function createStudentClassroomController({ return false; }; + const validLiveStatusBody = body => ( + validClassContext(body?.class) + && validParticipantContext(body?.participant) + && Number.isInteger(body.participant.assignmentRevision) + && body.participant.assignmentRevision >= 0 + && (body.assignment === null || validWorkspaceContext(body.assignment)) + ); + + const updateLiveSessionFromStatus = body => { + activeSession = { + ...activeSession, + class: { + id: body.class.id, + title: body.class.title, + expiresAt: body.class.expiresAt || null + }, + participant: { + id: body.participant.id, + displayName: body.participant.displayName + }, + assignmentRevision: body.participant.assignmentRevision, + assignment: normalizeAssignment(body.assignment) + }; + persistStudentSession(storage, activeSession); + }; + + const connectLiveAssignment = async epoch => { + if (destroyed || activeSession?.mode !== 'live' || epoch !== liveEpoch || !activeSession.assignment) return false; + const target = activeSession.assignment; + disconnectWorkspace(); + renderHolding(activeSession, { + status: 'connecting', + title: `Joining ${target.label}`, + message: 'Your instructor assigned your workspace. Loading the current team Intake…' + }); + + try { + const response = await fetchImpl(STUDENT_ACCESS_ENDPOINT, { + method: 'POST', + headers: { Authorization: `Bearer ${activeSession.studentSessionToken}` } + }); + const body = await responseJson(response); + if (epoch !== liveEpoch || destroyed) return false; + + if (response.status === 404 || response.status === 401) { + return terminalLiveSession('This class session is no longer available. Ask your instructor for the current class code.'); + } + if (response.status === 409 && body.status === 'waiting' && validParticipantContext(body.participant)) { + activeSession = { + ...activeSession, + class: validClassContext(body.class) ? { + id: body.class.id, + title: body.class.title, + expiresAt: body.class.expiresAt || null + } : activeSession.class, + participant: { + id: body.participant.id, + displayName: body.participant.displayName + }, + assignmentRevision: Number.isInteger(body.participant.assignmentRevision) + ? body.participant.assignmentRevision + : activeSession.assignmentRevision, + assignment: null + }; + persistStudentSession(storage, activeSession); + renderWaiting(activeSession); + scheduleLiveStatus(epoch); + return true; + } + if ( + !response.ok + || !validateStudentCapability(body.workspaceToken) + || !validClassContext(body.class) + || !validParticipantContext(body.participant) + || !Number.isInteger(body.participant.assignmentRevision) + || !validWorkspaceContext(body.assignment) + ) { + renderHolding(activeSession, { + status: 'retry', + title: 'Reconnecting to your assignment', + message: 'We could not load your assigned workspace yet. Intake will retry automatically.' + }); + scheduleLiveStatus(epoch); + return false; + } + + activeSession = { + ...activeSession, + class: { + id: body.class.id, + title: body.class.title, + expiresAt: body.class.expiresAt || null + }, + participant: { + id: body.participant.id, + displayName: body.participant.displayName + }, + assignmentRevision: body.participant.assignmentRevision, + assignment: normalizeAssignment(body.assignment) + }; + persistStudentSession(storage, activeSession); + + const workspaceToken = body.workspaceToken; + const connected = await collaboration?.connect?.(workspaceToken, { + displayName: activeSession.participant.displayName, + classroom: true + }); + if (epoch !== liveEpoch || destroyed) { + collaboration?.leave?.({ silent: true }); + return false; + } + if (!connected) { + activeWorkspaceToken = ''; + renderHolding(activeSession, { + status: 'retry', + title: 'Assignment changed', + message: 'Your workspace access changed while Intake was connecting. Retrying the current assignment…' + }); + scheduleLiveStatus(epoch, 500); + return false; + } + + activeWorkspaceToken = workspaceToken; + renderConnected(activeSession); + onClassConnected(workspaceToken); + scheduleLiveStatus(epoch); + return true; + } catch { + if (epoch !== liveEpoch || destroyed) return false; + renderHolding(activeSession, { + status: 'retry', + title: 'Reconnecting to your assignment', + message: 'The class service is temporarily unavailable. Intake will retry automatically.' + }); + scheduleLiveStatus(epoch); + return false; + } + }; + + async function syncLiveStatus(epoch = liveEpoch) { + if ( + destroyed + || activeSession?.mode !== 'live' + || epoch !== liveEpoch + || getActiveExperienceRole() !== EXPERIENCE_ROLE_IDS.STUDENT + ) return false; + + abort(statusAbort); + statusAbort = typeof AbortControllerImpl === 'function' ? new AbortControllerImpl() : null; + try { + const response = await fetchImpl(STUDENT_STATUS_ENDPOINT, { + method: 'GET', + headers: { Authorization: `Bearer ${activeSession.studentSessionToken}` }, + signal: statusAbort?.signal + }); + const body = await responseJson(response); + statusAbort = null; + if (epoch !== liveEpoch || destroyed) return false; + + if (response.status === 404 || response.status === 401) { + return terminalLiveSession('This class session is no longer available. Ask your instructor for the current class code.'); + } + if (!response.ok || !validLiveStatusBody(body)) { + if (!activeWorkspaceToken) { + renderHolding(activeSession, { + status: 'retry', + title: 'Checking your class assignment', + message: 'The class service is temporarily unavailable. Intake will keep retrying.' + }); + } + scheduleLiveStatus(epoch); + return false; + } + + const previousRevision = activeSession.assignmentRevision; + const previousAssignmentId = activeSession.assignment?.id || null; + const nextAssignmentId = body.assignment?.id || null; + const assignmentChanged = previousRevision !== body.participant.assignmentRevision + || previousAssignmentId !== nextAssignmentId; + + updateLiveSessionFromStatus(body); + + if (!activeSession.assignment) { + const wasAssigned = Boolean(activeWorkspaceToken || previousAssignmentId); + disconnectWorkspace(); + renderWaiting(activeSession); + if (wasAssigned && assignmentChanged) toast('Your instructor moved you back to Waiting.'); + scheduleLiveStatus(epoch); + return true; + } + + if (!assignmentChanged && activeWorkspaceToken) { + renderConnected(activeSession); + scheduleLiveStatus(epoch); + return true; + } + + if (previousAssignmentId && previousAssignmentId !== activeSession.assignment.id) { + toast(`Your instructor moved you to ${activeSession.assignment.label}.`); + } + return connectLiveAssignment(epoch); + } catch { + statusAbort = null; + if (epoch !== liveEpoch || destroyed) return false; + if (!activeWorkspaceToken) { + renderHolding(activeSession, { + status: 'retry', + title: 'Checking your class assignment', + message: 'The class service is temporarily unavailable. Intake will keep retrying.' + }); + } + scheduleLiveStatus(epoch); + return false; + } + } + + const activateLiveSession = async session => { + activeSession = session; + activeWorkspaceToken = ''; + liveEpoch += 1; + const epoch = liveEpoch; + stopLivePolling(); + persistStudentSession(storage, session); + renderHolding(session, { + status: 'connecting', + title: 'Joining your class', + message: 'Checking whether your instructor has assigned a workspace…' + }); + return syncLiveStatus(epoch); + }; + const resume = async () => { if (destroyed || getActiveExperienceRole() !== EXPERIENCE_ROLE_IDS.STUDENT) return false; const collaborationState = collaboration?.getState?.() || {}; @@ -316,27 +703,135 @@ export function createStudentClassroomController({ const stored = readStudentSession(storage); if (!stored) { activeSession = null; + activeWorkspaceToken = ''; renderEntry(); return false; } if (isStudentSessionExpired(stored, now())) { + if (stored.mode === 'live') { + activeSession = stored; + return terminalLiveSession('Your saved class access has expired. Ask your instructor for the current class code.'); + } clearStudentSession(storage); activeSession = null; + activeWorkspaceToken = ''; restoreLocalRecovery(); renderEntry(); setError('Your saved class access has expired. Ask your instructor for new class and assignment codes.'); return false; } - return attachSession(stored); + if (stored.mode === 'live') { + return activateLiveSession(stored); + } + return attachLegacySession(stored); }; - const join = async ({ classCode, assignmentCode, displayName }) => { - if (connecting) return false; - const normalizedName = normalizeStudentDisplayName(displayName); + const clearAdmissionInputs = () => { + if (element('studentClassCode')) element('studentClassCode').value = ''; + if (element('studentAssignmentCode')) element('studentAssignmentCode').value = ''; + if (element('studentLegacyJoin')) element('studentLegacyJoin').open = false; + }; + + const joinLegacy = async ({ classCode, assignmentCode, displayName, participantId }) => { const joinToken = typeof classCode === 'string' ? classCode.trim() : ''; const assignmentToken = typeof assignmentCode === 'string' ? assignmentCode.trim() : ''; - if (!validateStudentCapability(joinToken) || !validateStudentCapability(assignmentToken) || !normalizedName) { - setError('Enter your name and the current class and assignment codes from your instructor.'); + if (!validateStudentCapability(joinToken) || !validateStudentCapability(assignmentToken)) { + setError('Enter the current long class and assignment codes from your instructor.'); + return false; + } + + const response = await fetchImpl(STUDENT_JOIN_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${joinToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + assignmentToken, + participantId, + displayName + }) + }); + const body = await responseJson(response); + if (!response.ok) { + if (response.status === 404) { + setError('The class or assignment code was not accepted. Ask your instructor for the current codes.'); + } else if (response.status === 400) { + setError('Check your name and class codes, then try again.'); + } else { + setError('The class service is unavailable right now. Your local Intake is unchanged.'); + } + return false; + } + if ( + !validateStudentCapability(body.workspaceToken) + || !validClassContext(body.class) + || !validWorkspaceContext(body.workspace) + || !validParticipantContext(body.self) + ) { + setError('The class service returned an incomplete workspace response.'); + return false; + } + + preserveLocalRecovery(); + const session = buildLegacySession(body); + persistStudentSession(storage, session); + activeSession = session; + clearAdmissionInputs(); + return attachLegacySession(session); + }; + + const joinLive = async ({ classCode, displayName, participantId }) => { + const joinCode = normalizeStudentJoinCode(classCode); + if (!joinCode) { + setError('Enter the eight-character class code from your instructor.'); + return false; + } + + const response = await fetchImpl(STUDENT_ADMIT_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + joinCode, + participantId, + displayName + }) + }); + const body = await responseJson(response); + if (!response.ok) { + if (response.status === 404) { + setError('That class code is not available. Ask your instructor for the current code.'); + } else if (response.status === 400) { + setError('Check your name and class code, then try again.'); + } else { + setError('The class service is unavailable right now. Your local Intake is unchanged.'); + } + return false; + } + if ( + !validateStudentCapability(body.studentSessionToken) + || !validClassContext(body.class) + || !validParticipantContext(body.participant) + || !Number.isInteger(body.participant.assignmentRevision) + || (body.assignment !== null && !validWorkspaceContext(body.assignment)) + ) { + setError('The class service returned an incomplete admission response.'); + return false; + } + + preserveLocalRecovery(); + const session = buildLiveSession(body); + persistStudentSession(storage, session); + activeSession = session; + clearAdmissionInputs(); + return activateLiveSession(session); + }; + + const join = async ({ classCode, assignmentCode = '', displayName }) => { + if (connecting) return false; + const normalizedName = normalizeStudentDisplayName(displayName); + if (!normalizedName) { + setError('Enter your name before joining the class.'); return false; } const participantId = collaboration?.getState?.().profile?.participantId; @@ -348,54 +843,18 @@ export function createStudentClassroomController({ setBusy(true); setError(''); try { - const response = await fetchImpl(STUDENT_JOIN_ENDPOINT, { - method: 'POST', - headers: { - Authorization: `Bearer ${joinToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - assignmentToken, - participantId, - displayName: normalizedName - }) - }); - const body = await response.json().catch(() => ({})); - if (!response.ok) { - if (response.status === 404) { - setError('The class or assignment code was not accepted. Ask your instructor for the current codes.'); - } else if (response.status === 400) { - setError('Check your name and class codes, then try again.'); - } else { - setError('The class service is unavailable right now. Your local Intake is unchanged.'); - } - return false; - } - if ( - !validateStudentCapability(body.workspaceToken) - || typeof body.class?.id !== 'string' - || typeof body.class?.title !== 'string' - || typeof body.workspace?.id !== 'string' - || !['individual', 'group'].includes(body.workspace?.kind) - || typeof body.workspace?.label !== 'string' - || typeof body.self?.id !== 'string' - || typeof body.self?.displayName !== 'string' - ) { - setError('The class service returned an incomplete workspace response.'); - return false; - } - - preserveLocalRecovery(); - const session = buildSession(body); - persistStudentSession(storage, session); - activeSession = session; - - const classInput = element('studentClassCode'); - const assignmentInput = element('studentAssignmentCode'); - if (classInput) classInput.value = ''; - if (assignmentInput) assignmentInput.value = ''; - - return await attachSession(session); + return assignmentCode?.trim() + ? await joinLegacy({ + classCode, + assignmentCode, + displayName: normalizedName, + participantId + }) + : await joinLive({ + classCode, + displayName: normalizedName, + participantId + }); } catch { setError('Could not reach the class service. Your local Intake is unchanged.'); return false; @@ -405,8 +864,9 @@ export function createStudentClassroomController({ }; const leaveClass = ({ restore = true } = {}) => { - onClassDisconnected(); - collaboration?.leave?.({ silent: true }); + liveEpoch += 1; + stopLivePolling(); + disconnectWorkspace(); clearStudentSession(storage); activeSession = null; const restored = restore ? restoreLocalRecovery() : false; @@ -420,26 +880,25 @@ export function createStudentClassroomController({ const handleJoinSubmit = event => { event.preventDefault(); - join({ + void join({ classCode: element('studentClassCode')?.value || '', assignmentCode: element('studentAssignmentCode')?.value || '', displayName: element('studentDisplayName')?.value || '' }); }; - const handleRetry = () => resume(); + const handleRetry = () => { void resume(); }; const handleLeave = () => leaveClass(); const handleRoleChange = event => { const role = event?.detail?.role; if (role === EXPERIENCE_ROLE_IDS.STUDENT) { - resume(); + void resume(); return; } - if (collaboration?.getState?.().sessionKind === 'classroom') { - onClassDisconnected(); - collaboration.leave({ silent: true }); - } + liveEpoch += 1; + stopLivePolling(); + disconnectWorkspace(); restoreLocalRecovery({ clear: false }); }; @@ -447,18 +906,22 @@ export function createStudentClassroomController({ element('studentClassJoinForm')?.addEventListener('submit', handleJoinSubmit); element('studentClassRetryBtn')?.addEventListener('click', handleRetry); element('studentClassLeaveBtn')?.addEventListener('click', handleLeave); + element('studentClassWaitingLeaveBtn')?.addEventListener('click', handleLeave); windowRef?.addEventListener?.('intake:experience-role-changed', handleRoleChange); - if (getActiveExperienceRole() === EXPERIENCE_ROLE_IDS.STUDENT) resume(); + if (getActiveExperienceRole() === EXPERIENCE_ROLE_IDS.STUDENT) void resume(); return true; }; const destroy = () => { if (destroyed) return; destroyed = true; + liveEpoch += 1; + stopLivePolling(); onClassDisconnected(); element('studentClassJoinForm')?.removeEventListener('submit', handleJoinSubmit); element('studentClassRetryBtn')?.removeEventListener('click', handleRetry); element('studentClassLeaveBtn')?.removeEventListener('click', handleLeave); + element('studentClassWaitingLeaveBtn')?.removeEventListener('click', handleLeave); windowRef?.removeEventListener?.('intake:experience-role-changed', handleRoleChange); }; @@ -467,8 +930,15 @@ export function createStudentClassroomController({ destroy, join, resume, + refreshStatus: () => activeSession?.mode === 'live' ? syncLiveStatus(liveEpoch) : false, leaveClass, - getState: () => ({ activeSession, connecting, lastError }) + getState: () => ({ + activeSession, + activeWorkspaceToken, + connecting, + lastError, + liveEpoch + }) }; } diff --git a/styles.css b/styles.css index 9aeeff8..b95897c 100644 --- a/styles.css +++ b/styles.css @@ -395,7 +395,24 @@ body.experience-role-gate-open{overflow:hidden;} grid-template-columns:minmax(0,.8fr) repeat(2,minmax(0,1.1fr)); gap:16px; } -.student-class-form input[type="password"]{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.02em;} +.student-class-form__grid--live{grid-template-columns:minmax(0,.8fr) minmax(0,1.2fr);} +#studentClassCode,.student-class-form input[type="password"]{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.02em;} +.student-legacy-join{margin-top:16px;padding-top:14px;border-top:1px solid var(--line);} +.student-legacy-join>summary{cursor:pointer;color:var(--muted);font-size:12px;font-weight:750;} +.student-legacy-join[open]>summary{color:var(--ink-strong);} +.student-legacy-join .field{max-width:680px;margin-top:12px;} +.student-class-waiting{ + padding:22px; + border:1px solid color-mix(in srgb,var(--accent) 30%,var(--line)); + border-radius:16px; + background:color-mix(in srgb,var(--accent) 6%,var(--panel)); +} +.student-class-waiting>strong{display:block;color:var(--ink-strong);font-size:20px;} +.student-class-waiting>.student-class-resume__eyebrow{color:var(--accent-text);} +.student-class-waiting>p{max-width:720px;margin:8px 0 0;color:var(--muted);font-size:13px;line-height:1.6;} +.student-class-waiting__meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px;} +.student-class-waiting__meta>span{padding:6px 9px;border:1px solid var(--line);border-radius:999px;background:var(--panel);color:var(--muted);font-size:11px;} +.student-class-waiting__meta strong{display:inline;color:var(--ink-strong);font-size:11px;} .student-class-form__privacy{ margin:16px 0 0; color:var(--muted); @@ -3131,6 +3148,14 @@ body.dialog-open{overflow:hidden;} /* Instructor classroom observer */ .instructor-class-form{margin-top:24px;} +.instructor-class-start-form{margin-top:22px;padding-bottom:18px;border-bottom:1px solid var(--line);} +.instructor-existing-class{margin-top:14px;} +.instructor-existing-class>summary{cursor:pointer;color:var(--muted);font-size:12px;font-weight:750;} +.instructor-existing-class[open]>summary{color:var(--ink-strong);} +.instructor-join-code{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:12px;} +.instructor-join-code>span{color:var(--muted);font-size:10px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;} +.instructor-join-code>strong{padding:6px 9px;border:1px solid var(--line);border-radius:10px;background:var(--panel-muted);color:var(--ink-strong);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:15px;letter-spacing:.08em;} +.instructor-join-code .btn-secondary{padding:6px 9px;font-size:11px;} .instructor-class-form .field{max-width:680px;} .instructor-class-form input[type="password"]{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.02em;} .instructor-dashboard{display:flex;flex-direction:column;min-height:0;} @@ -3138,6 +3163,21 @@ body.dialog-open{overflow:hidden;} .instructor-dashboard__header h2{margin:0;color:var(--ink-strong);font-size:22px;letter-spacing:-.02em;} .instructor-dashboard__header p{margin:6px 0 0;color:var(--muted);font-size:12px;} .instructor-dashboard__actions{display:flex;gap:8px;flex-wrap:wrap;} +.instructor-live-management{margin:16px 0;padding:12px;border:1px solid var(--line);border-radius:14px;background:var(--panel-muted);} +.instructor-live-management__heading{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px;} +.instructor-live-management__heading>div{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;} +.instructor-live-management__heading strong,.instructor-participant-heading strong{color:var(--ink-strong);font-size:13px;} +.instructor-live-management__heading span,.instructor-participant-heading span{color:var(--muted);font-size:10px;} +.instructor-workspace-create{display:grid;grid-template-columns:minmax(0,1fr) 105px auto;gap:7px;margin-bottom:12px;} +.instructor-workspace-create input,.instructor-workspace-create select{min-width:0;} +.instructor-participant-heading{display:flex;align-items:baseline;justify-content:space-between;gap:8px;margin-bottom:7px;} +.instructor-participant-list{display:flex;flex-direction:column;gap:6px;max-height:235px;overflow:auto;} +.instructor-participant-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(120px,150px);gap:8px;align-items:center;padding:8px 9px;border:1px solid var(--line);border-radius:11px;background:var(--panel);} +.instructor-participant-row__identity{min-width:0;} +.instructor-participant-row__identity strong{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ink-strong);font-size:12px;} +.instructor-participant-row__identity span{display:block;margin-top:2px;color:var(--muted);font-size:10px;} +.instructor-participant-row select{width:100%;min-width:0;font-size:11px;} +.instructor-participant-empty{margin:6px 2px;color:var(--muted);font-size:11px;line-height:1.45;} .instructor-roster-controls{display:grid;grid-template-columns:minmax(0,1fr) 130px;gap:8px;margin:16px 0 12px;} .instructor-roster-controls input,.instructor-roster-controls select{min-width:0;} .instructor-roster-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:8px;color:var(--muted);font-size:12px;} @@ -3177,6 +3217,9 @@ body[data-experience-role="instructor"].instructor-class-connected .wrap{max-wid .instructor-dashboard__header,.instructor-observation-notice{flex-direction:column;align-items:stretch;} .instructor-dashboard__actions{flex-direction:column;} .instructor-roster-controls{grid-template-columns:1fr;} + .instructor-workspace-create{grid-template-columns:1fr;} + .instructor-participant-heading{align-items:flex-start;flex-direction:column;} + .instructor-participant-row{grid-template-columns:1fr;} .instructor-observation-participants{justify-content:flex-start;max-width:none;} } diff --git a/tests/classroom-api.unit.test.mjs b/tests/classroom-api.unit.test.mjs index 8b0b243..f1bfacf 100644 --- a/tests/classroom-api.unit.test.mjs +++ b/tests/classroom-api.unit.test.mjs @@ -5,15 +5,23 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { + CLASS_JOIN_CODE_ALPHABET, + CLASS_JOIN_CODE_LENGTH, CLASS_TITLE_MAX_LENGTH, CLASS_WORKSPACE_LABEL_MAX_LENGTH, COACHING_NOTE_MAX_LENGTH, + classAdmitHandler, classCoachingHandler, classHandler, classJoinHandler, + classParticipantsHandler, classObserveHandler, + classStudentAccessHandler, classStudentCoachingHandler, + classStudentHandler, classWorkspacesHandler, + formatClassJoinCode, + normalizeClassJoinCode, normalizeClassroomLabel, normalizeCoachingNote, normalizeCoachingStatus, @@ -35,6 +43,7 @@ const CLASS_A_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const CLASS_B_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; const WORKSPACE_A_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const WORKSPACE_B_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +const WORKSPACE_C_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; const PARTICIPANT_A = '11111111-1111-4111-8111-111111111111'; const PARTICIPANT_B = '22222222-2222-4222-8222-222222222222'; @@ -48,11 +57,112 @@ test('classroom labels, kinds, and public IDs validate conservatively', () => { assert.equal(validateClassroomId('not-an-id'), false); }); +test('live Classroom join codes normalize to an unambiguous eight-character contract', () => { + assert.equal(CLASS_JOIN_CODE_LENGTH, 8); + assert.equal(CLASS_JOIN_CODE_ALPHABET.includes('0'), false); + assert.equal(CLASS_JOIN_CODE_ALPHABET.includes('O'), false); + assert.equal(CLASS_JOIN_CODE_ALPHABET.includes('1'), false); + assert.equal(CLASS_JOIN_CODE_ALPHABET.includes('I'), false); + assert.equal(normalizeClassJoinCode(' k7fm-p4q2 '), 'K7FMP4Q2'); + assert.equal(formatClassJoinCode('k7fmp4q2'), 'K7FM-P4Q2'); + assert.equal(normalizeClassJoinCode('K7F0-P4Q2'), null); + assert.equal(normalizeClassJoinCode('too-short'), null); +}); + +test('live participant admission creates a waiting Student and rotates only the class-session capability', async () => { + const classrooms = createClassroomRepository(); + + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + + const first = await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_A, + displayName: 'Alex', + sessionHash: testTokenHash('S') + }); + + assert.equal(first.classroom.id, CLASS_A_ID); + assert.equal(first.participant.id, PARTICIPANT_A); + assert.equal(first.participant.displayName, 'Alex'); + assert.equal(first.participant.assignmentRevision, 0); + assert.equal(first.assignment, null); + + const firstSession = await classrooms.getParticipantBySession(testTokenHash('S')); + assert.equal(firstSession.classroom.id, CLASS_A_ID); + assert.equal(firstSession.assignment, null); + + const readmission = await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_A, + displayName: 'Alex Updated', + sessionHash: testTokenHash('T') + }); + + assert.equal(readmission.participant.displayName, 'Alex Updated'); + assert.equal(readmission.participant.assignmentRevision, 0); + assert.equal(await classrooms.getParticipantBySession(testTokenHash('S')), null); + assert.equal((await classrooms.getParticipantBySession(testTokenHash('T'))).participant.displayName, 'Alex Updated'); + + const roster = await classrooms.listParticipants(testTokenHash('A')); + assert.equal(roster.classroom.id, CLASS_A_ID); + assert.deepEqual(roster.participants.map(item => ({ + id: item.id, + assignment: item.assignment + })), [{ id: PARTICIPANT_A, assignment: null }]); +}); + +test('live participant admission is class-scoped and invalid join codes do not create roster entries', async () => { + const classrooms = createClassroomRepository(); + + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + await classrooms.createClass({ + publicId: CLASS_B_ID, + title: 'Class B', + instructorHash: testTokenHash('B'), + studentJoinHash: testTokenHash('D'), + studentJoinCode: 'M8RNQ5W3' + }); + + const denied = await classrooms.admitParticipant({ + joinCode: 'ZZZZZZZZ', + participantId: PARTICIPANT_A, + displayName: 'Alex', + sessionHash: testTokenHash('S') + }); + assert.equal(denied, null); + + await classrooms.admitParticipant({ + joinCode: 'M8RNQ5W3', + participantId: PARTICIPANT_B, + displayName: 'Blair', + sessionHash: testTokenHash('T') + }); + + const classA = await classrooms.listParticipants(testTokenHash('A')); + const classB = await classrooms.listParticipants(testTokenHash('B')); + assert.deepEqual(classA.participants, []); + assert.deepEqual(classB.participants.map(item => item.id), [PARTICIPANT_B]); + assert.equal(await classrooms.listParticipants(testTokenHash('C')), null); +}); + test('class creation returns raw capabilities once and stores only hashes', async () => { const classrooms = createClassroomRepository(); const handler = classHandler({ getRepository: async () => classrooms, tokenFactory: tokenFactory(['I', 'J']), + joinCodeFactory: () => 'K7FMP4Q2', idFactory: () => CLASS_A_ID }); @@ -63,12 +173,534 @@ test('class creation returns raw capabilities once and stores only hashes', asyn assert.equal(res.body.class.title, 'Fall Workshop'); assert.equal(res.body.instructorToken, 'I'.repeat(43)); assert.equal(res.body.studentJoinToken, 'J'.repeat(43)); + assert.equal(res.body.joinCode, 'K7FM-P4Q2'); + assert.equal(classrooms.classes[0].studentJoinCode, 'K7FMP4Q2'); assert.equal(classrooms.classes[0].instructorHash, testTokenHash('I')); assert.equal(classrooms.classes[0].studentJoinHash, testTokenHash('J')); assert.equal(res.headers['Cache-Control'], 'no-store'); assert.equal(res.headers['Referrer-Policy'], 'no-referrer'); }); +test('one-code admission creates a waiting Student class session without workspace edit authority', async () => { + const classrooms = createClassroomRepository(); + const workspaceRepo = createWorkspaceRepository(); + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + + const admit = classAdmitHandler({ + getRepository: async () => classrooms, + tokenFactory: tokenFactory(['S']) + }); + const admitted = response(); + await admit({ + method: 'POST', + body: { + joinCode: ' k7fm-p4q2 ', + participantId: PARTICIPANT_A, + displayName: ' Alex ' + } + }, admitted); + + assert.equal(admitted.statusCode, 200); + assert.equal(admitted.body.class.id, CLASS_A_ID); + assert.equal(admitted.body.participant.displayName, 'Alex'); + assert.equal(admitted.body.participant.assignmentRevision, 0); + assert.equal(admitted.body.assignment, null); + assert.equal(admitted.body.studentSessionToken, 'S'.repeat(43)); + + const directWorkspace = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'GET', + headers: { authorization: 'Bearer ' + admitted.body.studentSessionToken } + }, directWorkspace); + assert.equal(directWorkspace.statusCode, 404, 'class-session authority is not collaboration edit authority'); + + const invalid = response(); + await classAdmitHandler({ + getRepository: async () => classrooms, + tokenFactory: tokenFactory(['T']) + })({ + method: 'POST', + body: { + joinCode: 'M8RN-Q5W3', + participantId: PARTICIPANT_B, + displayName: 'Blair' + } + }, invalid); + assert.equal(invalid.statusCode, 404); + assert.deepEqual(invalid.body, { error: 'Class not available.' }); +}); + +test('Instructor participant roster is class-scoped while Student status returns only self', async () => { + const classrooms = createClassroomRepository(); + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + + await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_A, + displayName: 'Alex', + sessionHash: testTokenHash('S') + }); + await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_B, + displayName: 'Blair', + sessionHash: testTokenHash('T') + }); + + const rosterHandler = classParticipantsHandler({ getRepository: async () => classrooms }); + const roster = response(); + await rosterHandler({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) } + }, roster); + assert.equal(roster.statusCode, 200); + assert.deepEqual(roster.body.participants.map(item => item.displayName), ['Alex', 'Blair']); + + const studentCannotEnumerate = response(); + await rosterHandler({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, studentCannotEnumerate); + assert.equal(studentCannotEnumerate.statusCode, 404); + + const statusHandler = classStudentHandler({ getRepository: async () => classrooms }); + const alex = response(); + await statusHandler({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, alex); + assert.equal(alex.statusCode, 200); + assert.equal(alex.body.participant.id, PARTICIPANT_A); + assert.equal(alex.body.participant.displayName, 'Alex'); + assert.equal(alex.body.assignment, null); + assert.equal('participants' in alex.body, false); + assert.equal('workspaceToken' in alex.body, false); +}); + +test('Student workspace access waits while unassigned and rotates assignment-specific edit authority', async () => { + const classrooms = createClassroomRepository(); + const workspaceRepo = createWorkspaceRepository(); + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + const workspace = await workspaceRepo.create( + testTokenHash('P'), + { pre: { oneLine: 'Team A current Intake' } }, + 30, + 'Team A' + ); + await classrooms.addWorkspace(testTokenHash('A'), { + publicId: WORKSPACE_A_ID, + workspaceId: workspace.id, + kind: 'group', + label: 'Team A', + claimHash: testTokenHash('X') + }); + await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_A, + displayName: 'Alex', + sessionHash: testTokenHash('S') + }); + + const waitingHandler = classStudentAccessHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + tokenFactory: tokenFactory(['W']) + }); + const waiting = response(); + await waitingHandler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, waiting); + assert.equal(waiting.statusCode, 409); + assert.equal(waiting.body.status, 'waiting'); + assert.equal(waiting.body.assignment, null); + assert.equal('workspaceToken' in waiting.body, false); + + const participant = [...classrooms.participants.values()][0]; + participant.workspaceId = workspace.id; + participant.assignmentRevision = 1; + + const firstAccess = response(); + await classStudentAccessHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + tokenFactory: tokenFactory(['W']) + })({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, firstAccess); + assert.equal(firstAccess.statusCode, 200); + assert.equal(firstAccess.body.assignment.id, WORKSPACE_A_ID); + assert.equal(firstAccess.body.participant.assignmentRevision, 1); + assert.equal(firstAccess.body.workspaceToken, 'W'.repeat(43)); + + const firstLoad = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) } + }, firstLoad); + assert.equal(firstLoad.statusCode, 200); + assert.equal(firstLoad.body.snapshot.pre.oneLine, 'Team A current Intake'); + + await classrooms.upsertFeedback(testTokenHash('A'), WORKSPACE_A_ID, { + targetId: 'problem.one-line', + status: 'meets-standard', + note: 'Clear', + reviewedWorkspaceRevision: 1, + reviewedFieldFingerprint: 'v1-0123456789abcdef' + }); + const liveCoaching = response(); + await classStudentCoachingHandler({ getRepository: async () => classrooms })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) } + }, liveCoaching); + assert.equal(liveCoaching.statusCode, 200); + assert.deepEqual(liveCoaching.body.feedback.map(item => item.targetId), ['problem.one-line']); + + const rotated = response(); + await classStudentAccessHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + tokenFactory: tokenFactory(['V']) + })({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, rotated); + assert.equal(rotated.statusCode, 200); + assert.equal(rotated.body.workspaceToken, 'V'.repeat(43)); + + const oldLoad = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) } + }, oldLoad); + assert.equal(oldLoad.statusCode, 404); + + const newLoad = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'V'.repeat(43) } + }, newLoad); + assert.equal(newLoad.statusCode, 200); +}); + +test('Instructor assign -> reassign -> unassign revokes old authority and never merges team Intake', async () => { + const classrooms = createClassroomRepository(); + const workspaceRepo = createWorkspaceRepository(); + + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + await classrooms.createClass({ + publicId: CLASS_B_ID, + title: 'Class B', + instructorHash: testTokenHash('B'), + studentJoinHash: testTokenHash('D'), + studentJoinCode: 'M8RNQ5W3' + }); + + const teamA = await workspaceRepo.create( + testTokenHash('P'), + { pre: { oneLine: 'Team A original' } }, + 30, + 'Team A' + ); + const teamB = await workspaceRepo.create( + testTokenHash('Q'), + { pre: { oneLine: 'Team B original' } }, + 30, + 'Team B' + ); + const otherClass = await workspaceRepo.create( + testTokenHash('R'), + { pre: { oneLine: 'Other class' } }, + 30, + 'Other Class Team' + ); + + await classrooms.addWorkspace(testTokenHash('A'), { + publicId: WORKSPACE_A_ID, + workspaceId: teamA.id, + kind: 'group', + label: 'Team A', + claimHash: testTokenHash('X') + }); + await classrooms.addWorkspace(testTokenHash('A'), { + publicId: WORKSPACE_B_ID, + workspaceId: teamB.id, + kind: 'group', + label: 'Team B', + claimHash: testTokenHash('Y') + }); + await classrooms.addWorkspace(testTokenHash('B'), { + publicId: WORKSPACE_C_ID, + workspaceId: otherClass.id, + kind: 'group', + label: 'Other Class Team', + claimHash: testTokenHash('Z') + }); + await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_A, + displayName: 'Alex', + sessionHash: testTokenHash('S') + }); + + const participantHandler = classParticipantsHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo + }); + + const assigned = response(); + await participantHandler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { participantId: PARTICIPANT_A, workspaceId: WORKSPACE_A_ID } + }, assigned); + assert.equal(assigned.statusCode, 200); + assert.equal(assigned.body.changed, true); + assert.equal(assigned.body.assignment.id, WORKSPACE_A_ID); + assert.equal(assigned.body.participant.assignmentRevision, 1); + + const repeated = response(); + await participantHandler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { participantId: PARTICIPANT_A, workspaceId: WORKSPACE_A_ID } + }, repeated); + assert.equal(repeated.statusCode, 200); + assert.equal(repeated.body.changed, false); + assert.equal(repeated.body.participant.assignmentRevision, 1); + + const teamAAccess = response(); + await classStudentAccessHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + tokenFactory: tokenFactory(['W']) + })({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, teamAAccess); + assert.equal(teamAAccess.statusCode, 200); + assert.equal(teamAAccess.body.workspaceToken, 'W'.repeat(43)); + + const editedA = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { + revision: 1, + snapshot: { pre: { oneLine: 'Alex changed Team A only' } } + } + }, editedA); + assert.equal(editedA.statusCode, 200); + + await workspaceRepo.upsertPresence(testTokenHash('W'), PARTICIPANT_A, 'Alex'); + + const moved = response(); + await participantHandler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { participantId: PARTICIPANT_A, workspaceId: WORKSPACE_B_ID } + }, moved); + assert.equal(moved.statusCode, 200); + assert.equal(moved.body.changed, true); + assert.equal(moved.body.assignment.id, WORKSPACE_B_ID); + assert.equal(moved.body.participant.assignmentRevision, 2); + + const staleRead = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) } + }, staleRead); + assert.equal(staleRead.statusCode, 404); + + const staleWrite = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { + revision: 2, + snapshot: { pre: { oneLine: 'Must never become Team B' } } + } + }, staleWrite); + assert.equal(staleWrite.statusCode, 404); + + const staleCoaching = response(); + await classStudentCoachingHandler({ getRepository: async () => classrooms })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) } + }, staleCoaching); + assert.equal(staleCoaching.statusCode, 404); + + const oldPresence = await workspaceRepo.observeById(teamA.id); + assert.deepEqual(oldPresence.participants, []); + + const teamASnapshot = await workspaceRepo.load(testTokenHash('P')); + const teamBSnapshotBeforeAccess = await workspaceRepo.load(testTokenHash('Q')); + assert.equal(teamASnapshot.snapshot.pre.oneLine, 'Alex changed Team A only'); + assert.equal(teamBSnapshotBeforeAccess.snapshot.pre.oneLine, 'Team B original'); + + const studentStatus = response(); + await classStudentHandler({ getRepository: async () => classrooms })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, studentStatus); + assert.equal(studentStatus.statusCode, 200); + assert.equal(studentStatus.body.assignment.id, WORKSPACE_B_ID); + assert.equal(studentStatus.body.participant.assignmentRevision, 2); + + const teamBAccess = response(); + await classStudentAccessHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + tokenFactory: tokenFactory(['V']) + })({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, teamBAccess); + assert.equal(teamBAccess.statusCode, 200); + assert.equal(teamBAccess.body.workspaceToken, 'V'.repeat(43)); + + const teamBLoad = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'V'.repeat(43) } + }, teamBLoad); + assert.equal(teamBLoad.statusCode, 200); + assert.equal(teamBLoad.body.snapshot.pre.oneLine, 'Team B original'); + + const crossClass = response(); + await participantHandler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { participantId: PARTICIPANT_A, workspaceId: WORKSPACE_C_ID } + }, crossClass); + assert.equal(crossClass.statusCode, 404); + + const afterCrossClass = response(); + await classStudentHandler({ getRepository: async () => classrooms })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, afterCrossClass); + assert.equal(afterCrossClass.body.assignment.id, WORKSPACE_B_ID); + assert.equal(afterCrossClass.body.participant.assignmentRevision, 2); + + const unassigned = response(); + await participantHandler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { participantId: PARTICIPANT_A, workspaceId: null } + }, unassigned); + assert.equal(unassigned.statusCode, 200); + assert.equal(unassigned.body.changed, true); + assert.equal(unassigned.body.assignment, null); + assert.equal(unassigned.body.participant.assignmentRevision, 3); + + const oldTeamBLoad = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'V'.repeat(43) } + }, oldTeamBLoad); + assert.equal(oldTeamBLoad.statusCode, 404); + + const waitingAgain = response(); + await classStudentAccessHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + tokenFactory: tokenFactory(['U']) + })({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, waitingAgain); + assert.equal(waitingAgain.statusCode, 409); + assert.equal(waitingAgain.body.status, 'waiting'); + assert.equal('workspaceToken' in waitingAgain.body, false); +}); + +test('individual live workspace rejects a second Student without disturbing either assignment', async () => { + const classrooms = createClassroomRepository(); + const workspaceRepo = createWorkspaceRepository(); + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + const individual = await workspaceRepo.create(testTokenHash('P'), {}, 30, 'Alex workspace'); + await classrooms.addWorkspace(testTokenHash('A'), { + publicId: WORKSPACE_A_ID, + workspaceId: individual.id, + kind: 'individual', + label: 'Individual 1', + claimHash: testTokenHash('X') + }); + await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_A, + displayName: 'Alex', + sessionHash: testTokenHash('S') + }); + await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_B, + displayName: 'Blair', + sessionHash: testTokenHash('T') + }); + + const handler = classParticipantsHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo + }); + + const first = response(); + await handler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { participantId: PARTICIPANT_A, workspaceId: WORKSPACE_A_ID } + }, first); + assert.equal(first.statusCode, 200); + assert.equal(first.body.participant.assignmentRevision, 1); + + const denied = response(); + await handler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { participantId: PARTICIPANT_B, workspaceId: WORKSPACE_A_ID } + }, denied); + assert.equal(denied.statusCode, 409); + + const alex = await classrooms.getParticipantBySession(testTokenHash('S')); + const blair = await classrooms.getParticipantBySession(testTokenHash('T')); + assert.equal(alex.assignment.id, WORKSPACE_A_ID); + assert.equal(alex.participant.assignmentRevision, 1); + assert.equal(blair.assignment, null); + assert.equal(blair.participant.assignmentRevision, 0); +}); + test('class admin rejects missing or malformed authorization before repository access', async () => { let accessed = false; const handler = classHandler({ diff --git a/tests/classroomInstructor.feature.test.mjs b/tests/classroomInstructor.feature.test.mjs index c72473a..630f6d5 100644 --- a/tests/classroomInstructor.feature.test.mjs +++ b/tests/classroomInstructor.feature.test.mjs @@ -21,6 +21,8 @@ const INDEX_HTML = readFileSync(new URL('../index.html', import.meta.url), 'utf8 const TOKEN = 'i'.repeat(43); const W1 = '11111111-1111-4111-8111-111111111111'; const W2 = '22222222-2222-4222-8222-222222222222'; +const P1 = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const P2 = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; let dom = null; @@ -30,7 +32,12 @@ function response(status, body) { function rosterBody() { return { - class: { id: 'class-1', title: 'Problem Solving 101', expiresAt: '2099-01-01T00:00:00Z' }, + class: { + id: 'class-1', + title: 'Problem Solving 101', + joinCode: 'K7FMP4Q2', + expiresAt: '2099-01-01T00:00:00Z' + }, workspaces: [ { id: W1, kind: 'individual', label: 'Alex', participantCount: 1, activeParticipantCount: 1, editingParticipantCount: 0 }, { id: W2, kind: 'group', label: 'Team Beta', participantCount: 3, activeParticipantCount: 2, editingParticipantCount: 1 } @@ -38,6 +45,31 @@ function rosterBody() { }; } +function participantBody(overrides = {}) { + return { + class: { id: 'class-1', title: 'Problem Solving 101', expiresAt: '2099-01-01T00:00:00Z' }, + participants: [ + { + id: P1, + displayName: 'Alex', + assignmentRevision: 1, + joinedAt: '2026-10-01T00:00:00Z', + updatedAt: '2026-10-01T00:00:00Z', + assignment: { id: W1, kind: 'individual', label: 'Alex' } + }, + { + id: P2, + displayName: 'Blair', + assignmentRevision: 0, + joinedAt: '2026-10-01T00:01:00Z', + updatedAt: '2026-10-01T00:01:00Z', + assignment: null + } + ], + ...overrides + }; +} + function observeBody(id, statement) { const workspace = rosterBody().workspaces.find(item => item.id === id); return { @@ -122,6 +154,7 @@ test('Instructor opens one class, renders roster, and observes through the GET-o const env = setup(async (url, options) => { requests.push([url, options]); if (url === '/api/classes/workspaces') return response(200, rosterBody()); + if (url === '/api/classes/participants') return response(200, participantBody()); if (url.includes(W1)) return response(200, observeBody(W1, 'First')); return response(404, {}); }); @@ -134,6 +167,9 @@ test('Instructor opens one class, renders roster, and observes through the GET-o assert.equal(stored.selectedWorkspaceId, W1); assert.equal(dom.window.document.body.classList.contains('instructor-class-connected'), true); assert.equal(dom.window.document.querySelectorAll('.instructor-workspace-item').length, 2); + assert.equal(dom.window.document.querySelectorAll('.instructor-participant-row').length, 2); + assert.equal(dom.window.document.getElementById('instructorParticipantSummary').textContent, '2 students · 1 waiting'); + assert.equal(dom.window.document.getElementById('instructorJoinCode').textContent, 'K7FM-P4Q2'); assert.equal(dom.window.document.getElementById('oneLine').value, 'First'); assert.equal(dom.window.document.getElementById('oneLine').readOnly, true); assert.equal(dom.window.document.querySelector('.wrap').hasAttribute('aria-readonly'), false); @@ -144,11 +180,142 @@ test('Instructor opens one class, renders roster, and observes through the GET-o assert.equal(dom.window.document.getElementById('instructorObservedWorkspace').textContent, 'Alex'); }); +test('Instructor starts a live class, creates a team, and assigns a waiting Student without exposing assignment secrets', async () => { + const workspaces = []; + const participants = [{ + id: P2, + displayName: 'Blair', + assignmentRevision: 0, + joinedAt: '2026-10-01T00:01:00Z', + updatedAt: '2026-10-01T00:01:00Z', + assignment: null + }]; + const requests = []; + + const env = setup(async (url, options = {}) => { + requests.push([url, options]); + if (url === '/api/classes' && options.method === 'POST') { + return response(201, { + class: { + id: 'class-1', + title: 'Live PSDM Class', + expiresAt: '2099-01-01T00:00:00Z' + }, + instructorToken: TOKEN, + studentJoinToken: 'j'.repeat(43), + joinCode: 'K7FM-P4Q2' + }); + } + if (url === '/api/classes/workspaces' && options.method === 'GET') { + return response(200, { + class: { + id: 'class-1', + title: 'Live PSDM Class', + joinCode: 'K7FMP4Q2', + expiresAt: '2099-01-01T00:00:00Z' + }, + workspaces + }); + } + if (url === '/api/classes/participants' && options.method === 'GET') { + return response(200, { + class: { id: 'class-1', title: 'Live PSDM Class', expiresAt: '2099-01-01T00:00:00Z' }, + participants + }); + } + if (url === '/api/classes/workspaces' && options.method === 'POST') { + const body = JSON.parse(options.body); + const workspace = { + id: W1, + kind: body.kind, + label: body.label, + participantCount: 0, + activeParticipantCount: 0, + editingParticipantCount: 0 + }; + workspaces.push(workspace); + return response(201, { workspace, assignmentToken: 'x'.repeat(43) }); + } + if (url === '/api/classes/participants' && options.method === 'PATCH') { + const body = JSON.parse(options.body); + participants[0] = { + ...participants[0], + assignmentRevision: 1, + assignment: { id: W1, kind: 'group', label: 'Team Alpha' } + }; + return response(200, { + class: { id: 'class-1', title: 'Live PSDM Class', expiresAt: '2099-01-01T00:00:00Z' }, + participant: participants[0], + assignment: participants[0].assignment, + changed: true + }); + } + if (String(url).includes('/api/classes/observe')) { + return response(200, { + class: { id: 'class-1', title: 'Live PSDM Class', expiresAt: '2099-01-01T00:00:00Z' }, + workspace: { + id: W1, + kind: 'group', + label: 'Team Alpha', + revision: 1, + expiresAt: '2099-01-01T00:00:00Z' + }, + participants: [], + snapshot: {} + }); + } + return response(404, {}); + }); + + assert.equal(await env.controller.startClass('Live PSDM Class'), true); + assert.equal(dom.window.document.getElementById('instructorClassTitle').textContent, 'Live PSDM Class'); + assert.equal(dom.window.document.getElementById('instructorJoinCode').textContent, 'K7FM-P4Q2'); + assert.equal(dom.window.document.getElementById('instructorParticipantSummary').textContent, '1 student · 1 waiting'); + + const stored = JSON.parse(dom.window.localStorage.getItem(INSTRUCTOR_SESSION_STORAGE_KEY)); + assert.equal(stored.instructorToken, TOKEN); + assert.equal(stored.joinCode, 'K7FM-P4Q2'); + assert.equal(JSON.stringify(stored).includes('studentJoinToken'), false); + + assert.equal(await env.controller.createWorkspace('group', 'Team Alpha'), true); + assert.equal(dom.window.document.querySelectorAll('.instructor-workspace-item').length, 1); + assert.equal(dom.window.document.getElementById('instructorWorkspaceLabel').value, ''); + + assert.equal(await env.controller.assignParticipant(P2, W1), true); + const assignmentSelect = dom.window.document.querySelector('[data-participant-id="' + P2 + '"] select'); + assert.equal(assignmentSelect.value, W1); + assert.equal(dom.window.document.getElementById('instructorParticipantSummary').textContent, '1 student · 0 waiting'); + + const createRequest = requests.find(([url, options]) => ( + url === '/api/classes/workspaces' && options.method === 'POST' + )); + assert.deepEqual(JSON.parse(createRequest[1].body), { + kind: 'group', + label: 'Team Alpha', + snapshot: {} + }); + + const assignRequest = requests.find(([url, options]) => ( + url === '/api/classes/participants' && options.method === 'PATCH' + )); + assert.deepEqual(JSON.parse(assignRequest[1].body), { + participantId: P2, + workspaceId: W1 + }); + + assert.equal( + JSON.stringify(requests).includes('assignmentToken'), + false, + 'Instructor client never persists or reuses the legacy assignment secret' + ); +}); + test('rapid Instructor workspace switching aborts/stales the previous observer and keeps the newest snapshot', async () => { let resolveFirst; let firstSignal = null; const env = setup(async (url, options) => { if (url === '/api/classes/workspaces') return response(200, rosterBody()); + if (url === '/api/classes/participants') return response(200, participantBody()); if (url.includes(W1)) { firstSignal = options.signal; return new Promise(resolve => { resolveFirst = resolve; }); @@ -173,6 +340,7 @@ test('rapid Instructor workspace switching aborts/stales the previous observer a test('Instructor search and kind filter narrow the roster without changing authorization', async () => { const env = setup(async url => { if (url === '/api/classes/workspaces') return response(200, rosterBody()); + if (url === '/api/classes/participants') return response(200, participantBody()); if (url.includes(W1)) return response(200, observeBody(W1, 'First')); return response(404, {}); }); @@ -196,6 +364,7 @@ test('Instructor search and kind filter narrow the roster without changing autho test('switching away from Instructor restores local Intake and keeps same-device class resume', async () => { const env = setup(async url => { if (url === '/api/classes/workspaces') return response(200, rosterBody()); + if (url === '/api/classes/participants') return response(200, participantBody()); if (url.includes(W1)) return response(200, observeBody(W1, 'First')); return response(404, {}); }); @@ -213,6 +382,7 @@ test('switching away from Instructor restores local Intake and keeps same-device test('Leave class clears Instructor resume and restores local Intake', async () => { const env = setup(async url => { if (url === '/api/classes/workspaces') return response(200, rosterBody()); + if (url === '/api/classes/participants') return response(200, participantBody()); if (url.includes(W1)) return response(200, observeBody(W1, 'First')); return response(404, {}); }); @@ -242,6 +412,7 @@ test('Instructor observer lifecycle emits coaching integration hooks without gra let ended = 0; const env = setup(async url => { if (url === '/api/classes/workspaces') return response(200, rosterBody()); + if (url === '/api/classes/participants') return response(200, participantBody()); if (url.includes(W1)) return response(200, observeBody(W1, 'First')); return response(404, {}); }, { @@ -268,6 +439,7 @@ test('Instructor class lifecycle exposes protected-resource capability hooks', a let disconnected = 0; const env = setup(async url => { if (url === '/api/classes/workspaces') return response(200, rosterBody()); + if (url === '/api/classes/participants') return response(200, participantBody()); if (url.includes(W1)) return response(200, observeBody(W1, 'First')); return response(404, {}); }, { diff --git a/tests/classroomInstructor.unit.test.mjs b/tests/classroomInstructor.unit.test.mjs index aa2922a..164e0e1 100644 --- a/tests/classroomInstructor.unit.test.mjs +++ b/tests/classroomInstructor.unit.test.mjs @@ -9,7 +9,9 @@ import { INSTRUCTOR_SESSION_STORAGE_KEY, INSTRUCTOR_SESSION_VERSION, clearInstructorSession, + formatInstructorJoinCode, isInstructorSessionExpired, + normalizeInstructorJoinCode, persistInstructorSession, readInstructorSession, validateInstructorCapability @@ -22,6 +24,7 @@ function session(overrides = {}) { return { version: INSTRUCTOR_SESSION_VERSION, instructorToken: TOKEN, + joinCode: 'K7FM-P4Q2', class: { id: 'class-1', title: 'Problem Solving 101', expiresAt: '2099-01-01T00:00:00Z' }, selectedWorkspaceId: WORKSPACE_ID, openedAt: '2026-10-01T00:00:00Z', @@ -35,6 +38,13 @@ test('Instructor capability validation accepts only the expected opaque token sh assert.equal(validateInstructorCapability(null), false); }); +test('Instructor join-code helpers accept only the unambiguous human code contract', () => { + assert.equal(normalizeInstructorJoinCode(' k7fm-p4q2 '), 'K7FMP4Q2'); + assert.equal(formatInstructorJoinCode('k7fmp4q2'), 'K7FM-P4Q2'); + assert.equal(normalizeInstructorJoinCode('K7F0-P4Q2'), ''); + assert.equal(normalizeInstructorJoinCode('short'), ''); +}); + test('Instructor same-device resume envelope round-trips outside Intake state', () => { const dom = new JSDOM('', { url: 'https://intake.test/' }); assert.equal(persistInstructorSession(dom.window.localStorage, session()), true); @@ -49,6 +59,8 @@ test('Instructor resume rejects malformed sessions and detects server expiry', ( const dom = new JSDOM('', { url: 'https://intake.test/' }); dom.window.localStorage.setItem(INSTRUCTOR_SESSION_STORAGE_KEY, JSON.stringify(session({ instructorToken: 'bad' }))); assert.equal(readInstructorSession(dom.window.localStorage), null); + dom.window.localStorage.setItem(INSTRUCTOR_SESSION_STORAGE_KEY, JSON.stringify(session({ joinCode: 'BAD-CODE' }))); + assert.equal(readInstructorSession(dom.window.localStorage), null); assert.equal(isInstructorSessionExpired(session(), Date.parse('2026-10-01T00:00:00Z')), false); assert.equal(isInstructorSessionExpired(session({ diff --git a/tests/classroomStudent.feature.test.mjs b/tests/classroomStudent.feature.test.mjs index cfcdf36..7360529 100644 --- a/tests/classroomStudent.feature.test.mjs +++ b/tests/classroomStudent.feature.test.mjs @@ -24,7 +24,11 @@ const INDEX_HTML = readFileSync(new URL('../index.html', import.meta.url), 'utf8 const JOIN_TOKEN = 'c'.repeat(43); const ASSIGNMENT_TOKEN = 'x'.repeat(43); const WORKSPACE_TOKEN = 's'.repeat(43); +const WORKSPACE_TOKEN_B = 't'.repeat(43); +const LIVE_SESSION_TOKEN = 'l'.repeat(43); const PARTICIPANT_ID = '11111111-1111-4111-8111-111111111111'; +const WORKSPACE_A = '22222222-2222-4222-8222-222222222222'; +const WORKSPACE_B = '33333333-3333-4333-8333-333333333333'; let dom = null; @@ -50,7 +54,8 @@ function mount({ terminal = false, fetchImpl, onClassConnected = () => {}, - onClassDisconnected = () => {} + onClassDisconnected = () => {}, + fakeTimers = false } = {}) { dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); persistExperienceRolePreference(EXPERIENCE_ROLE_IDS.STUDENT, dom.window.localStorage); @@ -64,7 +69,7 @@ function mount({ location: dom.window.location }); - const calls = { connect: [], leave: [], apply: [], save: [], fetch: [] }; + const calls = { connect: [], leave: [], apply: [], save: [], fetch: [], timers: [] }; const collaborationState = { profile: { participantId: PARTICIPANT_ID, displayName: '' }, sessionKind: 'local', @@ -106,7 +111,14 @@ function mount({ now: () => Date.parse('2026-10-01T00:00:00Z'), toast: () => {}, onClassConnected, - onClassDisconnected + onClassDisconnected, + setTimeoutImpl: fakeTimers + ? fn => { + calls.timers.push(fn); + return calls.timers.length; + } + : undefined, + clearTimeoutImpl: fakeTimers ? () => {} : undefined }); controller.init(); return { controller, calls, collaborationState }; @@ -122,6 +134,153 @@ afterEach(() => { dom = null; }); +test('Student one-code admission persists only the class session and waits without collaboration access', async () => { + const requests = []; + const env = mount({ + fakeTimers: true, + fetchImpl: async (url, options = {}) => { + requests.push([url, options]); + if (url === '/api/classes/admit') { + const sent = JSON.parse(options.body); + assert.deepEqual(sent, { + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_ID, + displayName: 'Alex' + }); + return response(200, { + class: { id: 'class-1', title: 'Problem Solving 101', expiresAt: '2099-01-01T00:00:00Z' }, + participant: { + id: PARTICIPANT_ID, + displayName: 'Alex', + assignmentRevision: 0 + }, + assignment: null, + studentSessionToken: LIVE_SESSION_TOKEN + }); + } + if (url === '/api/classes/student') { + assert.equal(options.headers.Authorization, `Bearer ${LIVE_SESSION_TOKEN}`); + return response(200, { + class: { id: 'class-1', title: 'Problem Solving 101', expiresAt: '2099-01-01T00:00:00Z' }, + participant: { + id: PARTICIPANT_ID, + displayName: 'Alex', + assignmentRevision: 0 + }, + assignment: null + }); + } + return response(500, {}); + } + }); + await settle(); + + assert.equal(await env.controller.join({ + classCode: 'K7FM-P4Q2', + displayName: 'Alex' + }), true); + + const stored = JSON.parse(dom.window.localStorage.getItem(STUDENT_SESSION_STORAGE_KEY)); + assert.equal(stored.mode, 'live'); + assert.equal(stored.studentSessionToken, LIVE_SESSION_TOKEN); + assert.equal(stored.assignmentRevision, 0); + assert.equal(stored.assignment, null); + assert.equal('workspaceToken' in stored, false); + assert.equal(JSON.stringify(stored).includes('K7FM-P4Q2'), false); + assert.equal(env.calls.connect.length, 0); + assert.equal(dom.window.document.body.dataset.studentClassStatus, 'waiting'); + assert.equal(dom.window.document.getElementById('studentClassWaitingPanel').hidden, false); + assert.equal(dom.window.document.getElementById('studentClassWaitingClass').textContent, 'Problem Solving 101'); + assert.equal(dom.window.document.getElementById('studentClassWaitingIdentity').textContent, 'Alex'); + assert.equal(dom.window.document.querySelector('.wrap').hidden, true); + assert.equal(requests.some(([url]) => url === '/api/classes/student/access'), false); + assert.ok(env.calls.timers.length >= 1, 'waiting state schedules own-status polling'); +}); + +test('live Student assignment, reassignment, and unassign rotate workspace authority without merging local state', async () => { + let assignment = { + id: WORKSPACE_A, + kind: 'group', + label: 'Team Alpha' + }; + let revision = 1; + const accessTokens = [WORKSPACE_TOKEN, WORKSPACE_TOKEN_B]; + const connected = []; + let disconnected = 0; + + const env = mount({ + fakeTimers: true, + onClassConnected: token => connected.push(token), + onClassDisconnected: () => { disconnected += 1; }, + fetchImpl: async (url, options = {}) => { + if (url === '/api/classes/admit') { + return response(200, { + class: { id: 'class-1', title: 'Problem Solving 101', expiresAt: '2099-01-01T00:00:00Z' }, + participant: { id: PARTICIPANT_ID, displayName: 'Alex', assignmentRevision: revision }, + assignment, + studentSessionToken: LIVE_SESSION_TOKEN + }); + } + if (url === '/api/classes/student') { + return response(200, { + class: { id: 'class-1', title: 'Problem Solving 101', expiresAt: '2099-01-01T00:00:00Z' }, + participant: { id: PARTICIPANT_ID, displayName: 'Alex', assignmentRevision: revision }, + assignment + }); + } + if (url === '/api/classes/student/access') { + if (!assignment) { + return response(409, { + status: 'waiting', + class: { id: 'class-1', title: 'Problem Solving 101', expiresAt: '2099-01-01T00:00:00Z' }, + participant: { id: PARTICIPANT_ID, displayName: 'Alex', assignmentRevision: revision }, + assignment: null + }); + } + return response(200, { + class: { id: 'class-1', title: 'Problem Solving 101', expiresAt: '2099-01-01T00:00:00Z' }, + participant: { id: PARTICIPANT_ID, displayName: 'Alex', assignmentRevision: revision }, + assignment, + workspaceToken: accessTokens.shift() + }); + } + return response(500, {}); + } + }); + await settle(); + + assert.equal(await env.controller.join({ classCode: 'K7FM-P4Q2', displayName: 'Alex' }), true); + assert.deepEqual(env.calls.connect.at(-1), [WORKSPACE_TOKEN, { displayName: 'Alex', classroom: true }]); + assert.equal(dom.window.document.body.dataset.studentClassStatus, 'connected'); + assert.equal(dom.window.document.getElementById('studentClassWorkspace').textContent, 'Team Alpha'); + + assignment = { id: WORKSPACE_B, kind: 'group', label: 'Team Beta' }; + revision = 2; + assert.equal(await env.controller.refreshStatus(), true); + assert.deepEqual(env.calls.leave.at(-1), { silent: true }); + assert.deepEqual(env.calls.connect.at(-1), [WORKSPACE_TOKEN_B, { displayName: 'Alex', classroom: true }]); + assert.equal(dom.window.document.getElementById('studentClassWorkspace').textContent, 'Team Beta'); + assert.deepEqual(connected, [WORKSPACE_TOKEN, WORKSPACE_TOKEN_B]); + assert.ok(disconnected >= 1, 'old classroom integration is disconnected before destination access'); + + const storedAfterMove = JSON.parse(dom.window.localStorage.getItem(STUDENT_SESSION_STORAGE_KEY)); + assert.equal(storedAfterMove.assignment.id, WORKSPACE_B); + assert.equal(storedAfterMove.assignmentRevision, 2); + assert.equal('workspaceToken' in storedAfterMove, false); + assert.equal(JSON.stringify(storedAfterMove).includes(WORKSPACE_TOKEN), false); + assert.equal(JSON.stringify(storedAfterMove).includes(WORKSPACE_TOKEN_B), false); + + assignment = null; + revision = 3; + assert.equal(await env.controller.refreshStatus(), true); + assert.equal(dom.window.document.body.dataset.studentClassStatus, 'waiting'); + assert.equal(env.controller.getState().activeWorkspaceToken, ''); + assert.equal(dom.window.document.querySelector('.wrap').hidden, true); + const storedWaiting = JSON.parse(dom.window.localStorage.getItem(STUDENT_SESSION_STORAGE_KEY)); + assert.equal(storedWaiting.assignment, null); + assert.equal(storedWaiting.assignmentRevision, 3); +}); + test('Student join uses one-time class and assignment codes then attaches the issued workspace capability', async () => { const env = mount({ fetchImpl: async (_url, options) => { diff --git a/tests/classroomStudent.unit.test.mjs b/tests/classroomStudent.unit.test.mjs index 3fcb5c3..07b5a5d 100644 --- a/tests/classroomStudent.unit.test.mjs +++ b/tests/classroomStudent.unit.test.mjs @@ -11,6 +11,7 @@ import { clearStudentSession, isStudentSessionExpired, normalizeStudentDisplayName, + normalizeStudentJoinCode, persistStudentSession, readStudentSession, validateStudentCapability @@ -30,6 +31,20 @@ function validSession(overrides = {}) { }; } +function validLiveSession(overrides = {}) { + return { + version: STUDENT_SESSION_VERSION, + mode: 'live', + class: { id: 'class-1', title: 'Problem Solving 101', expiresAt: '2099-01-01T00:00:00Z' }, + participant: { id: '11111111-1111-4111-8111-111111111111', displayName: 'Alex' }, + studentSessionToken: TOKEN, + assignmentRevision: 0, + assignment: null, + joinedAt: '2026-10-01T00:00:00Z', + ...overrides + }; +} + test('Student capability and display-name validation reject malformed values', () => { assert.equal(validateStudentCapability(TOKEN), true); assert.equal(validateStudentCapability('short'), false); @@ -38,6 +53,29 @@ test('Student capability and display-name validation reject malformed values', ( assert.equal(normalizeStudentDisplayName('x'.repeat(61)), null); }); +test('Student human join codes normalize to the live eight-character contract', () => { + assert.equal(normalizeStudentJoinCode(' k7fm-p4q2 '), 'K7FMP4Q2'); + assert.equal(normalizeStudentJoinCode('K7F0-P4Q2'), ''); + assert.equal(normalizeStudentJoinCode('short'), ''); +}); + +test('live Student resume retains the class session but never persists assignment-specific workspace authority', () => { + const dom = new JSDOM('', { url: 'https://intake.test/' }); + const session = validLiveSession({ + assignmentRevision: 2, + assignment: { id: 'workspace-2', kind: 'group', label: 'Team Beta' } + }); + + assert.equal(persistStudentSession(dom.window.localStorage, session), true); + const restored = readStudentSession(dom.window.localStorage); + + assert.deepEqual(restored, session); + assert.equal(restored.studentSessionToken, TOKEN); + assert.equal('workspaceToken' in restored, false); + assert.equal(dom.window.localStorage.getItem(STUDENT_SESSION_STORAGE_KEY).includes('workspaceToken'), false); + dom.window.close(); +}); + test('Student resume envelope round-trips only the issued workspace capability and context', () => { const dom = new JSDOM('', { url: 'https://intake.test/' }); const session = validSession(); @@ -59,6 +97,10 @@ test('Student resume envelope rejects invalid capabilities and detects expiry', const dom = new JSDOM('', { url: 'https://intake.test/' }); dom.window.localStorage.setItem(STUDENT_SESSION_STORAGE_KEY, JSON.stringify(validSession({ workspaceToken: 'bad' }))); assert.equal(readStudentSession(dom.window.localStorage), null); + dom.window.localStorage.setItem(STUDENT_SESSION_STORAGE_KEY, JSON.stringify(validLiveSession({ studentSessionToken: 'bad' }))); + assert.equal(readStudentSession(dom.window.localStorage), null); + dom.window.localStorage.setItem(STUDENT_SESSION_STORAGE_KEY, JSON.stringify(validLiveSession({ workspaceToken: TOKEN }))); + assert.equal(readStudentSession(dom.window.localStorage), null); assert.equal(isStudentSessionExpired(validSession(), Date.parse('2026-10-01T00:00:00Z')), false); assert.equal(isStudentSessionExpired(validSession({ diff --git a/tests/e2e/classroom-authorization.spec.mjs b/tests/e2e/classroom-authorization.spec.mjs index 3e7b4cd..b9c8f60 100644 --- a/tests/e2e/classroom-authorization.spec.mjs +++ b/tests/e2e/classroom-authorization.spec.mjs @@ -32,6 +32,7 @@ test('Student workspace authority cannot enumerate the Instructor roster', async await page.getByRole('button', { name: /Join a class/ }).click(); await page.locator('#studentDisplayName').fill('Authorization Student'); await page.getByLabel('Class code').fill(classCode); + await page.locator('#studentLegacyJoin > summary').click(); await page.getByLabel('Assignment code').fill(assignmentCode); await page.getByRole('button', { name: 'Join class' }).click(); @@ -54,6 +55,7 @@ test('well-formed non-Instructor authority cannot open an Instructor class', asy await startFresh(page); await page.getByRole('button', { name: /Teach a class/ }).click(); + await page.locator('#instructorExistingClass > summary').click(); await page.getByLabel('Instructor access code').fill(wrongAuthority); await page.getByRole('button', { name: 'Open class' }).click(); diff --git a/tests/e2e/classroom-collaboration.spec.mjs b/tests/e2e/classroom-collaboration.spec.mjs index 20e9ca5..d4f954e 100644 --- a/tests/e2e/classroom-collaboration.spec.mjs +++ b/tests/e2e/classroom-collaboration.spec.mjs @@ -22,6 +22,7 @@ async function joinStudent(page, { name, classCode, assignmentCode }) { await page.getByRole('button', { name: /Join a class/ }).click(); await page.locator('#studentDisplayName').fill(name); await page.getByLabel('Class code').fill(classCode); + await page.locator('#studentLegacyJoin > summary').click(); await page.getByLabel('Assignment code').fill(assignmentCode); await page.getByRole('button', { name: 'Join class' }).click(); await expect(page.locator('body')).toHaveAttribute('data-student-class-status', 'connected'); diff --git a/tests/e2e/classroom-live-integration.spec.mjs b/tests/e2e/classroom-live-integration.spec.mjs new file mode 100644 index 0000000..34896c9 --- /dev/null +++ b/tests/e2e/classroom-live-integration.spec.mjs @@ -0,0 +1,202 @@ +/** + * Integrated real-browser acceptance for one Instructor and multiple live Students. + */ + +import AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; + +function watchPageErrors(page) { + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + return errors; +} + +async function startFresh(page) { + await page.goto('/'); + await page.evaluate(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + }); + await page.reload(); +} + +async function expectNoBlockingA11yViolations(page) { + const accessibility = await new AxeBuilder({ page }).analyze(); + const blockingViolations = accessibility.violations.filter( + violation => violation.impact === 'serious' || violation.impact === 'critical' + ); + expect(blockingViolations, JSON.stringify(blockingViolations, null, 2)).toEqual([]); +} + +async function joinLiveStudent(page, { name, classCode }) { + await startFresh(page); + await page.getByRole('button', { name: /Join a class/ }).click(); + await page.locator('#studentDisplayName').fill(name); + await page.getByLabel('Class code').fill(classCode); + await page.getByRole('button', { name: 'Join class' }).click(); + await expect(page.locator('body')).toHaveAttribute('data-student-class-status', 'waiting'); + await expect(page.locator('#studentClassWaitingIdentity')).toHaveText(name); +} + +async function createTeam(page, label) { + await page.locator('#instructorWorkspaceLabel').fill(label); + await page.getByLabel('Workspace type').selectOption('group'); + await page.getByRole('button', { name: 'Create workspace' }).click(); + await expect(page.locator('.instructor-workspace-item').filter({ hasText: label })).toBeVisible(); +} + +test('live class integrates Instructor roster, team sync, isolation, coaching, reassignment, and unassign', async ({ browser }, testInfo) => { + test.skip(testInfo.project.name === 'chromium-mobile', 'Integrated four-browser classroom acceptance is covered once on desktop; mobile Student and Instructor paths have dedicated coverage.'); + + const contextOptions = { baseURL: testInfo.project.use.baseURL }; + const instructorContext = await browser.newContext(contextOptions); + const studentAContext = await browser.newContext(contextOptions); + const studentBContext = await browser.newContext(contextOptions); + const lateStudentContext = await browser.newContext(contextOptions); + + const instructor = await instructorContext.newPage(); + const studentA = await studentAContext.newPage(); + const studentB = await studentBContext.newPage(); + const lateStudent = await lateStudentContext.newPage(); + + const instructorErrors = watchPageErrors(instructor); + const studentAErrors = watchPageErrors(studentA); + const studentBErrors = watchPageErrors(studentB); + const lateStudentErrors = watchPageErrors(lateStudent); + const studentATokens = []; + + studentA.on('request', request => { + const url = new URL(request.url()); + if (url.pathname !== '/api/workspaces/session') return; + const header = request.headers().authorization || ''; + const match = /^Bearer\s+(.+)$/u.exec(header); + if (match && !studentATokens.includes(match[1])) studentATokens.push(match[1]); + }); + + try { + await startFresh(instructor); + await instructor.getByRole('button', { name: /Teach a class/ }).click(); + await instructor.getByLabel('Class title').fill('Integrated Browser Classroom'); + await instructor.getByRole('button', { name: 'Start class' }).click(); + + await expect(instructor.locator('#instructorClassDashboard')).toBeVisible(); + const classCode = (await instructor.locator('#instructorJoinCode').textContent())?.trim() || ''; + expect(classCode).toMatch(/^[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$/u); + + await createTeam(instructor, 'Team Alpha'); + await createTeam(instructor, 'Team Beta'); + + await joinLiveStudent(studentA, { name: 'Student Alpha One', classCode }); + await joinLiveStudent(studentB, { name: 'Student Alpha Two', classCode }); + + await expect(instructor.locator('[data-participant-id]').filter({ hasText: 'Student Alpha One' })).toBeVisible({ timeout: 10000 }); + await expect(instructor.locator('[data-participant-id]').filter({ hasText: 'Student Alpha Two' })).toBeVisible({ timeout: 10000 }); + + await instructor.getByLabel('Assignment for Student Alpha One').selectOption({ label: 'Team · Team Alpha' }); + await instructor.getByLabel('Assignment for Student Alpha Two').selectOption({ label: 'Team · Team Alpha' }); + + await expect(studentA.locator('body')).toHaveAttribute('data-student-class-status', 'connected', { timeout: 10000 }); + await expect(studentB.locator('body')).toHaveAttribute('data-student-class-status', 'connected', { timeout: 10000 }); + await expect(studentA.locator('#studentClassWorkspace')).toHaveText('Team Alpha'); + await expect(studentB.locator('#studentClassWorkspace')).toHaveText('Team Alpha'); + await expect(studentA.locator('#oneLine')).toHaveValue('Team Alpha live-class Intake.'); + await expect(studentB.locator('#oneLine')).toHaveValue('Team Alpha live-class Intake.'); + + const alphaUpdate = 'Team Alpha converged on one shared Intake.'; + const alphaSave = studentA.waitForResponse(response => ( + response.request().method() === 'PUT' + && new URL(response.url()).pathname === '/api/workspaces/session' + && response.ok() + )); + await studentA.locator('#oneLine').fill(alphaUpdate); + await studentA.locator('#oneLine').blur(); + await alphaSave; + await expect(studentB.locator('#oneLine')).toHaveValue(alphaUpdate, { timeout: 10000 }); + + await joinLiveStudent(lateStudent, { name: 'Late Student', classCode }); + await expect(instructor.locator('[data-participant-id]').filter({ hasText: 'Late Student' })).toContainText('Waiting / unassigned', { timeout: 10000 }); + await instructor.getByLabel('Assignment for Late Student').selectOption({ label: 'Team · Team Beta' }); + + await expect(lateStudent.locator('body')).toHaveAttribute('data-student-class-status', 'connected', { timeout: 10000 }); + await expect(lateStudent.locator('#studentClassWorkspace')).toHaveText('Team Beta'); + await expect(lateStudent.locator('#oneLine')).toHaveValue('Team Beta live-class Intake.'); + + const betaUpdate = 'Team Beta stayed isolated with its own Intake.'; + const betaSave = lateStudent.waitForResponse(response => ( + response.request().method() === 'PUT' + && new URL(response.url()).pathname === '/api/workspaces/session' + && response.ok() + )); + await lateStudent.locator('#oneLine').fill(betaUpdate); + await lateStudent.locator('#oneLine').blur(); + await betaSave; + + await expect(studentA.locator('#oneLine')).toHaveValue(alphaUpdate); + await expect(studentB.locator('#oneLine')).toHaveValue(alphaUpdate); + + await instructor.locator('.instructor-workspace-item').filter({ hasText: 'Team Alpha' }).click(); + await expect(instructor.locator('#instructorObservedWorkspace')).toHaveText('Team Alpha'); + await expect(instructor.locator('#oneLine')).toHaveValue(alphaUpdate, { timeout: 10000 }); + await expect(instructor.locator('#oneLine')).toHaveJSProperty('readOnly', true); + + const coaching = instructor.locator('.classroom-coaching--instructor[data-coaching-target-id="problem.one-line"]'); + await expect(coaching).toBeVisible(); + await coaching.locator('summary').click(); + await coaching.locator('textarea').fill('Make the deviation measurable before the debrief.'); + const feedbackSaved = instructor.waitForResponse(response => ( + response.request().method() === 'PUT' + && new URL(response.url()).pathname === '/api/classes/coaching' + && response.ok() + )); + await coaching.getByRole('button', { name: 'Needs improvement' }).click(); + await feedbackSaved; + + const studentFeedback = studentB.locator('.classroom-coaching--student[data-coaching-target-id="problem.one-line"]'); + await expect(studentFeedback).toContainText('Instructor feedback · Needs improvement', { timeout: 10000 }); + await expect(studentFeedback).toContainText('Make the deviation measurable before the debrief.'); + + expect(studentATokens.length).toBeGreaterThanOrEqual(1); + const staleAlphaToken = studentATokens.at(-1); + + await instructor.getByLabel('Assignment for Student Alpha One').selectOption({ label: 'Team · Team Beta' }); + + await expect(studentA.locator('#studentClassWorkspace')).toHaveText('Team Beta', { timeout: 10000 }); + await expect(studentA.locator('#oneLine')).toHaveValue(betaUpdate, { timeout: 10000 }); + await expect(studentB.locator('#studentClassWorkspace')).toHaveText('Team Alpha'); + await expect(studentB.locator('#oneLine')).toHaveValue(alphaUpdate); + expect(studentATokens.length).toBeGreaterThanOrEqual(2); + + const staleAlphaResponse = await studentA.request.get('/api/workspaces/session', { + headers: { Authorization: `Bearer ${staleAlphaToken}` } + }); + expect(staleAlphaResponse.status()).toBe(404); + + const betaToken = studentATokens.at(-1); + await instructor.getByLabel('Assignment for Student Alpha One').selectOption(''); + + await expect(studentA.locator('body')).toHaveAttribute('data-student-class-status', 'waiting', { timeout: 10000 }); + await expect(studentA.locator('#studentClassWaitingPanel')).toBeVisible(); + await expect(studentA.locator('.wrap')).toBeHidden(); + + const staleBetaResponse = await studentA.request.get('/api/workspaces/session', { + headers: { Authorization: `Bearer ${betaToken}` } + }); + expect(staleBetaResponse.status()).toBe(404); + + await expect(lateStudent.locator('#oneLine')).toHaveValue(betaUpdate); + await expect(studentB.locator('#oneLine')).toHaveValue(alphaUpdate); + + await expectNoBlockingA11yViolations(instructor); + await expectNoBlockingA11yViolations(studentA); + + expect(instructorErrors).toEqual([]); + expect(studentAErrors).toEqual([]); + expect(studentBErrors).toEqual([]); + expect(lateStudentErrors).toEqual([]); + } finally { + await instructorContext.close(); + await studentAContext.close(); + await studentBContext.close(); + await lateStudentContext.close(); + } +}); diff --git a/tests/e2e/coaching.spec.mjs b/tests/e2e/coaching.spec.mjs index f92a6dd..cfef1d0 100644 --- a/tests/e2e/coaching.spec.mjs +++ b/tests/e2e/coaching.spec.mjs @@ -51,6 +51,7 @@ test('Instructor feedback reaches only the assigned Student and becomes stale af await startFresh(page); await page.getByRole('button', { name: /Teach a class/ }).click(); + await page.locator('#instructorExistingClass > summary').click(); await page.getByLabel('Instructor access code').fill(instructorCode); await page.getByRole('button', { name: 'Open class' }).click(); @@ -87,6 +88,7 @@ test('Instructor feedback reaches only the assigned Student and becomes stale af await page.getByRole('button', { name: /Join a class/ }).click(); await page.locator('#studentDisplayName').fill(studentName); await page.getByLabel('Class code').fill(classCode); + await page.locator('#studentLegacyJoin > summary').click(); await page.getByLabel('Assignment code').fill(assignmentCode); await page.getByRole('button', { name: 'Join class' }).click(); diff --git a/tests/e2e/instructor.spec.mjs b/tests/e2e/instructor.spec.mjs index dda6c6a..0245209 100644 --- a/tests/e2e/instructor.spec.mjs +++ b/tests/e2e/instructor.spec.mjs @@ -36,6 +36,78 @@ async function expectNoBlockingA11yViolations(page) { expect(blockingViolations, JSON.stringify(blockingViolations, null, 2)).toEqual([]); } +test('Instructor starts a live class, creates a team, assigns a waiting Student, and resumes it', async ({ page }, testInfo) => { + test.skip(testInfo.project.name === 'chromium-mobile', 'Desktop live-management journey; responsive Instructor observer remains covered on mobile.'); + const pageErrors = watchPageErrors(page); + const classroomRequests = []; + + page.on('request', request => { + const url = new URL(request.url()); + if (url.pathname.startsWith('/api/classes')) { + classroomRequests.push({ + method: request.method(), + pathname: url.pathname, + body: request.postData() || '' + }); + } + }); + + await startFresh(page); + await page.getByRole('button', { name: /Teach a class/ }).click(); + + await expect(page.locator('#instructorClassEntryCard')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Run a class' })).toBeVisible(); + + await page.getByLabel('Class title').fill('Browser Live PSDM'); + await page.getByRole('button', { name: 'Start class' }).click(); + + await expect(page.locator('#instructorClassDashboard')).toBeVisible(); + await expect(page.locator('#instructorClassTitle')).toHaveText('Browser Live PSDM'); + await expect(page.locator('#instructorJoinCode')).toHaveText('K7FM-P4Q2'); + await expect(page.locator('#instructorParticipantSummary')).toHaveText('1 student · 1 waiting'); + await expect(page.locator('[data-participant-id="99999999-9999-4999-8999-999999999999"]')).toContainText('Waiting Student'); + await expect(page.getByLabel('Assignment for Waiting Student')).toHaveValue(''); + + const storedAfterStart = await page.evaluate(key => window.localStorage.getItem(key), INSTRUCTOR_SESSION_STORAGE_KEY); + expect(storedAfterStart).toContain('K7FM-P4Q2'); + expect(storedAfterStart).not.toContain('studentJoinToken'); + + await page.getByPlaceholder('Team Alpha').fill('Team Alpha'); + await page.getByLabel('Workspace type').selectOption('group'); + await page.getByRole('button', { name: 'Create workspace' }).click(); + + await expect(page.locator('#instructorRosterSummary')).toHaveText('1 workspace'); + await expect(page.locator('.instructor-workspace-item')).toContainText('Team Alpha'); + await expect(page.locator('#instructorObservedWorkspace')).toHaveText('Team Alpha'); + await expect(page.locator('#oneLine')).toHaveValue('Team Alpha live-class Intake.'); + await expect(page.locator('#oneLine')).toHaveJSProperty('readOnly', true); + + const assignment = page.getByLabel('Assignment for Waiting Student'); + await assignment.selectOption({ label: 'Team · Team Alpha' }); + await expect(page.locator('#instructorParticipantSummary')).toHaveText('1 student · 0 waiting'); + await expect(assignment).not.toHaveValue(''); + await expect(page.locator('[data-participant-id="99999999-9999-4999-8999-999999999999"]')).toContainText('Team · Team Alpha'); + + expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes')).toBe(true); + expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes/workspaces')).toBe(true); + expect(classroomRequests.some(request => request.method === 'PATCH' && request.pathname === '/api/classes/participants')).toBe(true); + expect(classroomRequests.some(request => request.pathname === '/api/classes/join')).toBe(false); + expect(classroomRequests.some(request => request.body.includes('assignmentToken'))).toBe(false); + + await page.reload(); + + await expect(page.locator('#experienceRoleGate')).toBeHidden(); + await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'instructor'); + await expect(page.locator('#instructorClassDashboard')).toBeVisible(); + await expect(page.locator('#instructorClassTitle')).toHaveText('Browser Live PSDM'); + await expect(page.locator('#instructorJoinCode')).toHaveText('K7FM-P4Q2'); + await expect(page.locator('#instructorParticipantSummary')).toHaveText('1 student · 0 waiting'); + await expect(page.getByLabel('Assignment for Waiting Student')).not.toHaveValue(''); + + await expectNoBlockingA11yViolations(page); + expect(pageErrors).toEqual([]); +}); + test('Instructor opens a class, observes work read-only, switches workspaces, and resumes after reload', async ({ page }, testInfo) => { const pageErrors = watchPageErrors(page); const suffix = testInfo.project.name === 'chromium-mobile' ? 'm' : 'd'; @@ -55,6 +127,7 @@ test('Instructor opens a class, observes work read-only, switches workspaces, an await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'instructor'); await expect(page.locator('#instructorClassEntryCard')).toBeVisible(); + await page.locator('#instructorExistingClass > summary').click(); await page.getByLabel('Instructor access code').fill(instructorCode); await page.getByRole('button', { name: 'Open class' }).click(); diff --git a/tests/e2e/protected-case-studies.spec.mjs b/tests/e2e/protected-case-studies.spec.mjs index 4fc52b0..51e0271 100644 --- a/tests/e2e/protected-case-studies.spec.mjs +++ b/tests/e2e/protected-case-studies.spec.mjs @@ -52,6 +52,7 @@ test('protected Case Studies reject unauthenticated retrieval and an authorized await page.getByRole('button', { name: /Join a class/ }).click(); await page.locator('#studentDisplayName').fill('Protected Case Student'); await page.getByLabel('Class code').fill(classCode); + await page.locator('#studentLegacyJoin > summary').click(); await page.getByLabel('Assignment code').fill(assignmentCode); const catalogResponsePromise = page.waitForResponse(response => ( diff --git a/tests/e2e/student.spec.mjs b/tests/e2e/student.spec.mjs index bb0d5e4..c35cd12 100644 --- a/tests/e2e/student.spec.mjs +++ b/tests/e2e/student.spec.mjs @@ -38,6 +38,98 @@ async function expectNoBlockingA11yViolations(page) { expect(blockingViolations, JSON.stringify(blockingViolations, null, 2)).toEqual([]); } +test('Student joins with one code, waits, resumes Team Alpha, moves to Team Beta, and returns to Waiting', async ({ page }, testInfo) => { + const pageErrors = watchPageErrors(page); + const displayName = testInfo.project.name === 'chromium-mobile' ? 'Mobile Live Student' : 'Desktop Live Student'; + const classCode = 'M7QR-T4P2'; + const workspaceTokens = []; + + page.on('request', request => { + const url = new URL(request.url()); + if (url.pathname !== '/api/workspaces/session') return; + const header = request.headers().authorization || ''; + const match = /^Bearer\s+(.+)$/u.exec(header); + if (match && !workspaceTokens.includes(match[1])) workspaceTokens.push(match[1]); + }); + + await startFresh(page); + await page.getByRole('button', { name: /Join a class/ }).click(); + + await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'student'); + await expect(page.getByRole('heading', { name: 'Join your class' })).toBeVisible(); + await expect(page.locator('#studentLegacyJoin')).not.toHaveAttribute('open', ''); + + await page.locator('#studentDisplayName').fill(displayName); + await page.getByLabel('Class code').fill(classCode); + await page.getByRole('button', { name: 'Join class' }).click(); + + await expect(page.locator('body')).toHaveAttribute('data-student-class-status', 'waiting'); + await expect(page.locator('#studentClassWaitingPanel')).toBeVisible(); + await expect(page.locator('#studentClassWaitingClass')).toHaveText('Browser Student Live Classroom'); + await expect(page.locator('#studentClassWaitingIdentity')).toHaveText(displayName); + await expect(page.locator('.wrap')).toBeHidden(); + + const storedWaiting = await page.evaluate(key => JSON.parse(window.localStorage.getItem(key)), STUDENT_SESSION_STORAGE_KEY); + expect(storedWaiting.mode).toBe('live'); + expect(storedWaiting.studentSessionToken).toBeTruthy(); + expect(storedWaiting.assignment).toBeNull(); + expect(storedWaiting.workspaceToken).toBeUndefined(); + expect(JSON.stringify(storedWaiting)).not.toContain(classCode); + + await expect(page.locator('body')).toHaveAttribute('data-student-class-status', 'connected', { timeout: 10000 }); + await expect(page.locator('#studentClassWorkspace')).toHaveText('Team Alpha'); + await expect(page.locator('#oneLine')).toHaveValue('Team Alpha destination Intake.'); + expect(workspaceTokens.length).toBeGreaterThanOrEqual(1); + + const alphaMarker = `${displayName} wrote in Team Alpha.`; + const alphaSave = page.waitForResponse(response => ( + response.request().method() === 'PUT' + && new URL(response.url()).pathname === '/api/workspaces/session' + && response.ok() + )); + await page.locator('#oneLine').fill(alphaMarker); + await page.locator('#oneLine').blur(); + await alphaSave; + + await page.reload(); + + await expect(page.locator('#experienceRoleGate')).toBeHidden(); + await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'student'); + await expect(page.locator('body')).toHaveAttribute('data-student-class-status', 'connected', { timeout: 10000 }); + await expect(page.locator('#studentClassWorkspace')).toHaveText('Team Alpha'); + await expect(page.locator('#oneLine')).toHaveValue(alphaMarker); + expect(workspaceTokens.length).toBeGreaterThanOrEqual(2); + const resumedAlphaToken = workspaceTokens.at(-1); + + await expect(page.locator('#studentClassWorkspace')).toHaveText('Team Beta', { timeout: 10000 }); + await expect(page.locator('#oneLine')).toHaveValue('Team Beta destination Intake.'); + expect(workspaceTokens.length).toBeGreaterThanOrEqual(3); + + const staleAlphaResponse = await page.request.get('/api/workspaces/session', { + headers: { Authorization: `Bearer ${resumedAlphaToken}` } + }); + expect(staleAlphaResponse.status()).toBe(404); + + const betaToken = workspaceTokens.at(-1); + await expect(page.locator('body')).toHaveAttribute('data-student-class-status', 'waiting', { timeout: 10000 }); + await expect(page.locator('#studentClassWaitingPanel')).toBeVisible(); + await expect(page.locator('.wrap')).toBeHidden(); + + const staleBetaResponse = await page.request.get('/api/workspaces/session', { + headers: { Authorization: `Bearer ${betaToken}` } + }); + expect(staleBetaResponse.status()).toBe(404); + + const storedAfterUnassign = await page.evaluate(key => JSON.parse(window.localStorage.getItem(key)), STUDENT_SESSION_STORAGE_KEY); + expect(storedAfterUnassign.mode).toBe('live'); + expect(storedAfterUnassign.assignmentRevision).toBe(3); + expect(storedAfterUnassign.assignment).toBeNull(); + expect(storedAfterUnassign.workspaceToken).toBeUndefined(); + + await expectNoBlockingA11yViolations(page); + expect(pageErrors).toEqual([]); +}); + test('Student joins an assigned workspace, discards admission codes, and resumes shared work after reload', async ({ page }, testInfo) => { const pageErrors = watchPageErrors(page); const suffix = testInfo.project.name === 'chromium-mobile' ? 'm' : 'd'; @@ -54,6 +146,7 @@ test('Student joins an assigned workspace, discards admission codes, and resumes await page.locator('#studentDisplayName').fill(displayName); await page.getByLabel('Class code').fill(classCode); + await page.locator('#studentLegacyJoin > summary').click(); await page.getByLabel('Assignment code').fill(assignmentCode); await page.getByRole('button', { name: 'Join class' }).click(); diff --git a/tests/helpers/classroom-test-repositories.mjs b/tests/helpers/classroom-test-repositories.mjs index 5be0650..5c86877 100644 --- a/tests/helpers/classroom-test-repositories.mjs +++ b/tests/helpers/classroom-test-repositories.mjs @@ -129,6 +129,14 @@ export function createWorkspaceRepository() { participants: next }; }, + async removePresenceByWorkspaceId(workspaceId, participantId) { + const workspace = workspaces.get(Number(workspaceId)) || workspaces.get(workspaceId); + if (!workspace) return false; + const participants = presenceByWorkspace.get(workspace.id) || []; + const next = participants.filter(item => item.id !== participantId); + presenceByWorkspace.set(workspace.id, next); + return next.length !== participants.length; + }, async observeById(workspaceId) { const workspace = workspaces.get(Number(workspaceId)) || workspaces.get(workspaceId); if (!workspace) return null; @@ -150,30 +158,37 @@ export function createClassroomRepository() { const classes = []; const workspaces = []; const memberships = new Map(); + const participants = new Map(); const coaching = new Map(); const activeByInstructor = hash => classes.find(item => item.instructorHash === hash && !item.revoked) || null; const activeByJoin = hash => classes.find(item => item.studentJoinHash === hash && item.joinsEnabled && !item.revoked) || null; + const activeByJoinCode = joinCode => classes.find(item => ( + item.studentJoinCode === joinCode && item.joinsEnabled && !item.revoked + )) || null; const publicClass = item => item ? { internal_id: item.internalId, id: item.id, title: item.title, joinsEnabled: item.joinsEnabled, - expiresAt: item.expiresAt + expiresAt: item.expiresAt, + joinCode: item.studentJoinCode || null } : null; return { classes, workspaces, memberships, + participants, coaching, - async createClass({ publicId, title, instructorHash, studentJoinHash }) { + async createClass({ publicId, title, instructorHash, studentJoinHash, studentJoinCode = null }) { const item = { internalId: nextInternalId++, id: publicId, title, instructorHash, studentJoinHash, + studentJoinCode, joinsEnabled: true, expiresAt: 'future', revoked: false @@ -184,6 +199,242 @@ export function createClassroomRepository() { async getClassByInstructor(hash) { return publicClass(activeByInstructor(hash)); }, + async getClassByJoinCode(joinCode) { + return publicClass(activeByJoinCode(joinCode)); + }, + async admitParticipant({ joinCode, participantId, displayName, sessionHash }) { + const item = activeByJoinCode(joinCode); + if (!item) return null; + const key = item.internalId + ':' + participantId; + const previous = participants.get(key); + if (previous?.revoked) return null; + const participant = { + classInternalId: item.internalId, + participantId, + displayName, + sessionHash, + workspaceId: previous?.workspaceId || null, + workspaceAccessHash: previous?.workspaceAccessHash || null, + assignmentRevision: previous?.assignmentRevision || 0, + joinedAt: previous?.joinedAt || 'joined', + updatedAt: 'updated', + revoked: false + }; + participants.set(key, participant); + const assignment = participant.workspaceId + ? workspaces.find(workspace => ( + workspace.classInternalId === item.internalId + && workspace.workspaceId === participant.workspaceId + && !workspace.revoked + )) + : null; + return { + classroom: publicClass(item), + participant: { + id: participant.participantId, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt, + updatedAt: participant.updatedAt + }, + assignment: assignment + ? { id: assignment.id, kind: assignment.kind, label: assignment.label } + : null + }; + }, + async getParticipantBySession(sessionHash) { + const participant = [...participants.values()].find(value => value.sessionHash === sessionHash && !value.revoked); + if (!participant) return null; + const item = classes.find(candidate => candidate.internalId === participant.classInternalId && !candidate.revoked); + if (!item) return null; + const assignment = participant.workspaceId + ? workspaces.find(workspace => ( + workspace.classInternalId === item.internalId + && workspace.workspaceId === participant.workspaceId + && !workspace.revoked + )) + : null; + return { + classroom: publicClass(item), + participant: { + id: participant.participantId, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt + }, + assignment: assignment + ? { id: assignment.id, kind: assignment.kind, label: assignment.label } + : null, + internal: { classId: item.internalId, workspaceId: participant.workspaceId || null } + }; + }, + async listParticipants(instructorHash) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + return { + classroom: publicClass(item), + participants: [...participants.values()] + .filter(participant => participant.classInternalId === item.internalId && !participant.revoked) + .map(participant => { + const assignment = participant.workspaceId + ? workspaces.find(workspace => ( + workspace.classInternalId === item.internalId + && workspace.workspaceId === participant.workspaceId + && !workspace.revoked + )) + : null; + return { + id: participant.participantId, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt, + updatedAt: participant.updatedAt, + assignment: assignment + ? { id: assignment.id, kind: assignment.kind, label: assignment.label } + : null + }; + }) + }; + }, + async assignParticipant(instructorHash, { participantId, workspacePublicId, workspaceRepository }) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const participant = participants.get(item.internalId + ':' + participantId); + if (!participant || participant.revoked) return null; + + const destination = workspacePublicId === null + ? null + : workspaces.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.id === workspacePublicId + && !candidate.revoked + )); + if (workspacePublicId !== null && !destination) return null; + + if (destination?.kind === 'individual') { + if (destination.individualParticipantId && destination.individualParticipantId !== participantId) { + return { status: 'occupied', classroom: publicClass(item) }; + } + destination.individualParticipantId = participantId; + } + + const nextWorkspaceId = destination?.workspaceId || null; + if ((participant.workspaceId || null) === nextWorkspaceId) { + return { + status: 'unchanged', + classroom: publicClass(item), + participant: { + id: participant.participantId, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt, + updatedAt: participant.updatedAt + }, + assignment: destination + ? { id: destination.id, kind: destination.kind, label: destination.label } + : null + }; + } + + const previousWorkspaceId = participant.workspaceId; + if (participant.workspaceAccessHash) { + await workspaceRepository.revokeCapability(participant.workspaceAccessHash); + } + if (previousWorkspaceId) { + await workspaceRepository.removePresenceByWorkspaceId(previousWorkspaceId, participantId); + } + + const previousWorkspace = previousWorkspaceId + ? workspaces.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.workspaceId === previousWorkspaceId + )) + : null; + + participant.workspaceId = nextWorkspaceId; + participant.workspaceAccessHash = null; + participant.assignmentRevision += 1; + participant.updatedAt = 'updated'; + + if (previousWorkspace?.kind === 'individual' + && previousWorkspace.workspaceId !== nextWorkspaceId + && previousWorkspace.individualParticipantId === participantId) { + previousWorkspace.individualParticipantId = null; + } + + return { + status: 'updated', + classroom: publicClass(item), + participant: { + id: participant.participantId, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt, + updatedAt: participant.updatedAt + }, + assignment: destination + ? { id: destination.id, kind: destination.kind, label: destination.label } + : null + }; + }, + async issueParticipantWorkspaceAccess({ sessionHash, accessHash, workspaceRepository }) { + const participant = [...participants.values()].find(value => value.sessionHash === sessionHash && !value.revoked); + if (!participant) return null; + const item = classes.find(candidate => candidate.internalId === participant.classInternalId && !candidate.revoked); + if (!item) return null; + if (!participant.workspaceId) { + return { + classroom: publicClass(item), + participant: { + id: participant.participantId, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt + }, + assignment: null, + internal: { classId: item.internalId, workspaceId: null }, + waiting: true + }; + } + const workspace = workspaces.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.workspaceId === participant.workspaceId + && !candidate.revoked + )); + if (!workspace) return null; + + if (!await workspaceRepository.createCapability( + workspace.workspaceId, + accessHash, + 'classroom-student', + item.expiresAt + )) throw new Error('Unable to create live capability'); + + const previous = participant.workspaceAccessHash; + participant.workspaceAccessHash = accessHash; + participant.updatedAt = 'updated'; + if (previous && previous !== accessHash) { + await workspaceRepository.revokeCapability(previous); + } + + return { + classroom: publicClass(item), + participant: { + id: participant.participantId, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt + }, + assignment: { + id: workspace.id, + kind: workspace.kind, + label: workspace.label, + expiresAt: item.expiresAt + }, + internal: { classId: item.internalId, workspaceId: workspace.workspaceId }, + waiting: false + }; + }, async rotateStudentJoin(instructorHash, nextHash) { const item = activeByInstructor(instructorHash); if (!item) return null; @@ -241,9 +492,15 @@ export function createClassroomRepository() { kind: workspace.kind, label: workspace.label, createdAt: workspace.createdAt, - participantCount: [...memberships.values()].filter(member => ( - member.classInternalId === item.internalId && member.workspaceId === workspace.workspaceId - )).length, + participantCount: + [...memberships.values()].filter(member => ( + member.classInternalId === item.internalId && member.workspaceId === workspace.workspaceId + )).length + + [...participants.values()].filter(participant => ( + participant.classInternalId === item.internalId + && participant.workspaceId === workspace.workspaceId + && !participant.revoked + )).length, activeParticipantCount: 0, editingParticipantCount: 0, lastSeenAt: null @@ -311,6 +568,23 @@ export function createClassroomRepository() { return { classroom: scope.classroom, workspace: scope.workspace, cleared, targetId }; }, async getStudentContext(accessHash) { + const live = [...participants.values()].find(value => value.workspaceAccessHash === accessHash && !value.revoked); + if (live) { + const item = classes.find(candidate => candidate.internalId === live.classInternalId && !candidate.revoked); + const workspace = item && workspaces.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.workspaceId === live.workspaceId + && !candidate.revoked + )); + if (item && workspace) { + return { + classroom: publicClass(item), + workspace: { id: workspace.id, kind: workspace.kind, label: workspace.label }, + internal: { classId: item.internalId, workspaceId: workspace.workspaceId } + }; + } + } + const member = [...memberships.values()].find(value => value.accessHash === accessHash); if (!member) return null; const item = classes.find(candidate => candidate.internalId === member.classInternalId && !candidate.revoked);