diff --git a/.agent/knowledge/repo_discoveries.md b/.agent/knowledge/repo_discoveries.md index 6a6c9abb..16bd3ab5 100644 --- a/.agent/knowledge/repo_discoveries.md +++ b/.agent/knowledge/repo_discoveries.md @@ -14,6 +14,14 @@ Use this log for durable findings that future contributors and agents should reu ## Discoveries +- Date: 2026-04-21 +- Area: activities | planning | commissioned-ideas +- Discovery: A staged classroom competition activity like Commissioned Ideas, described to teachers as Shark Tank-inspired, fits the current repo best when it combines activity-local stage control and registration patterns from `gallery-walk`, reveal/review flow ideas from `resonance`, leaderboard-style ranking from `traveling-salesman`, and a lightweight winner reveal borrowed from `raffle`, without adding any activity-specific branches to shared dashboard or routing layers. +- Why it matters: The requested flow spans registration, instructor-controlled live progression, weighted peer voting, and a final reveal. Treating those as activity-owned concerns keeps the repo aligned with the activity-containment policy and avoids speculative shared abstractions for "multi-phase competition" behavior. +- Evidence: `activities/gallery-walk/server/routes.ts`; `activities/gallery-walk/client/manager/ManagerPage.tsx`; `activities/gallery-walk/client/student/StudentPage.tsx`; `activities/resonance/server/routes.ts`; `activities/resonance/client/manager/ResonanceManager.tsx`; `activities/traveling-salesman/client/components/Leaderboard.tsx`; `activities/raffle/client/manager/RaffleManager.tsx`; `.agent/plans/commissioned-ideas-activity-plan.md` +- Follow-up action: When implementation begins, keep Commissioned Ideas session state, voting rules, scoring, and podium reveal inside `activities/commissioned-ideas/...`, and only extract shared seams if a second activity later needs the same contracts. +- Owner: Codex + - Date: 2026-04-21 - Area: client | activities | syncdeck - Discovery: SyncDeck should scope `allow-popups-to-escape-sandbox` to instructor-configured presentation iframes only; embedded/internal iframes should keep the stricter sandbox without popup escape. diff --git a/.agent/knowledge/security-notes.md b/.agent/knowledge/security-notes.md index 8a4c500c..c1ad94f9 100644 --- a/.agent/knowledge/security-notes.md +++ b/.agent/knowledge/security-notes.md @@ -130,3 +130,12 @@ Track security-relevant boundaries, risks, and mitigation decisions. - Validation (test/review/path): `client/src/components/common/manageDashboardUtils.ts`; `client/src/components/common/ManageDashboard.tsx`; `activities/syncdeck/server/routes.ts`; `client/src/components/common/manageDashboardUtils.test.ts`; `activities/syncdeck/server/routes.test.ts`; `npm test`. - Follow-up action: Add optional hostname/domain allowlist policy if deployment requires restricting presentation origins. - Owner: Codex + +- Date: 2026-04-23 +- Area: commissioned-ideas instructor bootstrap + manager websocket auth +- Threat or risk: Storing `instructorPasscode` in browser `sessionStorage` and placing it in the websocket URL query string exposes the credential to client-side storage inspection, browser/network tooling, and URL-based logging surfaces. Those patterns are especially likely to trip CodeQL and create avoidable secret-handling risk. +- Control or mitigation: `commissioned-ideas` now carries the passcode only in the one-time create-session bootstrap history payload, opts out of the generic same-tab `sessionStorage` fallback (`allowSessionStorageFallback: false`), and authenticates manager websocket connections with a post-connect `commissioned-ideas:manager-auth` message instead of a query parameter. +- Residual risk: Reloading the manager page after the initial handoff drops the in-memory bootstrap payload, so the instructor must reopen from the create-session navigation flow. The create route still intentionally returns the passcode, so downstream consumers must avoid logging that response body. +- Validation (test/review/path): `activities/commissioned-ideas/activity.config.ts`; `activities/commissioned-ideas/client/manager/CommissionedIdeasManager.tsx`; `activities/commissioned-ideas/client/hooks/useCommissionedIdeasSession.ts`; `activities/commissioned-ideas/server/routes.ts`; `client/src/components/common/manageDashboardUtils.ts`; `client/src/components/common/manageDashboardUtils.test.ts`; `activities/commissioned-ideas/server/routeHandlers.test.ts`; `npm test` (blocked only by unrelated existing server failures in `galleryWalkRoutes.test.ts`, `sessionStore.test.ts`, and `statusRoute.test.ts`). +- Follow-up action: Reuse the same opt-out + post-connect auth pattern for any future activity that needs to hand an instructor secret from create-session into a websocket-managed screen. +- Owner: Codex diff --git a/.agent/plans/commissioned-ideas-activity-plan.md b/.agent/plans/commissioned-ideas-activity-plan.md new file mode 100644 index 00000000..7ba355fd --- /dev/null +++ b/.agent/plans/commissioned-ideas-activity-plan.md @@ -0,0 +1,549 @@ +# Commissioned Ideas Activity Plan + +This plan outlines a new multi-phase ActiveBits activity for classroom presentations. `Commissioned Ideas` should stay self-contained under `activities/commissioned-ideas/`, and the product description can continue to frame it as Shark Tank-inspired while the activity itself keeps a broader classroom-friendly name. + +## Goal + +Create a live classroom activity where: + +1. Students register as individuals, organize themselves into teams, and collaboratively settle on a team name and project name. +2. The instructor moves the class into a presentation phase. +3. Participants rate projects by assigning exactly one `$100`, one `$300`, and one `$500` award to three different projects. +4. The instructor reveals the final podium with a dramatic `3 ... 2 ... winner` sequence showing the top three teams. + +## Default Product Decisions + +- Activity id: `commissioned-ideas` +- Display name: `Commissioned Ideas` +- Session model: normal instructor-managed live session +- Registration starts with individual student names +- Instructor can edit or reject student display names +- Instructor sets a maximum team size for the session +- Instructor can choose manual self-organized grouping or random group assignment +- Instructor can continue assigning late or ungrouped students after the main student lock +- Students self-organize into teams during registration +- Team names and project names are proposed by team members and voted on by that team's members +- Voting rule: each voter must assign `$100`, `$300`, and `$500` to three distinct teams +- Result ranking: total awarded dollars, descending +- Tiebreakers: + 1. more `$500` awards + 2. more `$300` awards + 3. earlier registration time +- Self-voting: disallowed by default when the voter belongs to a registered team +- Podium reveal: instructor-controlled stepper showing third place, then second place, then winner + +## Scope + +In scope: + +- A new self-contained activity under `activities/commissioned-ideas/` +- Team registration flow for student groups +- Instructor-controlled phase transitions +- Live presentation roster / queue view +- Weighted ballot submission with validation +- Final ranking and podium reveal +- Activity-specific tests for shared logic, routes, and core client flows + +Out of scope for v1: + +- Persistent links or standalone utility flows +- Judge-specific weighting rules beyond the single `$100/$300/$500` ballot +- Multiple simultaneous ballots per voter +- Cross-session exports/reports unless the implementation later needs them +- SyncDeck embedding + +## Why This Fits The Current Repo + +- `gallery-walk` already demonstrates activity-local registration, stage changes, and teacher/student realtime updates. +- `resonance` already demonstrates instructor-controlled timed/live phases and review/reveal-oriented manager UI. +- `traveling-salesman` already demonstrates leaderboard-style ranking views. +- `raffle` already demonstrates a simple winner-focused manager reveal pattern that can inspire the podium moment. + +The new activity should borrow those ideas without adding Commissioned Ideas-specific conditionals to shared code. + +## Proposed Activity Shape + +```text +activities/commissioned-ideas/ +├── activity.config.ts +├── shared/ +│ ├── types.ts +│ ├── scoring.ts +│ └── validation.ts +├── client/ +│ ├── index.ts +│ ├── manager/ +│ │ ├── SharkTankManager.tsx +│ │ ├── PodiumReveal.tsx +│ │ └── PresentationQueue.tsx +│ └── student/ +│ ├── SharkTankStudent.tsx +│ ├── TeamRegistrationForm.tsx +│ └── VotingBallot.tsx +└── server/ + ├── routes.ts + └── routes.test.ts +``` + +## Session Model + +```ts +type SharkTankPhase = 'registration' | 'presentation' | 'voting' | 'results' + +type PodiumRevealStep = 'hidden' | 'third' | 'second' | 'winner' | 'complete' + +interface SharkTankTeam { + id: string + groupName: string | null + projectName: string | null + registeredAt: number + presenterOrder: number | null + locked: boolean + memberIds: string[] + proposedGroupNames: { + id: string + value: string + proposedByParticipantId: string + createdAt: number + rejectedByInstructor: boolean + }[] + proposedProjectNames: { + id: string + value: string + proposedByParticipantId: string + createdAt: number + rejectedByInstructor: boolean + }[] + groupNameVotes: Record + projectNameVotes: Record +} + +interface SharkTankBallot { + voterId: string + voterName: string + voterTeamId: string | null + allocations: { + teamId: string + amount: 100 | 300 | 500 + }[] + submittedAt: number +} + +interface SharkTankSessionData { + phase: SharkTankPhase + studentGroupingLocked: boolean + namingLocked: boolean + maxTeamSize: number + groupingMode: 'manual' | 'random' + presentationRound: number + allowLateRegistration: boolean + teams: Record + participantRoster: Record + ballots: Record + presentationHistory: { + round: number + teamId: string + presentedAt: number + }[] + currentPresentationTeamId: string | null + podiumRevealStep: PodiumRevealStep +} +``` + +## Phase Behavior + +### 1. Registration + +- Student view starts with individual name entry. +- The instructor can edit or reject submitted student names before registration is locked. +- The instructor sets a session-wide maximum team size. +- The instructor can remove students from groups. +- Student grouping lock and naming lock are separate controls. +- After student grouping is locked, existing groups stay fixed, but the instructor can still place ungrouped or late-arriving students. +- Students see the live roster of students and current groups. +- Students can: + - remain ungrouped + - create a new team + - join a team by selecting a classmate or existing group when grouping mode is manual + - leave a team until student grouping is locked +- The joining affordance should feel active and social, for example `Click a name to join a group with...`. +- If grouping mode is random, the instructor can assign or reshuffle random groups up to the grouping lock. +- After student grouping is locked, the instructor can still `reshuffle ungrouped` to place only students who are not yet in a team. +- After student grouping is locked, the instructor can manually assign ungrouped or late-arriving students to teams. +- Team members can propose: + - a team name + - a project name +- Team members vote within their own team on those proposed names. +- The instructor can reject inappropriate names or proposals. +- Students can continue proposing names and changing votes after grouping is locked until naming is locked. +- A team's current displayed team name/project name resolves from the leading non-rejected proposal, with ties broken deterministically. +- Manager view shows: + - total students + - total teams + - ungrouped students + - live team membership + - max team size control + - grouping mode control + - name moderation controls + - proposal moderation controls + - student removal from groups + - lock students control + - lock naming control + - QR code and join URL display + - reshuffle ungrouped control after the main grouping lock + - manual assignment tools for ungrouped or late students + - editable presentation order once teams are sufficiently formed + - button to advance to presentations + +### 2. Presentation + +- Student view shows the ordered project list and highlights the active presenting team. +- The instructor can return from results to another presentation round without rebuilding teams. +- Manager view shows: + - presentation queue + - current round indicator + - pick active/presenting team controls + - randomizer for teams that have not yet presented in the current round + - next/previous controls if the instructor is walking the queue manually + - reset/start next round control + - return-to-presentation control from results + - button to advance to voting +- No ballots accepted yet. + +### 3. Voting + +- Student view switches to a ballot builder. +- Validation rules: + - exactly three allocations + - must contain one each of `$100`, `$300`, `$500` + - all three target teams must be different + - target teams must exist + - if self-voting is disabled, the participant's own team cannot be selected +- Manager view shows: + - ballot submission progress + - who has submitted + - optional live totals hidden until results + - button to lock voting and move to results + +### 4. Results + +- Student view shows a waiting/reveal screen until the instructor reveals winners. +- Manager view shows: + - computed rankings + - podium reveal controls + - a step button for `Reveal 3rd`, `Reveal 2nd`, `Reveal Winner` + - final top-three podium after completion + +## Scoring Rules + +Aggregate each submitted ballot into per-team totals: + +- total dollars +- count of `$500` awards +- count of `$300` awards +- count of `$100` awards + +Ranking sort order: + +1. `totalDollars` descending +2. `fiveHundredCount` descending +3. `threeHundredCount` descending +4. `registeredAt` ascending + +This gives deterministic ordering without adding a shared tie-break abstraction. + +## Proposed REST / WS Contract + +### REST + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/api/commissioned-ideas/create` | Create session | +| `GET` | `/api/commissioned-ideas/:sessionId/state` | Return current student-safe snapshot | +| `POST` | `/api/commissioned-ideas/:sessionId/register-participant` | Register/reconnect a participant | +| `POST` | `/api/commissioned-ideas/:sessionId/participant-name` | Instructor edits or rejects a participant name | +| `POST` | `/api/commissioned-ideas/:sessionId/max-team-size` | Instructor sets max team size | +| `POST` | `/api/commissioned-ideas/:sessionId/grouping-mode` | Instructor chooses manual or random grouping | +| `POST` | `/api/commissioned-ideas/:sessionId/team/create` | Create a new team shell | +| `POST` | `/api/commissioned-ideas/:sessionId/team/membership` | Join or leave a team | +| `POST` | `/api/commissioned-ideas/:sessionId/team/remove-member` | Instructor removes a student from a team | +| `POST` | `/api/commissioned-ideas/:sessionId/random-groups` | Instructor assigns or reshuffles random groups | +| `POST` | `/api/commissioned-ideas/:sessionId/random-groups-ungrouped` | Instructor places only ungrouped students into teams/new groups | +| `POST` | `/api/commissioned-ideas/:sessionId/manual-assignment` | Instructor manually assigns an ungrouped or late student to a team | +| `POST` | `/api/commissioned-ideas/:sessionId/team/proposal` | Propose a team name or project name | +| `POST` | `/api/commissioned-ideas/:sessionId/team/proposal-vote` | Vote on a name proposal | +| `POST` | `/api/commissioned-ideas/:sessionId/team/proposal-moderation` | Instructor rejects or restores a proposal | +| `POST` | `/api/commissioned-ideas/:sessionId/grouping-lock` | Instructor locks or unlocks student grouping | +| `POST` | `/api/commissioned-ideas/:sessionId/naming-lock` | Instructor locks or unlocks team/project naming | +| `POST` | `/api/commissioned-ideas/:sessionId/phase` | Instructor changes phase | +| `POST` | `/api/commissioned-ideas/:sessionId/presentation-order` | Instructor reorders teams | +| `POST` | `/api/commissioned-ideas/:sessionId/presentation-active` | Instructor sets the current presenting team | +| `POST` | `/api/commissioned-ideas/:sessionId/presentation-randomize` | Select a random not-yet-presented team for the round | +| `POST` | `/api/commissioned-ideas/:sessionId/presentation-round` | Start the next presentation round or reset round tracking | +| `POST` | `/api/commissioned-ideas/:sessionId/ballot` | Submit or replace a participant ballot | +| `GET` | `/api/commissioned-ideas/:sessionId/results` | Instructor-visible scored results | +| `POST` | `/api/commissioned-ideas/:sessionId/podium-step` | Instructor advances/reset reveal step | + +### WebSocket Messages + +- `commissioned-ideas:session-state` +- `commissioned-ideas:registration-updated` +- `commissioned-ideas:phase-changed` +- `commissioned-ideas:team-updated` +- `commissioned-ideas:presentation-active` +- `commissioned-ideas:ballot-progress` +- `commissioned-ideas:podium-step` + +The outer message shape should follow the repo's existing activity-local realtime patterns rather than introducing new shared websocket infrastructure. + +## UI Direction + +### Manager + +- Registration dashboard with: + - roster moderation + - max team size control + - manual vs random grouping control + - grouped / ungrouped students + - team proposal moderation + - lock students control + - lock naming control + - QR code and join link for the live session + - reshuffle-ungrouped control after student lock + - manual late-student assignment tools +- Presentation mode with a bold queue and active team spotlight +- Voting dashboard with submission progress and "Reveal Results" +- Results dashboard with: + - compact ranking table + - top-three cards + - podium reveal control strip + - dramatic stepwise reveal state + +### Student + +- Registration phase: + - enter name + - see classmates and groups in real time + - click a student or team to join when grouping is manual + - wait for assignment when grouping is random + - leave current team until student grouping is locked + - propose team/project names + - vote or change vote on proposed names within the team until naming is locked + - late students can still register after student grouping lock, but they wait for instructor assignment instead of regrouping everyone +- Presentation phase: read-only queue and active presenter callout +- Voting phase: accessible ballot builder using native selects or radio groups +- Results phase: suspense/reveal card that updates as the instructor advances the podium + +## Validation / Normalization Requirements + +- Register a session normalizer for `commissioned-ideas` +- Treat missing or malformed collections as empty objects +- Sanitize strings for participant names, group names, and project names +- Enforce max team size on join attempts +- Prevent team membership changes after student grouping is locked +- Prevent proposal creation or vote changes after naming is locked +- Prevent rejected participants from joining teams or voting +- Ignore rejected proposals when resolving displayed team/project names +- Allow instructor-only placement of ungrouped/late students after student grouping lock +- Reject ballots with invalid amounts or duplicate team targets +- Reject instructor-only routes from student callers +- Add structured server logging for invalid ballot submissions and phase transition errors + +## Implementation Phases + +### Phase 0: Finalize Contract + +Goal: +- Lock the activity metadata and core product rules before code scaffolding starts. + +Implementation: +- [x] Confirm final activity metadata (`id`, name, description, color) +- [x] Confirm whether branch naming, file naming, and component naming should switch from legacy `SharkTank...` placeholders to `CommissionedIdeas...` +- [x] Confirm default grouping mode (`manual`) and whether `allowLateRegistration` should default on or off +- [x] Confirm whether presentation order can be edited after the first round begins or only before presentation starts +- [x] Confirm whether ballots remain editable until voting is locked + +Exit criteria: +- Product wording and default rules are stable enough to scaffold without churn. + +### Phase 1: Scaffold Activity Boundary + +Goal: +- Create the self-contained activity shell and shared data contract. + +Implementation: +- [x] Scaffold `activities/commissioned-ideas/` +- [x] Add `activity.config.ts` +- [x] Add client entry and initial manager/student components +- [x] Add server routes entry +- [x] Define shared `types`, `validation`, and `scoring` modules +- [x] Register the session normalizer for `commissioned-ideas` +- [x] Add a minimal create-session route and student-safe state route + +Exit criteria: +- Activity loads through the normal registry and returns normalized empty session state safely. + +### Phase 2: Registration Roster And Moderation + +Goal: +- Make individual student registration and instructor moderation work end to end. + +Implementation: +- [x] Implement participant registration and reconnect flow +- [x] Implement instructor edit/reject name flow +- [x] Implement roster state for connected, disconnected, and rejected students +- [x] Add manager registration dashboard shell +- [x] Add QR code and join-link display on the manager registration screen +- [x] Add tests for participant registration, normalization, and moderation routes + +Exit criteria: +- Students can join by name, instructors can moderate names live, and both views stay in sync. + +### Phase 3: Team Formation + +Goal: +- Support classroom grouping workflows before naming begins. + +Implementation: +- [x] Implement max team size control +- [x] Implement manual team formation and leave/join flow +- [x] Implement optional random grouping flow +- [x] Implement reshuffle-ungrouped flow for post-lock cleanup +- [x] Implement instructor manual assignment flow for late/ungrouped students +- [x] Implement instructor removal of students from groups +- [x] Implement `studentGroupingLocked` behavior so grouped students are fixed while ungrouped/late students remain instructor-placeable +- [x] Add manager/team roster UI for grouped and ungrouped students +- [x] Add tests for membership constraints, random grouping, reshuffle-ungrouped, and instructor-only late assignment + +Exit criteria: +- The teacher can get every student into a valid team structure without reopening full free-form grouping. + +### Phase 4: Team Naming And Proposal Voting + +Goal: +- Let teams collaboratively settle on a team name and project name after grouping is mostly stable. + +Implementation: +- [ ] Implement team-name proposal creation +- [ ] Implement project-name proposal creation +- [ ] Implement team-member voting and vote changes for both proposal types +- [ ] Implement instructor proposal rejection/restoration +- [ ] Implement deterministic current-name resolution from non-rejected proposals +- [ ] Implement `namingLocked` behavior separately from student grouping lock +- [ ] Add manager and student proposal/voting UI +- [ ] Add tests for proposal tallying, vote changes, moderation, and naming lock enforcement + +Exit criteria: +- Teams have stable displayed names and project titles that survive reloads and moderation. + +### Phase 5: Presentation Flow And Rounds + +Goal: +- Support live presentation facilitation across one or more rounds. + +Implementation: +- [ ] Implement presentation order management +- [ ] Implement direct presenting-team selection +- [ ] Implement randomizer for teams not yet presented in the current round +- [ ] Implement `presentationRound` tracking and `presentationHistory` +- [ ] Implement start-next-round/reset-round controls +- [ ] Implement return-from-results to presentation +- [ ] Add manager presentation queue UI and student active-presenter view +- [ ] Add tests for round tracking, randomizer eligibility, and multi-round behavior + +Exit criteria: +- The instructor can reliably run manual or randomized presentation rounds without losing team state. + +### Phase 6: Voting + +Goal: +- Collect valid weighted ballots tied to participant identity and team membership. + +Implementation: +- [ ] Implement voting ballot UI with accessibility semantics +- [ ] Enforce `$100/$300/$500` distinct-team validation on client and server +- [ ] Enforce self-vote blocking from `participant.teamId` +- [ ] Implement ballot create/replace persistence +- [ ] Implement manager voting-progress dashboard +- [ ] Add tests for ballot validation, self-vote blocking, replace behavior, and progress reporting + +Exit criteria: +- Valid ballots can be submitted and tracked live, and invalid/self-voting ballots are rejected deterministically. + +### Phase 7: Results And Podium + +Goal: +- Reveal the winning teams in a clean, dramatic final phase. + +Implementation: +- [ ] Implement ranking calculation and deterministic tie-breakers +- [ ] Implement results route / results state derivation +- [ ] Implement podium reveal sequencing +- [ ] Implement manager podium controls +- [ ] Implement student reveal screen updates +- [ ] Verify top-three rendering, reveal resets, and return-to-presentation behavior +- [ ] Add tests for scoring, tie-breakers, reveal-step transitions, and top-three rendering + +Exit criteria: +- The instructor can reveal 3rd, 2nd, and winner cleanly, and rankings stay stable across reloads. + +### Phase 8: Verification And Polish + +Goal: +- Close the feature with the expected repo validation and durable notes. + +Implementation: +- [ ] Add any missing activity-specific tests +- [ ] Run repo-appropriate validation commands +- [ ] Run `npm run test:e2e` if shared browser seams changed materially +- [ ] Update docs if runtime/build/deployment behavior changes +- [ ] Record durable implementation discoveries in `.agent/knowledge/` + +Exit criteria: +- The feature is reviewable, validated, and leaves reusable context for future contributors. + +## Test Matrix + +| Requirement | Unit tests | Integration tests | Browser-visible tests | +| --- | --- | --- | --- | +| Participant registration and moderation | validator and normalization tests | participant route persists roster data and instructor moderation works | student registers, teacher edits/rejects name, roster updates live | +| Team formation | membership helper tests | join/leave routes enforce max team size and grouping lock state | student joins/leaves a group and both views update | +| Random grouping | grouping helper tests | random assignment route respects max team size and current roster | instructor assigns random groups and views update live | +| Reshuffle ungrouped / late assignment | assignment helper tests | post-lock routes place only ungrouped students and preserve locked teams | instructor assigns late students without reopening full grouping | +| Team/project proposal voting | proposal tally helper tests | proposal vote route resolves current names deterministically and locks correctly | team members propose and vote on names in real time | +| Presentation rounds | round/randomizer helper tests | presenting-team route and randomizer skip already presented teams in the current round | instructor picks or randomizes presenting team across multiple rounds | +| Phase transitions | phase reducer/helper tests | instructor route updates phase and broadcasts | manager advances phases and student screen changes | +| Presentation ordering | order helper tests | reorder route persists order | active presenter changes appear on student view | +| Weighted ballot rules | ballot validation tests | invalid ballots rejected; valid ballots saved/replaced | voter can assign `$100/$300/$500` once each | +| Self-vote blocking | eligibility helper tests | own-team ballot target rejected | voter cannot submit with own team selected | +| Ranking and tie-breaks | scoring helper tests | results route returns deterministic top three | final podium reflects computed order | +| Podium reveal | reveal-step helper tests | reveal route broadcasts reveal state | student results screen animates through `3`, `2`, winner | + +If the implementation changes shared routing or browser seams materially, include `npm run test:e2e` per repo guidance. + +## Rollout Order + +Recommended execution order: + +1. Phase 0 and Phase 1 first so the data contract and activity shell are stable. +2. Phase 2 and Phase 3 next so roster + grouping work before naming complexity is added. +3. Phase 4 after grouping, because naming lock semantics depend on team membership already existing. +4. Phase 5 before Phase 6 so the instructor can already facilitate presentations on stable teams. +5. Phase 6 and Phase 7 last for the live competition outcome. +6. Phase 8 at the end of each implementation slice, not only at the very end. + +## Assumptions To Keep Unless Product Says Otherwise + +- One ballot per participant, replaceable until the instructor leaves voting +- Students can watch all phases from the same session URL +- Teams are ranked by total awarded dollars, not by average score +- The podium highlights teams, not individual presenters +- No anonymous registration requirement for v1 +- The instructor can move from results back into another presentation round when desired diff --git a/activities/commissioned-ideas/activity.config.ts b/activities/commissioned-ideas/activity.config.ts new file mode 100644 index 00000000..a647ff7a --- /dev/null +++ b/activities/commissioned-ideas/activity.config.ts @@ -0,0 +1,23 @@ +import type { ActivityConfig } from '../../types/activity.js' + +const commissionedIdeasConfig: ActivityConfig = { + id: 'commissioned-ideas', + name: 'Commissioned Ideas', + description: 'Teams pitch their ideas and the class votes to award funding', + color: 'amber', + standaloneEntry: { + enabled: false, + supportsDirectPath: false, + supportsPermalink: false, + showOnHome: false, + }, + createSessionBootstrap: { + sessionStorage: [], + historyState: ['instructorPasscode'], + allowSessionStorageFallback: false, + }, + clientEntry: './client/index.ts', + serverEntry: './server/routes.ts', +} + +export default commissionedIdeasConfig diff --git a/activities/commissioned-ideas/client/hooks/useCommissionedIdeasSession.ts b/activities/commissioned-ideas/client/hooks/useCommissionedIdeasSession.ts new file mode 100644 index 00000000..d6b485d3 --- /dev/null +++ b/activities/commissioned-ideas/client/hooks/useCommissionedIdeasSession.ts @@ -0,0 +1,176 @@ +import { useCallback, useRef, useState } from 'react' +import { useResilientWebSocket } from '@src/hooks/useResilientWebSocket' +import type { StudentSafeParticipant } from '../../shared/types.js' + +// ── Client-side snapshot types ──────────────────────────────────────────────── + +export interface ClientTeam { + id: string + groupName: string | null + projectName: string | null + registeredAt: number + memberIds: string[] +} + +export interface StudentSnapshot { + phase: string + studentGroupingLocked: boolean + namingLocked: boolean + maxTeamSize: number + groupingMode: string + participantRoster: Record + teams: Record + ballotSubmitted: boolean + myBallot: unknown | null + ballotsReceived: number + currentPresentationTeamId: string | null + podiumRevealStep: string +} + +export interface ManagerParticipant { + id: string + name: string + teamId: string | null + connected: boolean + lastSeen: number + rejectedByInstructor: boolean +} + +export interface ManagerSnapshot { + phase: string + studentGroupingLocked: boolean + namingLocked: boolean + maxTeamSize: number + groupingMode: string + participantRoster: Record + teams: Record + ballotsReceived: number + currentPresentationTeamId: string | null + podiumRevealStep: string +} + +interface WsMessage { + type: string + sessionId?: string + data?: unknown + error?: string +} + +// ── Student hook ────────────────────────────────────────────────────────────── + +interface UseStudentSessionOptions { + sessionId: string | null | undefined + participantId: string | null + attachSessionEndedHandler?: (ws: WebSocket) => void +} + +interface UseStudentSessionResult { + snapshot: StudentSnapshot | null + connect: () => WebSocket | null + disconnect: () => void +} + +export function useStudentSession({ + sessionId, + participantId, + attachSessionEndedHandler, +}: UseStudentSessionOptions): UseStudentSessionResult { + const [snapshot, setSnapshot] = useState(null) + + const buildWsUrl = useCallback(() => { + if (!sessionId) return null + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const params = new URLSearchParams({ sessionId }) + if (participantId) params.set('participantId', participantId) + return `${proto}//${window.location.host}/ws/commissioned-ideas?${params.toString()}` + }, [sessionId, participantId]) + + const handleMessage = useCallback((event: MessageEvent) => { + try { + const msg = JSON.parse(event.data as string) as WsMessage + if ( + msg.type === 'commissioned-ideas:session-state' || + msg.type === 'commissioned-ideas:registration-updated' || + msg.type === 'commissioned-ideas:phase-changed' + ) { + setSnapshot(msg.data as StudentSnapshot) + } + } catch { + // malformed WS frame — ignored + } + }, []) + + const { connect, disconnect } = useResilientWebSocket({ + buildUrl: buildWsUrl, + shouldReconnect: Boolean(sessionId), + onMessage: handleMessage, + attachSessionEndedHandler, + }) + + return { snapshot, connect, disconnect } +} + +// ── Manager hook ────────────────────────────────────────────────────────────── + +interface UseManagerSessionOptions { + sessionId: string | null | undefined + instructorPasscode: string | null + attachSessionEndedHandler?: (ws: WebSocket) => void +} + +interface UseManagerSessionResult { + snapshot: ManagerSnapshot | null + connect: () => WebSocket | null + disconnect: () => void + socketRef: ReturnType['socketRef'] +} + +export function useManagerSession({ + sessionId, + instructorPasscode, + attachSessionEndedHandler, +}: UseManagerSessionOptions): UseManagerSessionResult { + const [snapshot, setSnapshot] = useState(null) + const mountedRef = useRef(true) + + const buildWsUrl = useCallback(() => { + if (!sessionId || !instructorPasscode) return null + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const params = new URLSearchParams({ sessionId, role: 'manager' }) + return `${proto}//${window.location.host}/ws/commissioned-ideas?${params.toString()}` + }, [sessionId, instructorPasscode]) + + const handleOpen = useCallback((_event: Event, ws: WebSocket) => { + ws.send(JSON.stringify({ + type: 'commissioned-ideas:manager-auth', + instructorPasscode, + })) + }, [instructorPasscode]) + + const handleMessage = useCallback((event: MessageEvent) => { + try { + const msg = JSON.parse(event.data as string) as WsMessage + if ( + msg.type === 'commissioned-ideas:session-state' || + msg.type === 'commissioned-ideas:registration-updated' || + msg.type === 'commissioned-ideas:phase-changed' + ) { + if (mountedRef.current) { + setSnapshot(msg.data as ManagerSnapshot) + } + } + } catch { + // malformed WS frame — ignored + } + }, []) + + const { connect, disconnect, socketRef } = useResilientWebSocket({ + buildUrl: buildWsUrl, + shouldReconnect: Boolean(sessionId) && Boolean(instructorPasscode), + onOpen: handleOpen, + onMessage: handleMessage, + attachSessionEndedHandler, + }) + + return { snapshot, connect, disconnect, socketRef } +} diff --git a/activities/commissioned-ideas/client/index.ts b/activities/commissioned-ideas/client/index.ts new file mode 100644 index 00000000..0d3ffa8d --- /dev/null +++ b/activities/commissioned-ideas/client/index.ts @@ -0,0 +1,12 @@ +import type { ComponentType } from 'react' +import type { ActivityClientModule } from '../../../types/activity.js' +import CommissionedIdeasManager from './manager/CommissionedIdeasManager' +import CommissionedIdeasStudent from './student/CommissionedIdeasStudent' + +const commissionedIdeasActivity: ActivityClientModule = { + ManagerComponent: CommissionedIdeasManager as ComponentType, + StudentComponent: CommissionedIdeasStudent as ComponentType, + footerContent: null, +} + +export default commissionedIdeasActivity diff --git a/activities/commissioned-ideas/client/manager/CommissionedIdeasManager.tsx b/activities/commissioned-ideas/client/manager/CommissionedIdeasManager.tsx new file mode 100644 index 00000000..20860724 --- /dev/null +++ b/activities/commissioned-ideas/client/manager/CommissionedIdeasManager.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import SessionHeader from '@src/components/common/SessionHeader' +import { useSessionEndedHandler } from '@src/hooks/useSessionEndedHandler' +import { consumeCreateSessionBootstrapPayload } from '@src/components/common/manageDashboardUtils' +import { useManagerSession } from '../hooks/useCommissionedIdeasSession.js' +import RegistrationDashboard from './RegistrationDashboard.js' + +function resolvePasscode(sessionId: string): string | null { + const bootstrap = consumeCreateSessionBootstrapPayload('commissioned-ideas', sessionId) + if (bootstrap !== null && typeof bootstrap.instructorPasscode === 'string' && bootstrap.instructorPasscode.length > 0) { + return bootstrap.instructorPasscode + } + + return null +} + +export default function CommissionedIdeasManager() { + const { sessionId } = useParams<{ sessionId?: string }>() + const navigate = useNavigate() + const attachSessionEndedHandler = useSessionEndedHandler() + + const [instructorPasscode, setInstructorPasscode] = useState(null) + const [passcodeResolved, setPasscodeResolved] = useState(false) + + useEffect(() => { + if (!sessionId) return + const passcode = resolvePasscode(sessionId) + setInstructorPasscode(passcode) + setPasscodeResolved(true) + }, [sessionId]) + + const { snapshot, connect, disconnect } = useManagerSession({ + sessionId: sessionId ?? null, + instructorPasscode, + attachSessionEndedHandler, + }) + + useEffect(() => { + if (!sessionId || !instructorPasscode) return + connect() + return () => disconnect() + }, [sessionId, instructorPasscode, connect, disconnect]) + + const handleEndSession = async () => { + if (sessionId) { + await fetch(`/api/session/${sessionId}`, { method: 'DELETE' }) + } + void navigate('/manage') + } + + const phase = snapshot?.phase ?? 'registration' + + if (!sessionId) { + return
No active session.
+ } + + if (!passcodeResolved) { + return
Loading…
+ } + + if (!instructorPasscode) { + return ( +
+ Instructor passcode not found. Re-open from the session creation link. +
+ ) + } + + return ( +
+ { void handleEndSession() }} + /> + +
+ {!snapshot &&

Connecting…

} + + {snapshot && phase === 'registration' && ( + + )} + + {snapshot && phase === 'presentation' && ( +
+

Presentation phase — controls coming in Phase 4.

+
+ )} + + {snapshot && phase === 'voting' && ( +
+

Voting phase — ballot controls coming in Phase 5.

+
+ )} + + {snapshot && phase === 'results' && ( +
+

Results phase — podium reveal coming in Phase 6.

+
+ )} +
+
+ ) +} diff --git a/activities/commissioned-ideas/client/manager/RegistrationDashboard.tsx b/activities/commissioned-ideas/client/manager/RegistrationDashboard.tsx new file mode 100644 index 00000000..3f1639c5 --- /dev/null +++ b/activities/commissioned-ideas/client/manager/RegistrationDashboard.tsx @@ -0,0 +1,680 @@ +import { useCallback, useEffect, useState } from 'react' +import { QRCodeSVG } from 'qrcode.react' +import type { ClientTeam, ManagerParticipant, ManagerSnapshot } from '../hooks/useCommissionedIdeasSession.js' + +interface RegistrationDashboardProps { + sessionId: string + instructorPasscode: string + snapshot: ManagerSnapshot +} + +// ── API helpers ─────────────────────────────────────────────────────────────── + +function useApi(sessionId: string, instructorPasscode: string) { + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + const call = useCallback( + async (path: string, body: Record): Promise => { + setBusy(true) + setError(null) + try { + const res = await fetch(`/api/commissioned-ideas/${sessionId}/${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Commissioned-Ideas-Instructor-Passcode': instructorPasscode, + }, + body: JSON.stringify(body), + }) + const data = (await res.json()) as { error?: string } + if (!res.ok) { + setError(data.error ?? 'Request failed. Please try again.') + return false + } + return true + } catch { + setError('Network error — please try again.') + return false + } finally { + setBusy(false) + } + }, + [sessionId, instructorPasscode], + ) + + return { call, busy, error, clearError: () => setError(null) } +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export default function RegistrationDashboard({ + sessionId, + instructorPasscode, + snapshot, +}: RegistrationDashboardProps) { + const { call, busy, error, clearError } = useApi(sessionId, instructorPasscode) + + const joinUrl = + typeof window !== 'undefined' ? `${window.location.origin}/${sessionId}` : `/${sessionId}` + + const roster = Object.values(snapshot.participantRoster) + const teams = Object.values(snapshot.teams).sort((a, b) => a.registeredAt - b.registeredAt) + const ungrouped = roster.filter( + (p) => !p.rejectedByInstructor && p.teamId === null, + ) + const rejected = roster.filter((p) => p.rejectedByInstructor) + const connectedActive = roster.filter((p) => p.connected && !p.rejectedByInstructor).length + + // ── Settings ──────────────────────────────────────────────────────────────── + + const handleToggleLock = async (field: 'studentGroupingLocked' | 'namingLocked') => { + await call('settings', { [field]: !snapshot[field] }) + } + + const handleMaxTeamSize = async (value: number) => { + if (!Number.isInteger(value) || value < 2) return + await call('settings', { maxTeamSize: value }) + } + + const handleGroupingMode = async (mode: 'manual' | 'random') => { + await call('settings', { groupingMode: mode }) + } + + // ── Team actions ───────────────────────────────────────────────────────────── + + const handleAssignRandom = async () => { + await call('assign-random', {}) + } + + const handleAssignParticipant = async (participantId: string, teamId: string | null) => { + await call('assign-participant', { participantId, teamId }) + } + + return ( +
+ {/* Join link + QR code */} +
+

+ Student join link +

+
+
+ +
+
+ + {joinUrl} + + +
+
+
+ + {/* Summary counts */} +
+
+

+ {roster.filter((p) => !p.rejectedByInstructor).length} +

+

Registered

+
+
+

{connectedActive}

+

Online

+
+
+

{teams.length}

+

Teams

+
+
+

{ungrouped.length}

+

Ungrouped

+
+
+ + {/* Error banner */} + {error && ( +
+ {error} + +
+ )} + + {/* Settings panel */} + + + {/* Team roster */} + + + {/* Participant list (name moderation) */} + +
+ ) +} + +// ── Settings Panel ──────────────────────────────────────────────────────────── + +interface SettingsPanelProps { + snapshot: ManagerSnapshot + busy: boolean + onToggleLock: (field: 'studentGroupingLocked' | 'namingLocked') => void + onMaxTeamSize: (value: number) => void + onGroupingMode: (mode: 'manual' | 'random') => void + onAssignRandom: () => void +} + +function SettingsPanel({ + snapshot, + busy, + onToggleLock, + onMaxTeamSize, + onGroupingMode, + onAssignRandom, +}: SettingsPanelProps) { + const [maxInput, setMaxInput] = useState(String(snapshot.maxTeamSize)) + const serverMax = snapshot.maxTeamSize + + // When the server pushes a new maxTeamSize (e.g. another admin changed it), + // update the local input so it doesn't show a stale value. This runs only + // when serverMax actually changes, so it never interrupts the user mid-type. + useEffect(() => { + setMaxInput(String(serverMax)) + }, [serverMax]) + + const handleMaxBlur = () => { + const v = parseInt(maxInput, 10) + if (!Number.isNaN(v) && v >= 2) { + onMaxTeamSize(v) + } else { + setMaxInput(String(serverMax)) + } + } + + return ( +
+

Session settings

+ +
+ {/* Max team size */} +
+ +
+ + setMaxInput(e.target.value)} + onBlur={handleMaxBlur} + disabled={busy} + className="w-16 border border-gray-300 rounded-md px-2 py-1 text-sm text-center focus:outline-none focus:ring-2 focus:ring-amber-400 disabled:opacity-50" + /> + +
+
+ + {/* Grouping mode */} +
+

Grouping mode

+
+ + +
+
+
+ +
+ {/* Grouping lock */} + + + {/* Naming lock */} + + + {/* Assign random */} + {snapshot.groupingMode === 'random' && ( + + )} +
+
+ ) +} + +// ── Team Roster ─────────────────────────────────────────────────────────────── + +interface TeamRosterProps { + teams: ClientTeam[] + participantRoster: Record + studentGroupingLocked: boolean + busy: boolean + onAssignParticipant: (participantId: string, teamId: string | null) => void +} + +function TeamRoster({ + teams, + participantRoster, + studentGroupingLocked, + busy, + onAssignParticipant, +}: TeamRosterProps) { + const [assignTarget, setAssignTarget] = useState(null) + + const ungrouped = Object.values(participantRoster).filter( + (p) => !p.rejectedByInstructor && p.teamId === null, + ) + + const getParticipant = (id: string): ManagerParticipant | undefined => + participantRoster[id] + + if (teams.length === 0 && ungrouped.length === 0) return null + + return ( +
+
+

Teams

+
+ + {/* Teams */} + {teams.map((team, idx) => ( +
+

+ Team {idx + 1} + {team.groupName ? ` — ${team.groupName}` : ''} + ({team.memberIds.length} member{team.memberIds.length !== 1 ? 's' : ''}) +

+
    + {team.memberIds.map((memberId) => { + const p = getParticipant(memberId) + if (!p) return null + return ( +
  • + + {p.name} + +
  • + ) + })} +
+ + {/* Assign ungrouped to this team */} + {ungrouped.length > 0 && ( +
+ {assignTarget === team.id ? ( +
+ {ungrouped.map((p) => ( + + ))} + +
+ ) : ( + + )} +
+ )} +
+ ))} + + {/* Ungrouped */} + {ungrouped.length > 0 && ( +
+

Ungrouped ({ungrouped.length})

+
    + {ungrouped.map((p) => ( +
  • + + {p.name} + {studentGroupingLocked && teams.length > 0 && ( + + )} +
  • + ))} +
+
+ )} +
+ ) +} + +// ── Participant moderation (name edit / reject / approve) ───────────────────── + +interface ParticipantModerationPanelProps { + sessionId: string + instructorPasscode: string + roster: ManagerParticipant[] + rejected: ManagerParticipant[] +} + +interface EditState { + participantId: string + name: string +} + +function ParticipantModerationPanel({ + sessionId, + instructorPasscode, + roster, + rejected, +}: ParticipantModerationPanelProps) { + const { call, busy, error, clearError } = useApi(sessionId, instructorPasscode) + const [editState, setEditState] = useState(null) + + const callModeration = async (body: Record): Promise => { + return call('participant-name', body) + } + + const handleSaveEdit = async () => { + if (!editState) return + const ok = await callModeration({ participantId: editState.participantId, name: editState.name }) + if (ok) setEditState(null) + } + + const handleReject = async (participantId: string) => { + await callModeration({ participantId, rejected: true }) + } + + const handleApprove = async (participantId: string) => { + await callModeration({ participantId, rejected: false }) + } + + const active = roster.filter((p) => !p.rejectedByInstructor).sort((a, b) => a.name.localeCompare(b.name)) + const allParticipants = [...active, ...rejected.sort((a, b) => a.name.localeCompare(b.name))] + + if (allParticipants.length === 0) return null + + return ( +
+
+

Participants

+
+ + {error && ( +
+ {error} + +
+ )} + + {allParticipants.map((p) => ( + setEditState({ participantId: p.id, name: p.name })} + onEditNameChange={(name) => setEditState((s) => s ? { ...s, name } : null)} + onSaveEdit={() => { void handleSaveEdit() }} + onCancelEdit={() => { setEditState(null); clearError() }} + onReject={() => { void handleReject(p.id) }} + onApprove={() => { void handleApprove(p.id) }} + /> + ))} +
+ ) +} + +interface ParticipantRowProps { + participant: ManagerParticipant + editState: EditState | null + saving: boolean + onStartEdit: () => void + onEditNameChange: (name: string) => void + onSaveEdit: () => void + onCancelEdit: () => void + onReject: () => void + onApprove: () => void +} + +function ParticipantRow({ + participant, + editState, + saving, + onStartEdit, + onEditNameChange, + onSaveEdit, + onCancelEdit, + onReject, + onApprove, +}: ParticipantRowProps) { + const { id, name, connected, rejectedByInstructor } = participant + const isEditing = editState !== null + + return ( +
+ + +
+ {isEditing ? ( + onEditNameChange(e.target.value)} + maxLength={100} + autoFocus + disabled={saving} + aria-label="Edit participant name" + className="w-full border border-amber-400 rounded-md px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 disabled:opacity-50" + onKeyDown={(e) => { + if (e.key === 'Enter') onSaveEdit() + if (e.key === 'Escape') onCancelEdit() + }} + /> + ) : ( + + {name} + + )} + {id} +
+ +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + {rejectedByInstructor ? ( + + ) : ( + + )} + + )} +
+
+ ) +} diff --git a/activities/commissioned-ideas/client/student/CommissionedIdeasStudent.tsx b/activities/commissioned-ideas/client/student/CommissionedIdeasStudent.tsx new file mode 100644 index 00000000..4253d2b3 --- /dev/null +++ b/activities/commissioned-ideas/client/student/CommissionedIdeasStudent.tsx @@ -0,0 +1,170 @@ +import { useEffect, useRef, useState } from 'react' +import { + persistSessionParticipantIdentity, + resolveInitialEntryParticipantIdentity, +} from '@src/components/common/entryParticipantIdentityUtils' +import { useSessionEndedHandler } from '@src/hooks/useSessionEndedHandler' +import { useStudentSession } from '../hooks/useCommissionedIdeasSession' +import RegistrationForm from './RegistrationForm' +import StudentRoster from './StudentRoster' + +interface SessionData { + sessionId?: string + data?: Record +} + +/** localStorage key for the activity-specific participant token. */ +function tokenKey(sessionId: string): string { + return `ci:${sessionId}:token` +} + +export default function CommissionedIdeasStudent({ sessionData }: { sessionData: SessionData }) { + const sessionId = sessionData?.sessionId ?? null + const attachSessionEndedHandler = useSessionEndedHandler() + const mountedRef = useRef(true) + + // ── Identity state ────────────────────────────────────────────────────────── + const [studentName, setStudentName] = useState('') + const [studentId, setStudentId] = useState(null) + const [participantToken, setParticipantToken] = useState(null) + const [nameSubmitted, setNameSubmitted] = useState(false) + const [registered, setRegistered] = useState(false) + const [identityResolved, setIdentityResolved] = useState(false) + + // ── Resolve stored identity from localStorage (reconnect support) ─────────── + useEffect(() => { + if (!sessionId) return + mountedRef.current = true + + void (async () => { + try { + const identity = await resolveInitialEntryParticipantIdentity({ + activityName: 'commissioned-ideas', + sessionId, + isSoloSession: false, + localStorage: window.localStorage, + sessionStorage: window.sessionStorage, + }) + if (!mountedRef.current) return + setStudentName(identity.studentName) + setStudentId(identity.studentId) + setNameSubmitted(identity.nameSubmitted) + // Restore the participant token so team actions remain authenticated. + // If the token is absent (cleared storage, new device), leave registered=false + // so the form re-appears with the pre-filled name. Submitting it calls + // register-participant with the stored participantId, which returns the same + // token from the server and restores full team-action capability. + if (identity.studentId) { + const stored = window.localStorage.getItem(tokenKey(sessionId)) + if (stored) { + setParticipantToken(stored) + setRegistered(true) + } + } + } catch { + // non-fatal — fall through to RegistrationForm + } finally { + if (mountedRef.current) setIdentityResolved(true) + } + })() + + return () => { + mountedRef.current = false + } + }, [sessionId]) + + // ── WS connection (only after registered) ─────────────────────────────────── + const { snapshot, connect, disconnect } = useStudentSession({ + sessionId: registered ? sessionId : null, + participantId: studentId, + attachSessionEndedHandler, + }) + + useEffect(() => { + if (!registered || !sessionId) return + connect() + return () => disconnect() + }, [registered, sessionId, connect, disconnect]) + + // ── Handlers ──────────────────────────────────────────────────────────────── + const handleRegistered = (participantId: string, name: string, token: string) => { + setStudentId(participantId) + setStudentName(name) + setParticipantToken(token) + setRegistered(true) + if (sessionId) { + persistSessionParticipantIdentity(window.localStorage, sessionId, name, participantId) + window.localStorage.setItem(tokenKey(sessionId), token) + } + } + + // ── Render ────────────────────────────────────────────────────────────────── + if (!sessionId) { + return
Loading session…
+ } + + if (!identityResolved) { + return
Loading…
+ } + + if (!nameSubmitted || !registered) { + return ( + + ) + } + + const phase = snapshot?.phase ?? 'registration' + const participants = snapshot?.participantRoster ?? {} + const teams = snapshot?.teams ?? {} + const studentGroupingLocked = snapshot?.studentGroupingLocked ?? false + const groupingMode = snapshot?.groupingMode ?? 'manual' + + return ( +
+
+

Commissioned Ideas

+

+ Joined as {studentName} +

+
+ +
+ {phase === 'registration' && ( + + )} + + {phase === 'presentation' && ( +
+

Presentations are underway.

+

Your instructor will open voting when ready.

+
+ )} + + {phase === 'voting' && ( +
+

Voting is open — ballot coming in Phase 6.

+
+ )} + + {phase === 'results' && ( +
+

Results are being revealed — podium coming in Phase 7.

+
+ )} +
+
+ ) +} diff --git a/activities/commissioned-ideas/client/student/RegistrationForm.tsx b/activities/commissioned-ideas/client/student/RegistrationForm.tsx new file mode 100644 index 00000000..4f3b3bd5 --- /dev/null +++ b/activities/commissioned-ideas/client/student/RegistrationForm.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react' +import Button from '@src/components/ui/Button' + +interface RegistrationFormProps { + sessionId: string + initialName?: string + initialParticipantId?: string | null + onRegistered: (participantId: string, name: string, token: string) => void +} + +export default function RegistrationForm({ + sessionId, + initialName = '', + initialParticipantId, + onRegistered, +}: RegistrationFormProps) { + const [name, setName] = useState(initialName) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + const trimmed = name.trim() + if (!trimmed) return + + setSubmitting(true) + setError(null) + + try { + const res = await fetch(`/api/commissioned-ideas/${sessionId}/register-participant`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: trimmed, participantId: initialParticipantId }), + }) + const data = (await res.json()) as { participantId?: string; name?: string; token?: string; error?: string } + + if (!res.ok || !data.participantId || !data.token) { + setError(data.error ?? 'Could not join. Please try again.') + return + } + + onRegistered(data.participantId, data.name ?? trimmed, data.token) + } catch { + setError('Network error — could not join session') + } finally { + setSubmitting(false) + } + } + + return ( +
+
+

Join Session

+

Enter your name to join the activity.

+ +
{ void handleSubmit(e) }} noValidate> + + setName(e.target.value)} + maxLength={100} + autoFocus + autoComplete="off" + disabled={submitting} + aria-describedby={error ? 'ci-name-error' : undefined} + aria-invalid={Boolean(error)} + className="w-full border border-gray-300 rounded-lg px-4 py-2 text-base focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent disabled:opacity-50 mb-4" + placeholder="First Last" + /> + + {error && ( + + )} + + +
+
+
+ ) +} diff --git a/activities/commissioned-ideas/client/student/StudentRoster.tsx b/activities/commissioned-ideas/client/student/StudentRoster.tsx new file mode 100644 index 00000000..a9e5dd3d --- /dev/null +++ b/activities/commissioned-ideas/client/student/StudentRoster.tsx @@ -0,0 +1,252 @@ +import { useState } from 'react' +import type { ClientTeam } from '../hooks/useCommissionedIdeasSession.js' +import type { StudentSafeParticipant } from '../../shared/types.js' + +interface StudentRosterProps { + participants: Record + teams: Record + myParticipantId: string + participantToken: string + sessionId: string + studentGroupingLocked: boolean + groupingMode: string +} + +export default function StudentRoster({ + participants, + teams, + myParticipantId, + participantToken, + sessionId, + studentGroupingLocked, + groupingMode, +}: StudentRosterProps) { + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const me = participants[myParticipantId] + const myTeamId = me?.teamId ?? null + + const teamList = Object.values(teams).sort((a, b) => a.registeredAt - b.registeredAt) + const ungrouped = Object.values(participants) + .filter((p) => p.teamId === null) + .sort((a, b) => a.name.localeCompare(b.name)) + + // ── API helpers ───────────────────────────────────────────────────────────── + + const post = async (path: string, body: Record) => { + setBusy(true) + setError(null) + try { + const res = await fetch(`/api/commissioned-ideas/${sessionId}/${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Commissioned-Ideas-Participant-Token': participantToken, + }, + body: JSON.stringify({ ...body, participantId: myParticipantId }), + }) + const data = (await res.json()) as { error?: string } + if (!res.ok) { + setError(data.error ?? 'Something went wrong. Please try again.') + } + } catch { + setError('Network error — please try again.') + } finally { + setBusy(false) + } + } + + const handleCreateTeam = () => { void post('create-team', {}) } + const handleJoinTeam = (teamId: string) => { void post('join-team', { teamId }) } + const handleLeaveTeam = () => { void post('leave-team', {}) } + + const isManual = groupingMode === 'manual' + + return ( +
+ {error && ( +
+ {error} + +
+ )} + + {/* My status */} + + + {/* Random mode waiting message */} + {!isManual && myTeamId === null && studentGroupingLocked && ( +

+ Your instructor will assign you to a team. +

+ )} + + {/* Teams */} + {teamList.length > 0 && ( +
+

+ Teams ({teamList.length}) +

+
+ {teamList.map((team, idx) => { + const members = team.memberIds + .map((id) => participants[id]) + .filter((p): p is StudentSafeParticipant => p !== undefined) + .sort((a, b) => a.name.localeCompare(b.name)) + const isMine = team.id === myTeamId + const memberCount = members.length + const canJoin = isManual && !isMine && myTeamId === null && !studentGroupingLocked + + return ( +
+
+

+ Team {idx + 1} + {team.groupName ? ` — ${team.groupName}` : ''} + ({memberCount}) +

+ {canJoin && ( + + )} + {isMine && ( + Your team + )} +
+
    + {members.map((p) => ( +
  • + {p.name} + {p.id === myParticipantId && ( + (you) + )} +
  • + ))} +
+
+ ) + })} +
+
+ )} + + {/* Ungrouped students */} + {ungrouped.length > 0 && ( +
+

+ {isManual && myTeamId === null && !studentGroupingLocked + ? 'Ungrouped — create a team to invite classmates' + : `Ungrouped (${ungrouped.length})`} +

+
    + {ungrouped.map((p) => { + const isMe = p.id === myParticipantId + return ( +
  • + + {p.name} + {isMe && (you)} + +
  • + ) + })} +
+
+ )} + + {/* Create team */} + {isManual && myTeamId === null && !studentGroupingLocked && ( + + )} + + {ungrouped.length === 0 && teamList.length === 0 && ( +

Waiting for classmates to join…

+ )} +
+ ) +} + +// ── My team status strip ────────────────────────────────────────────────────── + +interface MyTeamStatusProps { + myTeamId: string | null + teams: Record + participants: Record + studentGroupingLocked: boolean + busy: boolean + onLeave: () => void +} + +function MyTeamStatus({ + myTeamId, + teams, + participants, + studentGroupingLocked, + busy, + onLeave, +}: MyTeamStatusProps) { + if (myTeamId === null) return null + + const team = teams[myTeamId] + if (!team) return null + + const members = team.memberIds + .map((id) => participants[id]) + .filter((p): p is StudentSafeParticipant => p !== undefined) + const names = members.map((p) => p.name).join(', ') + + return ( +
+
+

+ {team.groupName ?? 'Your team'} +

+

{names}

+
+ {!studentGroupingLocked && ( + + )} +
+ ) +} diff --git a/activities/commissioned-ideas/server/routeHandlers.test.ts b/activities/commissioned-ideas/server/routeHandlers.test.ts new file mode 100644 index 00000000..50616cae --- /dev/null +++ b/activities/commissioned-ideas/server/routeHandlers.test.ts @@ -0,0 +1,1357 @@ +/** + * End-to-end handler tests for commissioned-ideas routes. + * Uses the same mock harness pattern as algorithm-demo and resonance: + * a fake app/sessions/ws triple is wired to setupCommissionedIdeasRoutes, + * then individual handlers are invoked directly and the store is inspected. + */ +import test from 'node:test' +import assert from 'node:assert/strict' +import type { SessionRecord } from 'activebits-server/core/sessions.js' +import type { WsRouter } from '../../../types/websocket.js' +import setupCommissionedIdeasRoutes from './routes.js' +import type { CommissionedIdeasSessionData } from '../shared/types.js' + +// ── Mock infrastructure ─────────────────────────────────────────────────────── + +type RouteHandler = (req: MockRequest, res: MockResponse) => Promise | void + +interface MockRequest { + params: Record + query?: Record + body?: unknown + headers?: Record +} + +interface MockResponse { + statusCode: number + body: unknown + status(code: number): MockResponse + json(payload: unknown): MockResponse +} + +function createResponse(): MockResponse { + const res: MockResponse = { + statusCode: 200, + body: null, + status(code: number) { + res.statusCode = code + return res + }, + json(payload: unknown) { + res.body = payload + return res + }, + } + return res +} + +function createMockApp() { + const handlers: { post: Record; get: Record } = { + post: {}, + get: {}, + } + return { + handlers, + post(path: string, handler: RouteHandler) { + handlers.post[path] = handler + }, + get(path: string, handler: RouteHandler) { + handlers.get[path] = handler + }, + } +} + +function createMockWs(): WsRouter { + return { + wss: { clients: new Set(), close() {} }, + register() {}, + } +} + +function createMockSessions(initial: Record = {}) { + const store: Record = { ...initial } + + return { + store, + sessions: { + async get(id: string) { + return store[id] ?? null + }, + async set(id: string, session: SessionRecord) { + store[id] = session + }, + async delete(id: string) { + const had = id in store + delete store[id] + return had + }, + async touch(_id: string) { return true }, + async getAll() { return Object.values(store) }, + async getAllIds() { return Object.keys(store) }, + cleanup() {}, + async close() {}, + subscribeToBroadcast() {}, + }, + } +} + +function createRequest( + params: Record = {}, + body: unknown = {}, + headers: Record = {}, +): MockRequest { + return { params, body, headers } +} + +function withPasscode( + params: Record, + body: unknown, + passcode: string, +): MockRequest { + return createRequest(params, body, { 'x-commissioned-ideas-instructor-passcode': passcode }) +} + +function withParticipantToken( + params: Record, + body: unknown, + token: string, +): MockRequest { + return createRequest(params, body, { 'x-commissioned-ideas-participant-token': token }) +} + +function sessionData(store: Record, id: string): CommissionedIdeasSessionData { + const session = store[id] + assert.ok(session, `Session ${id} not found in store`) + return session.data as CommissionedIdeasSessionData +} + +// ── Helper: set up routes and pull a named handler ──────────────────────────── + +function setupAndGet( + method: 'post' | 'get', + path: string, + initial: Record = {}, +) { + const app = createMockApp() + const ws = createMockWs() + const { store, sessions } = createMockSessions(initial) + setupCommissionedIdeasRoutes(app, sessions, ws) + const handler = app.handlers[method][path] + assert.ok(handler, `Handler not registered: ${method.toUpperCase()} ${path}`) + return { handler, store, sessions } +} + +// ── POST /api/commissioned-ideas/create ────────────────────────────────────── + +void test('create route returns 200 with a session id', async () => { + const { handler } = setupAndGet('post', '/api/commissioned-ideas/create') + + const res = createResponse() + await handler(createRequest(), res) + + assert.equal(res.statusCode, 200) + const body = res.body as { id?: string } + assert.equal(typeof body.id, 'string') + assert.ok(body.id) +}) + +void test('create route persists a commissioned-ideas session in the store', async () => { + const { handler, store } = setupAndGet('post', '/api/commissioned-ideas/create') + + const res = createResponse() + await handler(createRequest(), res) + + const id = (res.body as { id: string }).id + const session = store[id] + assert.ok(session, 'Session was stored') + assert.equal(session.type, 'commissioned-ideas') +}) + +void test('create route initializes default session data', async () => { + const { handler, store } = setupAndGet('post', '/api/commissioned-ideas/create') + + const res = createResponse() + await handler(createRequest(), res) + + const id = (res.body as { id: string }).id + const data = sessionData(store, id) + + assert.equal(data.phase, 'registration') + assert.equal(data.studentGroupingLocked, false) + assert.equal(data.namingLocked, false) + assert.equal(data.maxTeamSize, 4) + assert.equal(data.groupingMode, 'manual') + assert.equal(data.presentationRound, 1) + assert.equal(data.allowLateRegistration, true) + assert.deepEqual(data.teams, {}) + assert.deepEqual(data.participantRoster, {}) + assert.deepEqual(data.ballots, {}) + assert.deepEqual(data.presentationHistory, []) + assert.equal(data.currentPresentationTeamId, null) + assert.equal(data.podiumRevealStep, 'hidden') +}) + +void test('create route generates a unique id on each call', async () => { + const { handler } = setupAndGet('post', '/api/commissioned-ideas/create') + + const res1 = createResponse() + const res2 = createResponse() + await handler(createRequest(), res1) + await handler(createRequest(), res2) + + const id1 = (res1.body as { id: string }).id + const id2 = (res2.body as { id: string }).id + assert.notEqual(id1, id2) +}) + +// ── GET /api/commissioned-ideas/:sessionId/state ────────────────────────────── + +void test('state route returns 400 when sessionId param is missing', async () => { + const { handler } = setupAndGet('get', '/api/commissioned-ideas/:sessionId/state') + + const res = createResponse() + await handler(createRequest({ sessionId: undefined }), res) + + assert.equal(res.statusCode, 400) + assert.deepEqual(res.body, { error: 'Missing sessionId' }) +}) + +void test('state route returns 404 for an unknown sessionId', async () => { + const { handler } = setupAndGet('get', '/api/commissioned-ideas/:sessionId/state') + + const res = createResponse() + await handler(createRequest({ sessionId: 'does-not-exist' }), res) + + assert.equal(res.statusCode, 404) + assert.deepEqual(res.body, { error: 'Session not found' }) +}) + +void test('state route returns 404 for a session of a different activity type', async () => { + const foreignSession: SessionRecord = { + id: 'wrong-type', + type: 'raffle', + created: Date.now(), + lastActivity: Date.now(), + data: { tickets: [] }, + } + const { handler } = setupAndGet( + 'get', + '/api/commissioned-ideas/:sessionId/state', + { 'wrong-type': foreignSession }, + ) + + const res = createResponse() + await handler(createRequest({ sessionId: 'wrong-type' }), res) + + assert.equal(res.statusCode, 404) +}) + +void test('state route returns 200 with sessionId and data for a valid session', async () => { + // First create a session so we have a real id + const { handler: create, store, sessions } = setupAndGet('post', '/api/commissioned-ideas/create') + const app = createMockApp() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + + const createRes = createResponse() + await create(createRequest(), createRes) + const id = (createRes.body as { id: string }).id + + const stateHandler = app.handlers.get['/api/commissioned-ideas/:sessionId/state'] + assert.ok(stateHandler) + const stateRes = createResponse() + await stateHandler(createRequest({ sessionId: id }), stateRes) + + assert.equal(stateRes.statusCode, 200) + const body = stateRes.body as { sessionId: string; data: Record } + assert.equal(body.sessionId, id) + assert.notEqual(body.data, null) + assert.equal(body.data.phase, 'registration') + + void store +}) + +void test('state route snapshot does not include instructorPasscode', async () => { + const app = createMockApp() + const ws = createMockWs() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, ws) + + const createRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/create']!(createRequest(), createRes) + const id = (createRes.body as { id: string }).id + + const stateRes = createResponse() + await app.handlers.get['/api/commissioned-ideas/:sessionId/state']!( + createRequest({ sessionId: id }), + stateRes, + ) + + const data = (stateRes.body as { data: Record }).data + assert.equal('instructorPasscode' in data, false, 'instructorPasscode must not appear in student snapshot') +}) + +void test('state route snapshot does not include raw ballots field', async () => { + const app = createMockApp() + const ws = createMockWs() + const { store: _store, sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, ws) + + const createRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/create']!(createRequest(), createRes) + const id = (createRes.body as { id: string }).id + + const stateRes = createResponse() + await app.handlers.get['/api/commissioned-ideas/:sessionId/state']!( + createRequest({ sessionId: id }), + stateRes, + ) + + const data = (stateRes.body as { data: Record }).data + assert.equal('ballots' in data, false, 'raw ballots must not appear in student snapshot') + assert.equal(typeof data.ballotsReceived, 'number') +}) + +void test('state route never returns myBallot even when participantId query param is supplied', async () => { + const app = createMockApp() + const ws = createMockWs() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, ws) + + const createRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/create']!(createRequest(), createRes) + const sessionId = (createRes.body as { id: string }).id + + // Register a participant so there is a real id to attempt to look up + const regRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/register-participant']!( + createRequest({ sessionId }, { name: 'Alice' }), + regRes, + ) + const participantId = (regRes.body as { participantId: string }).participantId + + // Request state with the participant's own id — ballot data must still be absent + const stateHandler = app.handlers.get['/api/commissioned-ideas/:sessionId/state']! + const stateRes = createResponse() + await stateHandler( + { params: { sessionId }, query: { participantId }, body: {} }, + stateRes, + ) + + assert.equal(stateRes.statusCode, 200) + const data = (stateRes.body as { data: Record }).data + assert.equal(data.myBallot, null, 'myBallot must always be null from the REST route') + assert.equal(data.ballotSubmitted, false) +}) + +void test('state route snapshot participantRoster omits instructor-only fields', async () => { + const now = Date.now() + const sessionWithParticipants: SessionRecord = { + id: 'sess-participants', + type: 'commissioned-ideas', + created: now, + lastActivity: now, + data: { + phase: 'registration', + studentGroupingLocked: false, + namingLocked: false, + maxTeamSize: 4, + groupingMode: 'manual', + presentationRound: 1, + allowLateRegistration: true, + teams: {}, + participantRoster: { + p1: { + id: 'p1', + name: 'Alice', + teamId: null, + connected: true, + lastSeen: 12345, + rejectedByInstructor: false, + }, + }, + ballots: {}, + presentationHistory: [], + currentPresentationTeamId: null, + podiumRevealStep: 'hidden', + }, + } + + const { handler } = setupAndGet( + 'get', + '/api/commissioned-ideas/:sessionId/state', + { 'sess-participants': sessionWithParticipants }, + ) + + const res = createResponse() + await handler(createRequest({ sessionId: 'sess-participants' }), res) + + assert.equal(res.statusCode, 200) + const data = (res.body as { data: Record }).data + const roster = data.participantRoster as Record> + const p1 = roster['p1'] + assert.ok(p1, 'p1 should be present') + assert.equal('connected' in p1, false) + assert.equal('lastSeen' in p1, false) + assert.equal('rejectedByInstructor' in p1, false) + assert.equal('token' in p1, false, 'token must never appear in student snapshot') + assert.equal(p1.id, 'p1') + assert.equal(p1.name, 'Alice') + assert.equal(p1.teamId, null) +}) + +void test('state route snapshot omits rejected participants', async () => { + const now = Date.now() + const sessionWithRejected: SessionRecord = { + id: 'sess-rejected', + type: 'commissioned-ideas', + created: now, + lastActivity: now, + data: { + phase: 'registration', + studentGroupingLocked: false, + namingLocked: false, + maxTeamSize: 4, + groupingMode: 'manual', + presentationRound: 1, + allowLateRegistration: true, + teams: {}, + participantRoster: { + approved: { + id: 'approved', + name: 'Alice', + teamId: null, + connected: true, + lastSeen: 0, + rejectedByInstructor: false, + }, + rejected: { + id: 'rejected', + name: 'BadName', + teamId: null, + connected: false, + lastSeen: 0, + rejectedByInstructor: true, + }, + }, + ballots: {}, + presentationHistory: [], + currentPresentationTeamId: null, + podiumRevealStep: 'hidden', + }, + } + + const { handler } = setupAndGet( + 'get', + '/api/commissioned-ideas/:sessionId/state', + { 'sess-rejected': sessionWithRejected }, + ) + + const res = createResponse() + await handler(createRequest({ sessionId: 'sess-rejected' }), res) + + assert.equal(res.statusCode, 200) + const data = (res.body as { data: Record }).data + const roster = data.participantRoster as Record + assert.ok('approved' in roster, 'approved participant should be visible') + assert.equal('rejected' in roster, false, 'rejected participant must not appear in student snapshot') +}) + +void test('state route snapshot preserves team groupName from persisted value when no proposals', async () => { + const now = Date.now() + const sessionWithTeam: SessionRecord = { + id: 'sess-team-name', + type: 'commissioned-ideas', + created: now, + lastActivity: now, + data: { + phase: 'registration', + studentGroupingLocked: false, + namingLocked: false, + maxTeamSize: 4, + groupingMode: 'manual', + presentationRound: 1, + allowLateRegistration: true, + teams: { + t1: { + id: 't1', + groupName: 'The Finalists', + projectName: 'Fin Tracker', + registeredAt: now, + presenterOrder: null, + locked: false, + memberIds: [], + proposedGroupNames: [], + proposedProjectNames: [], + groupNameVotes: {}, + projectNameVotes: {}, + }, + }, + participantRoster: {}, + ballots: {}, + presentationHistory: [], + currentPresentationTeamId: null, + podiumRevealStep: 'hidden', + }, + } + + const { handler } = setupAndGet( + 'get', + '/api/commissioned-ideas/:sessionId/state', + { 'sess-team-name': sessionWithTeam }, + ) + + const res = createResponse() + await handler(createRequest({ sessionId: 'sess-team-name' }), res) + + assert.equal(res.statusCode, 200) + const data = (res.body as { data: Record }).data + const teams = data.teams as Record> + assert.equal(teams['t1']?.groupName, 'The Finalists') + assert.equal(teams['t1']?.projectName, 'Fin Tracker') +}) + +// ── POST /api/commissioned-ideas/:sessionId/register-participant ────────────── + +void test('register-participant returns 400 when sessionId is missing', async () => { + const { handler } = setupAndGet( + 'post', + '/api/commissioned-ideas/:sessionId/register-participant', + ) + + const res = createResponse() + await handler(createRequest({ sessionId: undefined }, { name: 'Alice' }), res) + + assert.equal(res.statusCode, 400) +}) + +void test('register-participant returns 404 for unknown session', async () => { + const { handler } = setupAndGet( + 'post', + '/api/commissioned-ideas/:sessionId/register-participant', + ) + + const res = createResponse() + await handler(createRequest({ sessionId: 'no-such-session' }, { name: 'Alice' }), res) + + assert.equal(res.statusCode, 404) +}) + +void test('register-participant returns 400 when name is missing', async () => { + const app = createMockApp() + const ws = createMockWs() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, ws) + + // Create a session first + const createRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/create']!(createRequest(), createRes) + const sessionId = (createRes.body as { id: string }).id + + const handler = app.handlers.post['/api/commissioned-ideas/:sessionId/register-participant']! + const res = createResponse() + await handler(createRequest({ sessionId }, { name: '' }), res) + + assert.equal(res.statusCode, 400) + assert.deepEqual(res.body, { error: 'name is required' }) +}) + +void test('register-participant creates a new participant and returns id + name + token', async () => { + const app = createMockApp() + const ws = createMockWs() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, ws) + + const createRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/create']!(createRequest(), createRes) + const sessionId = (createRes.body as { id: string }).id + + const handler = app.handlers.post['/api/commissioned-ideas/:sessionId/register-participant']! + const res = createResponse() + await handler(createRequest({ sessionId }, { name: 'Alice' }), res) + + assert.equal(res.statusCode, 200) + const body = res.body as { participantId: string; name: string; token: string } + assert.equal(typeof body.participantId, 'string') + assert.ok(body.participantId.length > 0) + assert.equal(body.name, 'Alice') + assert.equal(typeof body.token, 'string', 'token must be returned for student auth') + assert.ok(body.token.length > 0) +}) + +void test('register-participant stores participant in session roster', async () => { + const app = createMockApp() + const ws = createMockWs() + const { store, sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, ws) + + const createRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/create']!(createRequest(), createRes) + const sessionId = (createRes.body as { id: string }).id + + const handler = app.handlers.post['/api/commissioned-ideas/:sessionId/register-participant']! + const regRes = createResponse() + await handler(createRequest({ sessionId }, { name: 'Bob' }), regRes) + + const body = regRes.body as { participantId: string } + const data = sessionData(store, sessionId) + const participant = data.participantRoster[body.participantId] + assert.ok(participant, 'participant must exist in roster') + assert.equal(participant.name, 'Bob') + assert.equal(participant.teamId, null) + assert.equal(participant.rejectedByInstructor, false) + assert.equal(participant.connected, true) +}) + +void test('register-participant reconnects an existing participant by id', async () => { + const app = createMockApp() + const ws = createMockWs() + const { store, sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, ws) + + const createRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/create']!(createRequest(), createRes) + const sessionId = (createRes.body as { id: string }).id + + const handler = app.handlers.post['/api/commissioned-ideas/:sessionId/register-participant']! + + // First registration + const firstRes = createResponse() + await handler(createRequest({ sessionId }, { name: 'Carol' }), firstRes) + const firstId = (firstRes.body as { participantId: string }).participantId + + // Reconnect with same id + const reconnectRes = createResponse() + await handler( + createRequest({ sessionId }, { name: 'Carol Updated', participantId: firstId }), + reconnectRes, + ) + + assert.equal(reconnectRes.statusCode, 200) + const reconnectBody = reconnectRes.body as { participantId: string; name: string } + assert.equal(reconnectBody.participantId, firstId, 'same id returned on reconnect') + + // Only one participant in roster + const data = sessionData(store, sessionId) + assert.equal(Object.keys(data.participantRoster).length, 1) +}) + +void test('register-participant reconnect does not overwrite instructor-moderated name', async () => { + const app = createMockApp() + const ws = createMockWs() + const { store, sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, ws) + + const createRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/create']!(createRequest(), createRes) + const sessionId = (createRes.body as { id: string }).id + const passcode = (createRes.body as { instructorPasscode: string }).instructorPasscode + + const regHandler = app.handlers.post['/api/commissioned-ideas/:sessionId/register-participant']! + const moderateHandler = app.handlers.post['/api/commissioned-ideas/:sessionId/participant-name']! + + // Student registers with a typo + const regRes = createResponse() + await regHandler(createRequest({ sessionId }, { name: 'bbo' }), regRes) + const participantId = (regRes.body as { participantId: string }).participantId + + // Instructor corrects the name + await moderateHandler(withPasscode({ sessionId }, { participantId, name: 'Bob' }, passcode), createResponse()) + + // Student reconnects with stale local storage name + await regHandler(createRequest({ sessionId }, { name: 'bbo', participantId }), createResponse()) + + const data = sessionData(store, sessionId) + assert.equal( + data.participantRoster[participantId]?.name, + 'Bob', + 'instructor-corrected name must survive reconnect', + ) +}) + +// ── POST /api/commissioned-ideas/:sessionId/participant-name ────────────────── + +void test('participant-name returns 400 when sessionId is missing', async () => { + const { handler } = setupAndGet( + 'post', + '/api/commissioned-ideas/:sessionId/participant-name', + ) + + const res = createResponse() + await handler( + createRequest({ sessionId: undefined }, { participantId: 'p1', name: 'Alice' }), + res, + ) + + assert.equal(res.statusCode, 400) +}) + +void test('participant-name returns 404 for unknown session', async () => { + const { handler } = setupAndGet( + 'post', + '/api/commissioned-ideas/:sessionId/participant-name', + ) + + const res = createResponse() + await handler( + createRequest({ sessionId: 'no-session' }, { participantId: 'p1', name: 'Alice' }), + res, + ) + + assert.equal(res.statusCode, 404) +}) + +void test('participant-name returns 403 when instructor passcode is absent', async () => { + const app = createMockApp() + const ws = createMockWs() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, ws) + + const createRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/create']!(createRequest(), createRes) + const sessionId = (createRes.body as { id: string }).id + + const handler = app.handlers.post['/api/commissioned-ideas/:sessionId/participant-name']! + const res = createResponse() + await handler(createRequest({ sessionId }, { participantId: 'p1', name: 'Alice' }), res) + + assert.equal(res.statusCode, 403) + assert.deepEqual(res.body, { error: 'Instructor authentication required' }) +}) + +void test('participant-name returns 403 when instructor passcode is wrong', async () => { + const app = createMockApp() + const ws = createMockWs() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, ws) + + const createRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/create']!(createRequest(), createRes) + const sessionId = (createRes.body as { id: string }).id + + const handler = app.handlers.post['/api/commissioned-ideas/:sessionId/participant-name']! + const res = createResponse() + await handler( + withPasscode({ sessionId }, { participantId: 'p1', name: 'Alice' }, 'WRONGPASSCODE'), + res, + ) + + assert.equal(res.statusCode, 403) +}) + +void test('participant-name returns 400 when participantId is missing from body', async () => { + const app = createMockApp() + const ws = createMockWs() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, ws) + + const createRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/create']!(createRequest(), createRes) + const sessionId = (createRes.body as { id: string }).id + const passcode = (createRes.body as { instructorPasscode: string }).instructorPasscode + + const handler = app.handlers.post['/api/commissioned-ideas/:sessionId/participant-name']! + const res = createResponse() + await handler(withPasscode({ sessionId }, { name: 'Alice' }, passcode), res) + + assert.equal(res.statusCode, 400) + assert.deepEqual(res.body, { error: 'participantId is required' }) +}) + +void test('participant-name returns 404 when participant does not exist', async () => { + const app = createMockApp() + const ws = createMockWs() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, ws) + + const createRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/create']!(createRequest(), createRes) + const sessionId = (createRes.body as { id: string }).id + const passcode = (createRes.body as { instructorPasscode: string }).instructorPasscode + + const handler = app.handlers.post['/api/commissioned-ideas/:sessionId/participant-name']! + const res = createResponse() + await handler(withPasscode({ sessionId }, { participantId: 'ghost', name: 'Ghost' }, passcode), res) + + assert.equal(res.statusCode, 404) +}) + +void test('participant-name edits a name and clears rejection', async () => { + const app = createMockApp() + const ws = createMockWs() + const { store, sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, ws) + + const createRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/create']!(createRequest(), createRes) + const sessionId = (createRes.body as { id: string }).id + const passcode = (createRes.body as { instructorPasscode: string }).instructorPasscode + + const regHandler = app.handlers.post['/api/commissioned-ideas/:sessionId/register-participant']! + const regRes = createResponse() + await regHandler(createRequest({ sessionId }, { name: 'BadName' }), regRes) + const participantId = (regRes.body as { participantId: string }).participantId + + // Reject them first + const moderateHandler = app.handlers.post['/api/commissioned-ideas/:sessionId/participant-name']! + await moderateHandler(withPasscode({ sessionId }, { participantId, rejected: true }, passcode), createResponse()) + + let data = sessionData(store, sessionId) + assert.equal(data.participantRoster[participantId]?.rejectedByInstructor, true) + + // Edit name — should clear rejection + const editRes = createResponse() + await moderateHandler(withPasscode({ sessionId }, { participantId, name: 'GoodName' }, passcode), editRes) + + assert.equal(editRes.statusCode, 200) + assert.deepEqual(editRes.body, { ok: true }) + + data = sessionData(store, sessionId) + assert.equal(data.participantRoster[participantId]?.name, 'GoodName') + assert.equal(data.participantRoster[participantId]?.rejectedByInstructor, false) +}) + +void test('participant-name can reject a participant', async () => { + const app = createMockApp() + const ws = createMockWs() + const { store, sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, ws) + + const createRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/create']!(createRequest(), createRes) + const sessionId = (createRes.body as { id: string }).id + const passcode = (createRes.body as { instructorPasscode: string }).instructorPasscode + + const regHandler = app.handlers.post['/api/commissioned-ideas/:sessionId/register-participant']! + const regRes = createResponse() + await regHandler(createRequest({ sessionId }, { name: 'SlightlyBad' }), regRes) + const participantId = (regRes.body as { participantId: string }).participantId + + const moderateHandler = app.handlers.post['/api/commissioned-ideas/:sessionId/participant-name']! + const rejectRes = createResponse() + await moderateHandler(withPasscode({ sessionId }, { participantId, rejected: true }, passcode), rejectRes) + + assert.equal(rejectRes.statusCode, 200) + const data = sessionData(store, sessionId) + assert.equal(data.participantRoster[participantId]?.rejectedByInstructor, true) + assert.equal(data.participantRoster[participantId]?.name, 'SlightlyBad') +}) + +void test('create route returns instructorPasscode in the response', async () => { + const { handler } = setupAndGet('post', '/api/commissioned-ideas/create') + + const res = createResponse() + await handler(createRequest(), res) + + const body = res.body as { id: string; instructorPasscode?: string } + assert.equal(typeof body.instructorPasscode, 'string') + assert.ok(body.instructorPasscode && body.instructorPasscode.length > 0) +}) + +// ── WebSocket registration ──────────────────────────────────────────────────── + +void test('setupCommissionedIdeasRoutes registers the websocket namespace', () => { + const app = createMockApp() + const { sessions } = createMockSessions() + + const registered: string[] = [] + const trackingWs: WsRouter = { + wss: { clients: new Set(), close() {} }, + register(path: string) { + registered.push(path) + }, + } + + setupCommissionedIdeasRoutes(app, sessions, trackingWs) + assert.ok(registered.includes('/ws/commissioned-ideas'), 'WS namespace must be registered') +}) + +void test('manager websocket authenticates with a post-connect message instead of a URL passcode', async () => { + const app = createMockApp() + const { sessions } = createMockSessions() + + type CommissionedIdeasWsTestSocket = { + sessionId?: string + participantId?: string | null + isManager?: boolean + wantsManager?: boolean + readyState: number + send(message: string): void + close(code?: number, reason?: string): void + on(event: string, handler: (raw?: unknown) => void): void + } + + type CommissionedIdeasWsHandler = (socket: CommissionedIdeasWsTestSocket, query: URLSearchParams) => void + + let wsHandler: CommissionedIdeasWsHandler | null = null + + const trackingWs: WsRouter = { + wss: { clients: new Set(), close() {} }, + register(path, handler) { + if (path === '/ws/commissioned-ideas') { + wsHandler = handler as CommissionedIdeasWsHandler + } + }, + } + + setupCommissionedIdeasRoutes(app, sessions, trackingWs) + + const createHandler = app.handlers.post['/api/commissioned-ideas/create'] + assert.ok(createHandler) + const createRes = createResponse() + await createHandler(createRequest(), createRes) + + const { id: sessionId, instructorPasscode } = createRes.body as { id: string; instructorPasscode: string } + if (wsHandler == null) { + throw new Error('Expected commissioned-ideas websocket handler to be registered') + } + const registeredWsHandler: CommissionedIdeasWsHandler = wsHandler + + const sentMessages: string[] = [] + const messageHandlers: Array<(raw?: unknown) => void> = [] + const socket: CommissionedIdeasWsTestSocket = { + readyState: 1, + send(message: string) { + sentMessages.push(message) + }, + close() {}, + on(event: string, handler: (raw?: unknown) => void) { + if (event === 'message') { + messageHandlers.push(handler) + } + }, + } + + registeredWsHandler(socket, new URLSearchParams({ sessionId, role: 'manager' })) + await new Promise((resolve) => setTimeout(resolve, 0)) + + assert.equal( + sentMessages.some((message) => message.includes('commissioned-ideas:session-state')), + false, + 'manager socket should not receive a privileged snapshot before authenticating', + ) + + messageHandlers[0]?.(JSON.stringify({ + type: 'commissioned-ideas:manager-auth', + instructorPasscode, + })) + await new Promise((resolve) => setTimeout(resolve, 0)) + + assert.equal( + sentMessages.some((message) => message.includes('commissioned-ideas:session-state')), + true, + 'manager socket should receive a session snapshot after authenticating over the socket', + ) +}) + +// ── POST /api/commissioned-ideas/:sessionId/settings ───────────────────────── + +async function createSession(app: ReturnType) { + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/create']!(createRequest(), res) + return res.body as { id: string; instructorPasscode: string } +} + +async function registerParticipant( + app: ReturnType, + sessionId: string, + name: string, +): Promise<{ participantId: string; token: string }> { + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/register-participant']!( + createRequest({ sessionId }, { name }), + res, + ) + return res.body as { participantId: string; token: string } +} + +void test('settings route returns 403 without instructor auth', async () => { + const app = createMockApp() + setupCommissionedIdeasRoutes(app, createMockSessions().sessions, createMockWs()) + const { id: sessionId } = await createSession(app) + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/settings']!( + createRequest({ sessionId }, { maxTeamSize: 5 }), + res, + ) + assert.equal(res.statusCode, 403) +}) + +void test('settings route updates maxTeamSize', async () => { + const app = createMockApp() + const { store, sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId, instructorPasscode } = await createSession(app) + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/settings']!( + withPasscode({ sessionId }, { maxTeamSize: 5 }, instructorPasscode), + res, + ) + assert.equal(res.statusCode, 200) + assert.equal(sessionData(store, sessionId).maxTeamSize, 5) +}) + +void test('settings route rejects non-integer maxTeamSize', async () => { + const app = createMockApp() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId, instructorPasscode } = await createSession(app) + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/settings']!( + withPasscode({ sessionId }, { maxTeamSize: 'big' }, instructorPasscode), + res, + ) + assert.equal(res.statusCode, 400) +}) + +void test('settings route sets studentGroupingLocked', async () => { + const app = createMockApp() + const { store, sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId, instructorPasscode } = await createSession(app) + + await app.handlers.post['/api/commissioned-ideas/:sessionId/settings']!( + withPasscode({ sessionId }, { studentGroupingLocked: true }, instructorPasscode), + createResponse(), + ) + assert.equal(sessionData(store, sessionId).studentGroupingLocked, true) +}) + +// ── POST /api/commissioned-ideas/:sessionId/create-team ─────────────────────── + +void test('create-team creates a team and sets participant teamId', async () => { + const app = createMockApp() + const { store, sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId } = await createSession(app) + + const { participantId, token } = await registerParticipant(app, sessionId, 'Alice') + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/create-team']!( + withParticipantToken({ sessionId }, { participantId }, token), + res, + ) + + assert.equal(res.statusCode, 200) + const { teamId } = res.body as { teamId: string } + assert.ok(teamId) + const data = sessionData(store, sessionId) + assert.ok(data.teams[teamId], 'team must exist in store') + assert.equal(data.participantRoster[participantId]?.teamId, teamId) + assert.deepEqual(data.teams[teamId]?.memberIds, [participantId]) +}) + +void test('create-team returns 403 without participant token', async () => { + const app = createMockApp() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId } = await createSession(app) + const { participantId } = await registerParticipant(app, sessionId, 'Alice') + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/create-team']!( + createRequest({ sessionId }, { participantId }), + res, + ) + assert.equal(res.statusCode, 403) +}) + +void test('create-team returns 403 when grouping is locked', async () => { + const app = createMockApp() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId, instructorPasscode } = await createSession(app) + + const { participantId, token } = await registerParticipant(app, sessionId, 'Alice') + + await app.handlers.post['/api/commissioned-ideas/:sessionId/settings']!( + withPasscode({ sessionId }, { studentGroupingLocked: true }, instructorPasscode), + createResponse(), + ) + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/create-team']!( + withParticipantToken({ sessionId }, { participantId }, token), + res, + ) + assert.equal(res.statusCode, 403) +}) + +void test('create-team returns 403 in random grouping mode', async () => { + const app = createMockApp() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId, instructorPasscode } = await createSession(app) + + const { participantId, token } = await registerParticipant(app, sessionId, 'Alice') + + await app.handlers.post['/api/commissioned-ideas/:sessionId/settings']!( + withPasscode({ sessionId }, { groupingMode: 'random' }, instructorPasscode), + createResponse(), + ) + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/create-team']!( + withParticipantToken({ sessionId }, { participantId }, token), + res, + ) + assert.equal(res.statusCode, 403) +}) + +// ── POST /api/commissioned-ideas/:sessionId/join-team ──────────────────────── + +void test('join-team adds participant to existing team', async () => { + const app = createMockApp() + const { store, sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId } = await createSession(app) + + const { participantId: aliceId, token: aliceToken } = await registerParticipant(app, sessionId, 'Alice') + const { participantId: bobId, token: bobToken } = await registerParticipant(app, sessionId, 'Bob') + + // Alice creates a team + const teamRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/create-team']!( + withParticipantToken({ sessionId }, { participantId: aliceId }, aliceToken), teamRes, + ) + const { teamId } = teamRes.body as { teamId: string } + + // Bob joins Alice's team + const joinRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/join-team']!( + withParticipantToken({ sessionId }, { participantId: bobId, teamId }, bobToken), joinRes, + ) + + assert.equal(joinRes.statusCode, 200) + const data = sessionData(store, sessionId) + assert.equal(data.participantRoster[bobId]?.teamId, teamId) + assert.ok(data.teams[teamId]?.memberIds.includes(bobId)) +}) + +void test('join-team returns 403 without participant token', async () => { + const app = createMockApp() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId } = await createSession(app) + + const { participantId: aliceId, token: aliceToken } = await registerParticipant(app, sessionId, 'Alice') + const { participantId: bobId } = await registerParticipant(app, sessionId, 'Bob') + + const teamRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/create-team']!( + withParticipantToken({ sessionId }, { participantId: aliceId }, aliceToken), teamRes, + ) + const { teamId } = teamRes.body as { teamId: string } + + // Bob tries to join without his token + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/join-team']!( + createRequest({ sessionId }, { participantId: bobId, teamId }), res, + ) + assert.equal(res.statusCode, 403) +}) + +void test('join-team returns 403 in random grouping mode', async () => { + const app = createMockApp() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId, instructorPasscode } = await createSession(app) + + const { participantId: aliceId, token: aliceToken } = await registerParticipant(app, sessionId, 'Alice') + const { participantId: bobId, token: bobToken } = await registerParticipant(app, sessionId, 'Bob') + + // Alice creates team while still in manual mode, then instructor switches to random + const teamRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/create-team']!( + withParticipantToken({ sessionId }, { participantId: aliceId }, aliceToken), teamRes, + ) + const { teamId } = teamRes.body as { teamId: string } + + await app.handlers.post['/api/commissioned-ideas/:sessionId/settings']!( + withPasscode({ sessionId }, { groupingMode: 'random' }, instructorPasscode), createResponse(), + ) + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/join-team']!( + withParticipantToken({ sessionId }, { participantId: bobId, teamId }, bobToken), res, + ) + assert.equal(res.statusCode, 403) +}) + +void test('join-team returns 409 when team is full', async () => { + const app = createMockApp() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId, instructorPasscode } = await createSession(app) + + await app.handlers.post['/api/commissioned-ideas/:sessionId/settings']!( + withPasscode({ sessionId }, { maxTeamSize: 1 }, instructorPasscode), createResponse(), + ) + + const { participantId: aliceId, token: aliceToken } = await registerParticipant(app, sessionId, 'Alice') + const { participantId: bobId, token: bobToken } = await registerParticipant(app, sessionId, 'Bob') + + const teamRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/create-team']!( + withParticipantToken({ sessionId }, { participantId: aliceId }, aliceToken), teamRes, + ) + const { teamId } = teamRes.body as { teamId: string } + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/join-team']!( + withParticipantToken({ sessionId }, { participantId: bobId, teamId }, bobToken), res, + ) + assert.equal(res.statusCode, 409) +}) + +// ── POST /api/commissioned-ideas/:sessionId/leave-team ─────────────────────── + +void test('leave-team removes participant from their team', async () => { + const app = createMockApp() + const { store, sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId } = await createSession(app) + + const { participantId, token } = await registerParticipant(app, sessionId, 'Alice') + + await app.handlers.post['/api/commissioned-ideas/:sessionId/create-team']!( + withParticipantToken({ sessionId }, { participantId }, token), createResponse(), + ) + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/leave-team']!( + withParticipantToken({ sessionId }, { participantId }, token), res, + ) + + assert.equal(res.statusCode, 200) + assert.equal(sessionData(store, sessionId).participantRoster[participantId]?.teamId, null) +}) + +void test('leave-team returns 403 without participant token', async () => { + const app = createMockApp() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId } = await createSession(app) + + const { participantId, token } = await registerParticipant(app, sessionId, 'Alice') + await app.handlers.post['/api/commissioned-ideas/:sessionId/create-team']!( + withParticipantToken({ sessionId }, { participantId }, token), createResponse(), + ) + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/leave-team']!( + createRequest({ sessionId }, { participantId }), res, + ) + assert.equal(res.statusCode, 403) +}) + +void test('leave-team returns 409 when participant is not in a team', async () => { + const app = createMockApp() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId } = await createSession(app) + + const { participantId, token } = await registerParticipant(app, sessionId, 'Alice') + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/leave-team']!( + withParticipantToken({ sessionId }, { participantId }, token), res, + ) + assert.equal(res.statusCode, 409) +}) + +// ── POST /api/commissioned-ideas/:sessionId/assign-participant ──────────────── + +void test('assign-participant moves a participant to a specified team', async () => { + const app = createMockApp() + const { store, sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId, instructorPasscode } = await createSession(app) + + const { participantId: aliceId, token: aliceToken } = await registerParticipant(app, sessionId, 'Alice') + const { participantId: bobId } = await registerParticipant(app, sessionId, 'Bob') + + const teamRes = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/create-team']!( + withParticipantToken({ sessionId }, { participantId: aliceId }, aliceToken), teamRes, + ) + const { teamId } = teamRes.body as { teamId: string } + + await app.handlers.post['/api/commissioned-ideas/:sessionId/settings']!( + withPasscode({ sessionId }, { studentGroupingLocked: true }, instructorPasscode), createResponse(), + ) + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/assign-participant']!( + withPasscode({ sessionId }, { participantId: bobId, teamId }, instructorPasscode), res, + ) + + assert.equal(res.statusCode, 200) + assert.equal(sessionData(store, sessionId).participantRoster[bobId]?.teamId, teamId) +}) + +void test('assign-participant removes from team when teamId is null', async () => { + const app = createMockApp() + const { store, sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId, instructorPasscode } = await createSession(app) + + const { participantId, token } = await registerParticipant(app, sessionId, 'Alice') + + await app.handlers.post['/api/commissioned-ideas/:sessionId/create-team']!( + withParticipantToken({ sessionId }, { participantId }, token), createResponse(), + ) + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/assign-participant']!( + withPasscode({ sessionId }, { participantId, teamId: null }, instructorPasscode), res, + ) + + assert.equal(res.statusCode, 200) + assert.equal(sessionData(store, sessionId).participantRoster[participantId]?.teamId, null) +}) + +// ── POST /api/commissioned-ideas/:sessionId/assign-random ──────────────────── + +void test('assign-random places all ungrouped participants', async () => { + const app = createMockApp() + const { store, sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId, instructorPasscode } = await createSession(app) + + for (const name of ['A', 'B', 'C', 'D']) { + await app.handlers.post['/api/commissioned-ideas/:sessionId/register-participant']!( + createRequest({ sessionId }, { name }), createResponse(), + ) + } + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/assign-random']!( + withPasscode({ sessionId }, {}, instructorPasscode), res, + ) + + assert.equal(res.statusCode, 200) + const data = sessionData(store, sessionId) + for (const p of Object.values(data.participantRoster)) { + assert.ok(p.teamId !== null, `${p.name} must be placed in a team`) + } + for (const team of Object.values(data.teams)) { + assert.ok(team.memberIds.length <= data.maxTeamSize, 'team must not exceed maxTeamSize') + } +}) + +void test('assign-random returns 403 without instructor auth', async () => { + const app = createMockApp() + const { sessions } = createMockSessions() + setupCommissionedIdeasRoutes(app, sessions, createMockWs()) + const { id: sessionId } = await createSession(app) + + const res = createResponse() + await app.handlers.post['/api/commissioned-ideas/:sessionId/assign-random']!( + createRequest({ sessionId }, {}), res, + ) + assert.equal(res.statusCode, 403) +}) diff --git a/activities/commissioned-ideas/server/routes.test.ts b/activities/commissioned-ideas/server/routes.test.ts new file mode 100644 index 00000000..d2e9fa16 --- /dev/null +++ b/activities/commissioned-ideas/server/routes.test.ts @@ -0,0 +1,588 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { computeTeamScores, resolveLeadingProposal } from '../shared/scoring.js' +import { validateBallot, sanitizeDisplayName, coerceAllocations } from '../shared/validation.js' +import { normalizeTeam, buildStudentSnapshot, removeParticipantFromTeam, assignRandom } from './routes.js' +import type { CommissionedIdeasSessionData, CommissionedIdeasTeam } from '../shared/types.js' + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function makeTeam(id: string, registeredAt = 0): CommissionedIdeasTeam { + return { + id, + groupName: id, + projectName: null, + registeredAt, + presenterOrder: null, + locked: false, + memberIds: [], + proposedGroupNames: [], + proposedProjectNames: [], + groupNameVotes: {}, + projectNameVotes: {}, + } +} + +function makeSession(overrides: Partial = {}): CommissionedIdeasSessionData { + return { + instructorPasscode: 'TESTPASS', + phase: 'voting', + studentGroupingLocked: true, + namingLocked: true, + maxTeamSize: 4, + groupingMode: 'manual', + presentationRound: 1, + allowLateRegistration: true, + teams: { + t1: makeTeam('t1'), + t2: makeTeam('t2'), + t3: makeTeam('t3'), + }, + participantRoster: { + voter1: { id: 'voter1', name: 'Alice', teamId: null, connected: true, lastSeen: 0, rejectedByInstructor: false, token: 'TESTTOKEN' }, + }, + ballots: {}, + presentationHistory: [], + currentPresentationTeamId: null, + podiumRevealStep: 'hidden', + ...overrides, + } +} + +// ── scoring ─────────────────────────────────────────────────────────────────── + +void test('computeTeamScores totals dollars per team', () => { + const teams = { + t1: makeTeam('t1'), + t2: makeTeam('t2'), + t3: makeTeam('t3'), + } + const ballots = { + v1: { + voterId: 'v1', + voterName: 'Alice', + voterTeamId: null, + submittedAt: 0, + allocations: [ + { teamId: 't1', amount: 500 as const }, + { teamId: 't2', amount: 300 as const }, + { teamId: 't3', amount: 100 as const }, + ], + }, + } + + const [first, second, third] = computeTeamScores(teams, ballots) + assert.ok(first && second && third) + assert.equal(first.teamId, 't1') + assert.equal(first.totalDollars, 500) + assert.equal(second.teamId, 't2') + assert.equal(third.teamId, 't3') +}) + +void test('computeTeamScores tiebreaker: earlier registration wins when dollars and $500 count match', () => { + const teams = { + early: makeTeam('early', 1000), + late: makeTeam('late', 2000), + } + // v1: early=$500, late=$300, early=$100 → duplicate team in same ballot, but scoring aggregates across ballots + // Use two separate ballots so each ballot is valid: + // b1: early=$500, late=$300, early=$100 is invalid (duplicate team) — use a third dummy team + // Use three teams to keep ballots valid + const teams3 = { + early: makeTeam('early', 1000), + late: makeTeam('late', 2000), + other: makeTeam('other', 3000), + } + const ballots = { + b1: { + voterId: 'b1', + voterName: 'X', + voterTeamId: null, + submittedAt: 0, + allocations: [ + { teamId: 'early', amount: 500 as const }, + { teamId: 'other', amount: 300 as const }, + { teamId: 'late', amount: 100 as const }, + ], + }, + b2: { + voterId: 'b2', + voterName: 'Y', + voterTeamId: null, + submittedAt: 0, + allocations: [ + { teamId: 'late', amount: 500 as const }, + { teamId: 'other', amount: 300 as const }, + { teamId: 'early', amount: 100 as const }, + ], + }, + } + // early: 500+100 = 600, $500×1 + // late: 100+500 = 600, $500×1 + // $500 count tie → $300 count: early=0, late=0 → registeredAt: early=1000 < late=2000 → early wins + const [first, second] = computeTeamScores(teams3, ballots) + assert.ok(first && second) + assert.equal(first.teamId, 'early') + assert.equal(first.totalDollars, 600) + + // Confirm teams arg didn't shadow outer teams + void teams +}) + +void test('computeTeamScores full dollar ranking', () => { + const teams = { + winner: makeTeam('winner', 100), + second: makeTeam('second', 200), + third: makeTeam('third', 300), + } + const ballots = { + b1: { + voterId: 'b1', + voterName: 'A', + voterTeamId: null, + submittedAt: 0, + allocations: [ + { teamId: 'winner', amount: 500 as const }, + { teamId: 'second', amount: 300 as const }, + { teamId: 'third', amount: 100 as const }, + ], + }, + } + const [first, second, third] = computeTeamScores(teams, ballots) + assert.ok(first && second && third) + assert.equal(first.teamId, 'winner') + assert.equal(second.teamId, 'second') + assert.equal(third.teamId, 'third') +}) + +void test('resolveLeadingProposal returns null when no eligible proposals', () => { + assert.equal(resolveLeadingProposal([], {}), null) +}) + +void test('resolveLeadingProposal skips rejected proposals', () => { + const proposals = [ + { id: 'p1', value: 'Alpha', rejectedByInstructor: true }, + { id: 'p2', value: 'Beta', rejectedByInstructor: false }, + ] + assert.equal(resolveLeadingProposal(proposals, {}), 'Beta') +}) + +void test('resolveLeadingProposal returns leading vote winner', () => { + const proposals = [ + { id: 'p1', value: 'Alpha', rejectedByInstructor: false }, + { id: 'p2', value: 'Beta', rejectedByInstructor: false }, + ] + const votes = { alice: 'p2', bob: 'p2', carol: 'p1' } + assert.equal(resolveLeadingProposal(proposals, votes), 'Beta') +}) + +// ── validation ──────────────────────────────────────────────────────────────── + +void test('validateBallot accepts a valid $100/$300/$500 ballot', () => { + const session = makeSession() + const result = validateBallot( + [ + { teamId: 't1', amount: 500 }, + { teamId: 't2', amount: 300 }, + { teamId: 't3', amount: 100 }, + ], + 'voter1', + session, + ) + assert.equal(result.valid, true) +}) + +void test('validateBallot rejects fewer than three allocations', () => { + const session = makeSession() + const result = validateBallot( + [ + { teamId: 't1', amount: 500 }, + { teamId: 't2', amount: 300 }, + ], + 'voter1', + session, + ) + assert.equal(result.valid, false) +}) + +void test('validateBallot rejects duplicate amounts', () => { + const session = makeSession() + const result = validateBallot( + [ + { teamId: 't1', amount: 500 }, + { teamId: 't2', amount: 500 }, + { teamId: 't3', amount: 100 }, + ], + 'voter1', + session, + ) + assert.equal(result.valid, false) +}) + +void test('validateBallot rejects duplicate team targets', () => { + const session = makeSession() + const result = validateBallot( + [ + { teamId: 't1', amount: 500 }, + { teamId: 't1', amount: 300 }, + { teamId: 't3', amount: 100 }, + ], + 'voter1', + session, + ) + assert.equal(result.valid, false) +}) + +void test('validateBallot blocks self-vote when voter belongs to a team', () => { + const session = makeSession({ + participantRoster: { + voter1: { id: 'voter1', name: 'Alice', teamId: 't1', connected: true, lastSeen: 0, rejectedByInstructor: false, token: 'TESTTOKEN' }, + }, + }) + const result = validateBallot( + [ + { teamId: 't1', amount: 500 }, + { teamId: 't2', amount: 300 }, + { teamId: 't3', amount: 100 }, + ], + 'voter1', + session, + ) + assert.equal(result.valid, false) + assert.match(result.error ?? '', /own team/) +}) + +void test('validateBallot allows self-vote when allowSelfVote=true', () => { + const session = makeSession({ + participantRoster: { + voter1: { id: 'voter1', name: 'Alice', teamId: 't1', connected: true, lastSeen: 0, rejectedByInstructor: false, token: 'TESTTOKEN' }, + }, + }) + const result = validateBallot( + [ + { teamId: 't1', amount: 500 }, + { teamId: 't2', amount: 300 }, + { teamId: 't3', amount: 100 }, + ], + 'voter1', + session, + true, + ) + assert.equal(result.valid, true) +}) + +void test('sanitizeDisplayName trims and enforces maxLength', () => { + assert.equal(sanitizeDisplayName(' hello '), 'hello') + assert.equal(sanitizeDisplayName(''), null) + assert.equal(sanitizeDisplayName(null), null) + const long = 'x'.repeat(200) + assert.equal((sanitizeDisplayName(long, 10) ?? '').length, 10) +}) + +void test('coerceAllocations returns null for invalid input', () => { + assert.equal(coerceAllocations(null), null) + assert.equal(coerceAllocations([{ teamId: 't1', amount: 999 }]), null) +}) + +void test('coerceAllocations parses valid allocations', () => { + const result = coerceAllocations([ + { teamId: 't1', amount: 500 }, + { teamId: 't2', amount: 300 }, + { teamId: 't3', amount: 100 }, + ]) + assert.ok(result) + assert.equal(result.length, 3) +}) + +// ── normalizeTeam: name fallback ────────────────────────────────────────────── + +void test('normalizeTeam preserves persisted groupName when no proposals exist', () => { + const raw = { + id: 't1', + groupName: 'The Sharks', + projectName: 'Fin Tracker', + registeredAt: 1000, + presenterOrder: null, + locked: false, + memberIds: [], + proposedGroupNames: [], + proposedProjectNames: [], + groupNameVotes: {}, + projectNameVotes: {}, + } + const team = normalizeTeam(raw, 't1') + assert.equal(team.groupName, 'The Sharks') + assert.equal(team.projectName, 'Fin Tracker') +}) + +void test('normalizeTeam resolves name from proposals when proposals exist', () => { + const raw = { + id: 't1', + groupName: 'Old Name', + projectName: 'Old Project', + registeredAt: 1000, + presenterOrder: null, + locked: false, + memberIds: [], + proposedGroupNames: [ + { id: 'p1', value: 'New Name', proposedByParticipantId: 'a', createdAt: 0, rejectedByInstructor: false }, + ], + proposedProjectNames: [], + groupNameVotes: { a: 'p1' }, + projectNameVotes: {}, + } + const team = normalizeTeam(raw, 't1') + // Proposals exist for groupName, so resolution wins + assert.equal(team.groupName, 'New Name') + // No proposals for projectName, so persisted value is preserved + assert.equal(team.projectName, 'Old Project') +}) + +void test('normalizeTeam returns null name when all proposals are rejected and no fallback', () => { + const raw = { + id: 't1', + groupName: null, + projectName: null, + registeredAt: 1000, + presenterOrder: null, + locked: false, + memberIds: [], + proposedGroupNames: [ + { id: 'p1', value: 'Bad Name', proposedByParticipantId: 'a', createdAt: 0, rejectedByInstructor: true }, + ], + proposedProjectNames: [], + groupNameVotes: { a: 'p1' }, + projectNameVotes: {}, + } + const team = normalizeTeam(raw, 't1') + assert.equal(team.groupName, null) +}) + +// ── buildStudentSnapshot: privacy ───────────────────────────────────────────── + +function makeFullSession(overrides: Partial = {}): CommissionedIdeasSessionData { + return { + instructorPasscode: 'TESTPASS', + phase: 'registration', + studentGroupingLocked: false, + namingLocked: false, + maxTeamSize: 4, + groupingMode: 'manual', + presentationRound: 1, + allowLateRegistration: true, + teams: {}, + participantRoster: { + p1: { id: 'p1', name: 'Alice', teamId: null, connected: true, lastSeen: 999, rejectedByInstructor: false, token: 'TESTTOKEN' }, + p2: { id: 'p2', name: 'BadName', teamId: null, connected: false, lastSeen: 100, rejectedByInstructor: true, token: 'TESTTOKEN' }, + }, + ballots: { + p1: { + voterId: 'p1', + voterName: 'Alice', + voterTeamId: null, + allocations: [ + { teamId: 't1', amount: 500 }, + { teamId: 't2', amount: 300 }, + { teamId: 't3', amount: 100 }, + ], + submittedAt: 0, + }, + }, + presentationHistory: [], + currentPresentationTeamId: null, + podiumRevealStep: 'hidden', + ...overrides, + } +} + +void test('buildStudentSnapshot strips connected, lastSeen, rejectedByInstructor, token from participants', () => { + const snapshot = buildStudentSnapshot(makeFullSession(), null) + const p1 = snapshot.participantRoster['p1'] + assert.ok(p1) + assert.equal('connected' in p1, false) + assert.equal('lastSeen' in p1, false) + assert.equal('rejectedByInstructor' in p1, false) + assert.equal('token' in p1, false, 'participant token must never appear in student snapshot') + assert.equal(p1.id, 'p1') + assert.equal(p1.name, 'Alice') +}) + +void test('buildStudentSnapshot does not expose instructorPasscode', () => { + const snapshot = buildStudentSnapshot(makeFullSession(), null) + assert.equal( + 'instructorPasscode' in snapshot, + false, + 'instructorPasscode must not appear in student snapshot', + ) +}) + +void test('buildStudentSnapshot omits rejected participants from student roster', () => { + const snapshot = buildStudentSnapshot(makeFullSession(), null) + assert.equal('p2' in snapshot.participantRoster, false) + assert.equal(Object.keys(snapshot.participantRoster).length, 1) +}) + +void test('buildStudentSnapshot exposes ballot count but not ballot contents', () => { + const snapshot = buildStudentSnapshot(makeFullSession(), null) + assert.equal(snapshot.ballotsReceived, 1) + assert.equal('ballots' in snapshot, false) + assert.equal(snapshot.myBallot, null) +}) + +void test('buildStudentSnapshot returns own ballot for the viewer', () => { + const snapshot = buildStudentSnapshot(makeFullSession(), 'p1') + assert.ok(snapshot.myBallot) + assert.equal(snapshot.ballotSubmitted, true) +}) + +void test('buildStudentSnapshot does not include another participant ballot for viewer', () => { + const snapshot = buildStudentSnapshot(makeFullSession(), 'p2') + assert.equal(snapshot.myBallot, null) + assert.equal(snapshot.ballotSubmitted, false) +}) + +// ── removeParticipantFromTeam ───────────────────────────────────────────────── + +function makeRegistrationSession(overrides: Partial = {}): CommissionedIdeasSessionData { + return { + instructorPasscode: 'TESTPASS', + phase: 'registration', + studentGroupingLocked: false, + namingLocked: false, + maxTeamSize: 3, + groupingMode: 'manual', + presentationRound: 1, + allowLateRegistration: true, + teams: {}, + participantRoster: {}, + ballots: {}, + presentationHistory: [], + currentPresentationTeamId: null, + podiumRevealStep: 'hidden', + ...overrides, + } +} + +void test('removeParticipantFromTeam clears participant teamId and removes from team memberIds', () => { + const data = makeRegistrationSession({ + teams: { + t1: { ...makeTeam('t1'), memberIds: ['p1', 'p2'] }, + }, + participantRoster: { + p1: { id: 'p1', name: 'Alice', teamId: 't1', connected: true, lastSeen: 0, rejectedByInstructor: false, token: 'TESTTOKEN' }, + p2: { id: 'p2', name: 'Bob', teamId: 't1', connected: true, lastSeen: 0, rejectedByInstructor: false, token: 'TESTTOKEN' }, + }, + }) + + removeParticipantFromTeam(data, data.participantRoster['p1']!) + + assert.equal(data.participantRoster['p1']?.teamId, null) + assert.deepEqual(data.teams['t1']?.memberIds, ['p2']) +}) + +void test('removeParticipantFromTeam deletes team when it becomes empty', () => { + const data = makeRegistrationSession({ + teams: { + t1: { ...makeTeam('t1'), memberIds: ['p1'] }, + }, + participantRoster: { + p1: { id: 'p1', name: 'Alice', teamId: 't1', connected: true, lastSeen: 0, rejectedByInstructor: false, token: 'TESTTOKEN' }, + }, + }) + + removeParticipantFromTeam(data, data.participantRoster['p1']!) + + assert.equal('t1' in data.teams, false, 'empty team must be deleted') + assert.equal(data.participantRoster['p1']?.teamId, null) +}) + +// ── assignRandom ────────────────────────────────────────────────────────────── + +void test('assignRandom places all ungrouped participants into teams', () => { + const data = makeRegistrationSession({ + maxTeamSize: 2, + participantRoster: { + p1: { id: 'p1', name: 'Alice', teamId: null, connected: true, lastSeen: 0, rejectedByInstructor: false, token: 'TESTTOKEN' }, + p2: { id: 'p2', name: 'Bob', teamId: null, connected: true, lastSeen: 0, rejectedByInstructor: false, token: 'TESTTOKEN' }, + p3: { id: 'p3', name: 'Carol', teamId: null, connected: true, lastSeen: 0, rejectedByInstructor: false, token: 'TESTTOKEN' }, + }, + }) + + assignRandom(data, false) + + for (const p of Object.values(data.participantRoster)) { + assert.ok(p.teamId !== null, `${p.name} must be in a team`) + } + for (const team of Object.values(data.teams)) { + assert.ok(team.memberIds.length <= 2, 'no team should exceed maxTeamSize') + } +}) + +void test('assignRandom skips rejected participants', () => { + const data = makeRegistrationSession({ + maxTeamSize: 3, + participantRoster: { + p1: { id: 'p1', name: 'Alice', teamId: null, connected: true, lastSeen: 0, rejectedByInstructor: false, token: 'TESTTOKEN' }, + rejected: { id: 'rejected', name: 'Bad', teamId: null, connected: false, lastSeen: 0, rejectedByInstructor: true, token: 'TESTTOKEN' }, + }, + }) + + assignRandom(data, false) + + assert.ok(data.participantRoster['p1']?.teamId !== null) + assert.equal(data.participantRoster['rejected']?.teamId, null, 'rejected participant must not be assigned') +}) + +void test('assignRandom does not disturb already-grouped participants', () => { + const data = makeRegistrationSession({ + maxTeamSize: 3, + teams: { + existing: { ...makeTeam('existing'), memberIds: ['grouped'] }, + }, + participantRoster: { + grouped: { id: 'grouped', name: 'Already', teamId: 'existing', connected: true, lastSeen: 0, rejectedByInstructor: false, token: 'TESTTOKEN' }, + ungrouped: { id: 'ungrouped', name: 'New', teamId: null, connected: true, lastSeen: 0, rejectedByInstructor: false, token: 'TESTTOKEN' }, + }, + }) + + assignRandom(data, false) + + assert.equal(data.participantRoster['grouped']?.teamId, 'existing', 'grouped participant must stay put') + assert.ok(data.participantRoster['ungrouped']?.teamId !== null, 'ungrouped participant must be placed') +}) + +void test('assignRandom fills existing teams before creating new ones', () => { + const data = makeRegistrationSession({ + maxTeamSize: 3, + teams: { + partial: { ...makeTeam('partial'), memberIds: ['existing'] }, + }, + participantRoster: { + existing: { id: 'existing', name: 'E', teamId: 'partial', connected: true, lastSeen: 0, rejectedByInstructor: false, token: 'TESTTOKEN' }, + new1: { id: 'new1', name: 'N1', teamId: null, connected: true, lastSeen: 0, rejectedByInstructor: false, token: 'TESTTOKEN' }, + }, + }) + + assignRandom(data, false) + + // 'partial' had 1 member with capacity 3 — new1 should land in it + assert.equal(data.participantRoster['new1']?.teamId, 'partial') + assert.equal(Object.keys(data.teams).length, 1, 'no new team should be created when existing one has space') +}) + +void test('assignRandom is a no-op when all participants are already grouped', () => { + const data = makeRegistrationSession({ + maxTeamSize: 3, + teams: { + t1: { ...makeTeam('t1'), memberIds: ['p1'] }, + }, + participantRoster: { + p1: { id: 'p1', name: 'Alice', teamId: 't1', connected: true, lastSeen: 0, rejectedByInstructor: false, token: 'TESTTOKEN' }, + }, + }) + + assignRandom(data, false) + + assert.equal(data.participantRoster['p1']?.teamId, 't1') + assert.equal(Object.keys(data.teams).length, 1) +}) diff --git a/activities/commissioned-ideas/server/routes.ts b/activities/commissioned-ideas/server/routes.ts new file mode 100644 index 00000000..a0fa3ad7 --- /dev/null +++ b/activities/commissioned-ideas/server/routes.ts @@ -0,0 +1,1039 @@ +import { randomInt, timingSafeEqual } from 'crypto' +import { createSession, type SessionRecord, type SessionStore } from 'activebits-server/core/sessions.js' +import { registerSessionNormalizer } from 'activebits-server/core/sessionNormalization.js' +import { createBroadcastSubscriptionHelper } from 'activebits-server/core/broadcastUtils.js' +import type { ActiveBitsWebSocket, WsRouter } from '../../../types/websocket.js' +import type { + CommissionedIdeasSessionData, + CommissionedIdeasTeam, + CommissionedIdeasParticipant, + CommissionedIdeasBallot, + CommissionedIdeasPhase, + PodiumRevealStep, + GroupingMode, + PresentationHistoryEntry, + NameProposal, + StudentSafeParticipant, +} from '../shared/types.js' +import { sanitizeDisplayName } from '../shared/validation.js' +import { resolveLeadingProposal } from '../shared/scoring.js' +import { generateShortId } from '../shared/id.js' + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface CommissionedIdeasSession extends SessionRecord { + type?: string + data: CommissionedIdeasSessionData +} + +/** Extended socket that carries per-connection identity. */ +interface CommissionedIdeasSocket extends ActiveBitsWebSocket { + participantId?: string | null + isManager?: boolean + wantsManager?: boolean +} + +interface JsonResponse { + status(code: number): JsonResponse + json(payload: unknown): JsonResponse | void +} + +interface RouteRequest { + params: Record + query?: Record + body?: unknown + headers?: Record +} + +interface CommissionedIdeasRouteApp { + post(path: string, handler: (req: RouteRequest, res: JsonResponse) => void | Promise): void + get(path: string, handler: (req: RouteRequest, res: JsonResponse) => void | Promise): void +} + +// ── Normalization helpers ───────────────────────────────────────────────────── + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function ensurePlainObject(value: unknown): Record { + return isPlainObject(value) ? value : {} +} + +function normalizePhase(value: unknown): CommissionedIdeasPhase { + if (value === 'presentation' || value === 'voting' || value === 'results') { + return value + } + return 'registration' +} + +function normalizePodiumStep(value: unknown): PodiumRevealStep { + if (value === 'third' || value === 'second' || value === 'winner' || value === 'complete') { + return value + } + return 'hidden' +} + +function normalizeGroupingMode(value: unknown): GroupingMode { + return value === 'random' ? 'random' : 'manual' +} + +function normalizeNameProposals(value: unknown): NameProposal[] { + if (!Array.isArray(value)) return [] + return value + .filter(isPlainObject) + .map((item) => ({ + id: typeof item.id === 'string' ? item.id : '', + value: sanitizeDisplayName(item.value, 100) ?? '', + proposedByParticipantId: typeof item.proposedByParticipantId === 'string' ? item.proposedByParticipantId : '', + createdAt: typeof item.createdAt === 'number' ? item.createdAt : 0, + rejectedByInstructor: Boolean(item.rejectedByInstructor), + })) + .filter((p) => Boolean(p.id && p.value)) +} + +function normalizeVotesRecord(value: unknown): Record { + if (!isPlainObject(value)) return {} + const result: Record = {} + for (const [k, v] of Object.entries(value)) { + if (typeof v === 'string') result[k] = v + } + return result +} + +function normalizeTeam(raw: unknown, id: string): CommissionedIdeasTeam { + const item = ensurePlainObject(raw) + const proposedGroupNames = normalizeNameProposals(item.proposedGroupNames) + const proposedProjectNames = normalizeNameProposals(item.proposedProjectNames) + const groupNameVotes = normalizeVotesRecord(item.groupNameVotes) + const projectNameVotes = normalizeVotesRecord(item.projectNameVotes) + + // Resolve name from proposals when they exist; otherwise preserve the + // persisted value so records with names-but-no-proposals (e.g. instructor + // overrides, manual seeds) survive normalization unchanged. + const groupName = + proposedGroupNames.length > 0 + ? resolveLeadingProposal(proposedGroupNames, groupNameVotes) + : (sanitizeDisplayName(item.groupName) ?? null) + const projectName = + proposedProjectNames.length > 0 + ? resolveLeadingProposal(proposedProjectNames, projectNameVotes) + : (sanitizeDisplayName(item.projectName) ?? null) + + return { + id, + groupName, + projectName, + registeredAt: typeof item.registeredAt === 'number' ? item.registeredAt : Date.now(), + presenterOrder: typeof item.presenterOrder === 'number' ? item.presenterOrder : null, + locked: Boolean(item.locked), + memberIds: Array.isArray(item.memberIds) + ? item.memberIds.filter((m): m is string => typeof m === 'string') + : [], + proposedGroupNames, + proposedProjectNames, + groupNameVotes, + projectNameVotes, + } +} + +function normalizeTeams(value: unknown): Record { + if (!isPlainObject(value)) return {} + const result: Record = {} + for (const [id, raw] of Object.entries(value)) { + result[id] = normalizeTeam(raw, id) + } + return result +} + +function normalizeParticipant(raw: unknown, id: string): CommissionedIdeasParticipant { + const item = ensurePlainObject(raw) + return { + id, + name: sanitizeDisplayName(item.name, 100) ?? '', + teamId: typeof item.teamId === 'string' ? item.teamId : null, + connected: Boolean(item.connected), + lastSeen: typeof item.lastSeen === 'number' ? item.lastSeen : 0, + rejectedByInstructor: Boolean(item.rejectedByInstructor), + token: typeof item.token === 'string' && item.token.length > 0 ? item.token : generatePasscode(), + } +} + +function normalizeParticipantRoster(value: unknown): Record { + if (!isPlainObject(value)) return {} + const result: Record = {} + for (const [id, raw] of Object.entries(value)) { + const p = normalizeParticipant(raw, id) + if (p.id) result[id] = p + } + return result +} + +function normalizeBallots(value: unknown): Record { + if (!isPlainObject(value)) return {} + const result: Record = {} + for (const [id, raw] of Object.entries(value)) { + if (!isPlainObject(raw)) continue + if (!Array.isArray(raw.allocations)) continue + result[id] = { + voterId: typeof raw.voterId === 'string' ? raw.voterId : id, + voterName: sanitizeDisplayName(raw.voterName, 100) ?? '', + voterTeamId: typeof raw.voterTeamId === 'string' ? raw.voterTeamId : null, + allocations: raw.allocations + .filter(isPlainObject) + .filter((a) => typeof a.teamId === 'string' && (a.amount === 100 || a.amount === 300 || a.amount === 500)) + .map((a) => ({ teamId: a.teamId as string, amount: a.amount as 100 | 300 | 500 })), + submittedAt: typeof raw.submittedAt === 'number' ? raw.submittedAt : 0, + } + } + return result +} + +function normalizePresentationHistory(value: unknown): PresentationHistoryEntry[] { + if (!Array.isArray(value)) return [] + return value + .filter(isPlainObject) + .filter((e) => typeof e.teamId === 'string' && typeof e.round === 'number') + .map((e) => ({ + round: e.round as number, + teamId: e.teamId as string, + presentedAt: typeof e.presentedAt === 'number' ? e.presentedAt : 0, + })) +} + +function normalizeSessionData(data: unknown): CommissionedIdeasSessionData { + const source = ensurePlainObject(data) + return { + ...source, + instructorPasscode: normalizeInstructorPasscode(source.instructorPasscode) ?? generatePasscode(), + phase: normalizePhase(source.phase), + studentGroupingLocked: Boolean(source.studentGroupingLocked), + namingLocked: Boolean(source.namingLocked), + maxTeamSize: typeof source.maxTeamSize === 'number' && source.maxTeamSize >= 1 ? source.maxTeamSize : 4, + groupingMode: normalizeGroupingMode(source.groupingMode), + presentationRound: typeof source.presentationRound === 'number' ? source.presentationRound : 1, + allowLateRegistration: source.allowLateRegistration !== false, + teams: normalizeTeams(source.teams), + participantRoster: normalizeParticipantRoster(source.participantRoster), + ballots: normalizeBallots(source.ballots), + presentationHistory: normalizePresentationHistory(source.presentationHistory), + currentPresentationTeamId: + typeof source.currentPresentationTeamId === 'string' ? source.currentPresentationTeamId : null, + podiumRevealStep: normalizePodiumStep(source.podiumRevealStep), + } +} + +// ── Instructor auth ─────────────────────────────────────────────────────────── + +function generatePasscode(): string { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + let passcode = '' + for (let index = 0; index < 8; index += 1) { + passcode += alphabet[randomInt(0, alphabet.length)] + } + return passcode +} + +function normalizeInstructorPasscode(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value.toUpperCase() : null +} + +function verifyPasscode(expected: string, candidate: string): boolean { + if (!expected || !candidate || expected.length !== candidate.length) return false + try { + return timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(candidate, 'utf8')) + } catch { + return false + } +} + +function checkInstructorAuth(req: RouteRequest, session: CommissionedIdeasSession): boolean { + const raw = req.headers?.['x-commissioned-ideas-instructor-passcode'] + const header = Array.isArray(raw) ? raw[0] : raw + return typeof header === 'string' && verifyPasscode(session.data.instructorPasscode, header.toUpperCase()) +} + +/** + * Verifies that the caller owns the participantId they are acting on. + * Reads the participantId from the request body and the token from the + * `X-Commissioned-Ideas-Participant-Token` header, then does a constant-time + * comparison against the stored token. Returns the verified participant or + * null on any failure (missing token, bad token, unknown id, rejected). + */ +function checkParticipantAuth( + req: RouteRequest, + session: CommissionedIdeasSession, +): CommissionedIdeasParticipant | null { + const body = ensurePlainObject(req.body) + const participantId = typeof body.participantId === 'string' ? body.participantId : null + if (!participantId) return null + + const raw = req.headers?.['x-commissioned-ideas-participant-token'] + const tokenHeader = Array.isArray(raw) ? raw[0] : raw + if (typeof tokenHeader !== 'string' || tokenHeader.length === 0) return null + + const participant = session.data.participantRoster[participantId] + if (!participant || participant.rejectedByInstructor) return null + + return verifyPasscode(participant.token, tokenHeader.toUpperCase()) ? participant : null +} + +function parseSocketMessage(raw: unknown): Record | null { + const text = typeof raw === 'string' + ? raw + : Buffer.isBuffer(raw) + ? raw.toString('utf8') + : null + + if (!text) { + return null + } + + try { + return ensurePlainObject(JSON.parse(text)) + } catch { + return null + } +} + +// ── Session normalizer ──────────────────────────────────────────────────────── + +registerSessionNormalizer('commissioned-ideas', (session) => { + session.data = normalizeSessionData(session.data) +}) + +// ── Session helpers ─────────────────────────────────────────────────────────── + +async function getSession( + sessions: SessionStore, + sessionId: string, +): Promise { + const session = await sessions.get(sessionId) + if (!session || session.type !== 'commissioned-ideas') return null + session.data = normalizeSessionData(session.data) + return session as CommissionedIdeasSession +} + +function buildDefaultSessionData(): CommissionedIdeasSessionData { + return { + instructorPasscode: generatePasscode(), + phase: 'registration', + studentGroupingLocked: false, + namingLocked: false, + maxTeamSize: 4, + groupingMode: 'manual', + presentationRound: 1, + allowLateRegistration: true, + teams: {}, + participantRoster: {}, + ballots: {}, + presentationHistory: [], + currentPresentationTeamId: null, + podiumRevealStep: 'hidden', + } +} + +// ── Snapshot builders ───────────────────────────────────────────────────────── + +/** + * Student-safe snapshot. Strips: + * - All ballot contents (replaced by submission count + own-ballot summary) + * - Instructor-only participant fields: connected, lastSeen, rejectedByInstructor + * - Rejected participants (hidden from peers until instructor approves/edits name) + */ +function buildStudentSnapshot(data: CommissionedIdeasSessionData, viewerParticipantId: string | null) { + const { ballots: _ballots, participantRoster, instructorPasscode: _passcode, ...rest } = data + + const myBallot = viewerParticipantId ? (_ballots[viewerParticipantId] ?? null) : null + + const safeRoster: Record = {} + for (const [id, p] of Object.entries(participantRoster)) { + if (p.rejectedByInstructor) continue + safeRoster[id] = { id: p.id, name: p.name, teamId: p.teamId } + } + + return { + ...rest, + participantRoster: safeRoster, + ballotSubmitted: Boolean(myBallot), + myBallot, + ballotsReceived: Object.keys(_ballots).length, + } +} + +/** + * Manager snapshot. Keeps the full participant roster (including connection state + * and moderation fields) but still omits raw ballot contents. + */ +function buildManagerSnapshot(data: CommissionedIdeasSessionData) { + const { ballots: _ballots, ...rest } = data + return { + ...rest, + ballotsReceived: Object.keys(_ballots).length, + } +} + +// ── Broadcast helpers ───────────────────────────────────────────────────────── + +/** + * Fan out a `commissioned-ideas:registration-updated` event to all sockets + * attached to the session. Manager sockets receive the full roster; student + * sockets receive their ballot-aware student-safe snapshot. + */ +function broadcastRegistrationUpdate( + ws: WsRouter, + sessionId: string, + data: CommissionedIdeasSessionData, +): void { + const managerPayload = JSON.stringify({ + type: 'commissioned-ideas:registration-updated', + sessionId, + data: buildManagerSnapshot(data), + }) + + for (const client of ws.wss.clients as Set) { + if (client.readyState !== 1 || client.sessionId !== sessionId) continue + try { + if (client.isManager) { + client.send(managerPayload) + } else { + const snapshot = buildStudentSnapshot(data, client.participantId ?? null) + client.send(JSON.stringify({ + type: 'commissioned-ideas:registration-updated', + sessionId, + data: snapshot, + })) + } + } catch { + // stale socket — ignored + } + } +} + +/** Generic broadcast to all session sockets (student-safe). Used for phase changes etc. */ +function broadcastToAll( + ws: WsRouter, + sessionId: string, + type: string, + data: CommissionedIdeasSessionData, +): void { + const managerPayload = JSON.stringify({ type, sessionId, data: buildManagerSnapshot(data) }) + + for (const client of ws.wss.clients as Set) { + if (client.readyState !== 1 || client.sessionId !== sessionId) continue + try { + if (client.isManager) { + client.send(managerPayload) + } else { + const snapshot = buildStudentSnapshot(data, client.participantId ?? null) + client.send(JSON.stringify({ type, sessionId, data: snapshot })) + } + } catch { + // stale socket + } + } +} + +// ── Team formation helpers ──────────────────────────────────────────────────── + +/** + * Removes `participant` from their current team's memberIds and clears teamId. + * Deletes the team record if it becomes empty. + */ +function removeParticipantFromTeam( + data: CommissionedIdeasSessionData, + participant: CommissionedIdeasParticipant, +): void { + const team = participant.teamId ? data.teams[participant.teamId] : null + if (team) { + team.memberIds = team.memberIds.filter((id) => id !== participant.id) + if (team.memberIds.length === 0) { + delete data.teams[team.id] + } + } + participant.teamId = null +} + +/** + * Randomly assigns ungrouped (and non-rejected) participants to teams, + * filling existing teams with space before creating new ones. + * + * @param reshuffleOnly When true only ungrouped participants are placed; + * when false the same logic applies (both modes act on + * ungrouped participants only — grouped ones are never + * disturbed). + */ +function assignRandom(data: CommissionedIdeasSessionData, _reshuffleOnly: boolean): void { + const ungrouped = Object.values(data.participantRoster).filter( + (p) => !p.rejectedByInstructor && p.teamId === null, + ) + if (ungrouped.length === 0) return + + // Fisher-Yates shuffle using randomInt for uniform distribution + for (let i = ungrouped.length - 1; i > 0; i--) { + const j = randomInt(0, i + 1) + const tmp = ungrouped[i]! + ungrouped[i] = ungrouped[j]! + ungrouped[j] = tmp + } + + const maxSize = data.maxTeamSize + + // Fill existing teams with available space first, then create new ones. + const openTeams = Object.values(data.teams).filter((t) => t.memberIds.length < maxSize) + + for (const participant of ungrouped) { + let target = openTeams.find((t) => t.memberIds.length < maxSize) + if (!target) { + const teamId = generateShortId() + target = { + id: teamId, + groupName: null, + projectName: null, + registeredAt: Date.now(), + presenterOrder: null, + locked: false, + memberIds: [], + proposedGroupNames: [], + proposedProjectNames: [], + groupNameVotes: {}, + projectNameVotes: {}, + } + data.teams[teamId] = target + openTeams.push(target) + } + target.memberIds.push(participant.id) + participant.teamId = target.id + } +} + +// ── Route export ────────────────────────────────────────────────────────────── + +export default function setupCommissionedIdeasRoutes( + app: CommissionedIdeasRouteApp, + sessions: SessionStore, + ws: WsRouter, +): void { + const ensureBroadcastSubscription = createBroadcastSubscriptionHelper(sessions, ws) + + // ── Create session ────────────────────────────────────────────────────────── + app.post('/api/commissioned-ideas/create', async (_req, res) => { + const session = await createSession(sessions, { data: {} }) + session.type = 'commissioned-ideas' + session.data = buildDefaultSessionData() + await sessions.set(session.id, session) + console.log(`[commissioned-ideas] Session created: ${session.id}`) + res.json({ id: session.id, instructorPasscode: session.data.instructorPasscode }) + }) + + // ── Student-safe state snapshot ───────────────────────────────────────────── + // `participantId` query param gives the viewer their own ballot summary. + app.get('/api/commissioned-ideas/:sessionId/state', async (req, res) => { + const { sessionId } = req.params + if (!sessionId) { + res.status(400).json({ error: 'Missing sessionId' }) + return + } + + const session = await getSession(sessions, sessionId) + if (!session) { + res.status(404).json({ error: 'Session not found' }) + return + } + + // Always pass null: ballot context belongs to the WS channel, where the + // server binds participantId at socket-open time. Accepting an arbitrary + // query param here would let any student read another participant's ballot. + const snapshot = buildStudentSnapshot(session.data, null) + res.json({ sessionId, data: snapshot }) + }) + + // ── Register / reconnect participant ──────────────────────────────────────── + app.post('/api/commissioned-ideas/:sessionId/register-participant', async (req, res) => { + const { sessionId } = req.params + if (!sessionId) { + res.status(400).json({ error: 'Missing sessionId' }) + return + } + + const session = await getSession(sessions, sessionId) + if (!session) { + res.status(404).json({ error: 'Session not found' }) + return + } + + const body = ensurePlainObject(req.body) + const name = sanitizeDisplayName(body.name, 100) + if (!name) { + res.status(400).json({ error: 'name is required' }) + return + } + + // Accept a client-provided id for reconnect; generate one for new registrations. + const requestedId = + typeof body.participantId === 'string' && /^[A-Z0-9]{6,12}$/i.test(body.participantId) + ? body.participantId + : null + const existing = requestedId ? session.data.participantRoster[requestedId] : null + + let participantId: string + let participantToken: string + if (existing) { + // Reconnect: mark connected only. The server-side name is authoritative — + // overwriting it here would silently undo instructor moderation applied + // between the student's last visit and this reconnect. + participantId = existing.id + participantToken = existing.token + existing.connected = true + existing.lastSeen = Date.now() + } else { + // New registration + participantId = requestedId ?? generateShortId() + participantToken = generatePasscode() + const participant: CommissionedIdeasParticipant = { + id: participantId, + name, + teamId: null, + connected: true, + lastSeen: Date.now(), + rejectedByInstructor: false, + token: participantToken, + } + session.data.participantRoster[participantId] = participant + } + + await sessions.set(sessionId, session) + console.info(`[commissioned-ideas] Participant registered`, { sessionId, participantId, name }) + + broadcastRegistrationUpdate(ws, sessionId, session.data) + res.json({ participantId, name, token: participantToken }) + }) + + // ── Instructor: edit or reject a participant name ─────────────────────────── + app.post('/api/commissioned-ideas/:sessionId/participant-name', async (req, res) => { + const { sessionId } = req.params + if (!sessionId) { + res.status(400).json({ error: 'Missing sessionId' }) + return + } + + const session = await getSession(sessions, sessionId) + if (!session) { + res.status(404).json({ error: 'Session not found' }) + return + } + + if (!checkInstructorAuth(req, session)) { + res.status(403).json({ error: 'Instructor authentication required' }) + return + } + + const body = ensurePlainObject(req.body) + const participantId = typeof body.participantId === 'string' ? body.participantId : null + if (!participantId) { + res.status(400).json({ error: 'participantId is required' }) + return + } + + const participant = session.data.participantRoster[participantId] + if (!participant) { + res.status(404).json({ error: 'Participant not found' }) + return + } + + if ('name' in body) { + const newName = sanitizeDisplayName(body.name, 100) + if (!newName) { + res.status(400).json({ error: 'name must be a non-empty string' }) + return + } + participant.name = newName + // Editing the name clears a prior rejection so the student is visible again. + participant.rejectedByInstructor = false + } + + if ('rejected' in body) { + participant.rejectedByInstructor = Boolean(body.rejected) + } + + await sessions.set(sessionId, session) + console.info(`[commissioned-ideas] Participant name moderated`, { sessionId, participantId }) + + broadcastRegistrationUpdate(ws, sessionId, session.data) + res.json({ ok: true }) + }) + + // ── Instructor: update session settings ──────────────────────────────────── + app.post('/api/commissioned-ideas/:sessionId/settings', async (req, res) => { + const { sessionId } = req.params + if (!sessionId) { res.status(400).json({ error: 'Missing sessionId' }); return } + + const session = await getSession(sessions, sessionId) + if (!session) { res.status(404).json({ error: 'Session not found' }); return } + + if (!checkInstructorAuth(req, session)) { + res.status(403).json({ error: 'Instructor authentication required' }) + return + } + + const body = ensurePlainObject(req.body) + if ('maxTeamSize' in body) { + const v = body.maxTeamSize + if (typeof v !== 'number' || !Number.isInteger(v) || v < 1) { + res.status(400).json({ error: 'maxTeamSize must be a positive integer' }) + return + } + session.data.maxTeamSize = v + } + if ('groupingMode' in body) { + session.data.groupingMode = normalizeGroupingMode(body.groupingMode) + } + if ('studentGroupingLocked' in body) { + session.data.studentGroupingLocked = Boolean(body.studentGroupingLocked) + } + if ('namingLocked' in body) { + session.data.namingLocked = Boolean(body.namingLocked) + } + if ('allowLateRegistration' in body) { + session.data.allowLateRegistration = Boolean(body.allowLateRegistration) + } + + await sessions.set(sessionId, session) + console.info('[commissioned-ideas] Settings updated', { sessionId }) + broadcastToAll(ws, sessionId, 'commissioned-ideas:registration-updated', session.data) + res.json({ ok: true }) + }) + + // ── Student: create a new team and join it ────────────────────────────────── + app.post('/api/commissioned-ideas/:sessionId/create-team', async (req, res) => { + const { sessionId } = req.params + if (!sessionId) { res.status(400).json({ error: 'Missing sessionId' }); return } + + const session = await getSession(sessions, sessionId) + if (!session) { res.status(404).json({ error: 'Session not found' }); return } + + const participant = checkParticipantAuth(req, session) + if (!participant) { + res.status(403).json({ error: 'Participant authentication required' }) + return + } + + if (session.data.studentGroupingLocked) { + res.status(403).json({ error: 'Grouping is locked' }) + return + } + + if (session.data.groupingMode !== 'manual') { + res.status(403).json({ error: 'Self-grouping is not allowed in random mode' }) + return + } + + const participantId = participant.id + + // Leave current team if already in one + if (participant.teamId) { + removeParticipantFromTeam(session.data, participant) + } + + const teamId = generateShortId() + session.data.teams[teamId] = { + id: teamId, + groupName: null, + projectName: null, + registeredAt: Date.now(), + presenterOrder: null, + locked: false, + memberIds: [participantId], + proposedGroupNames: [], + proposedProjectNames: [], + groupNameVotes: {}, + projectNameVotes: {}, + } + participant.teamId = teamId + + await sessions.set(sessionId, session) + console.info('[commissioned-ideas] Team created', { sessionId, teamId, participantId }) + broadcastRegistrationUpdate(ws, sessionId, session.data) + res.json({ teamId }) + }) + + // ── Student: join an existing team ────────────────────────────────────────── + app.post('/api/commissioned-ideas/:sessionId/join-team', async (req, res) => { + const { sessionId } = req.params + if (!sessionId) { res.status(400).json({ error: 'Missing sessionId' }); return } + + const session = await getSession(sessions, sessionId) + if (!session) { res.status(404).json({ error: 'Session not found' }); return } + + const participant = checkParticipantAuth(req, session) + if (!participant) { + res.status(403).json({ error: 'Participant authentication required' }) + return + } + + const body = ensurePlainObject(req.body) + const teamId = typeof body.teamId === 'string' ? body.teamId : null + if (!teamId) { res.status(400).json({ error: 'teamId is required' }); return } + + if (session.data.studentGroupingLocked) { + res.status(403).json({ error: 'Grouping is locked' }) + return + } + + if (session.data.groupingMode !== 'manual') { + res.status(403).json({ error: 'Self-grouping is not allowed in random mode' }) + return + } + + const participantId = participant.id + + const team = session.data.teams[teamId] + if (!team) { res.status(404).json({ error: 'Team not found' }); return } + + const memberCount = team.memberIds.filter((id) => id in session.data.participantRoster).length + if (memberCount >= session.data.maxTeamSize) { + res.status(409).json({ error: 'Team is full' }) + return + } + + // Leave current team first + if (participant.teamId) { + removeParticipantFromTeam(session.data, participant) + } + + team.memberIds.push(participantId) + participant.teamId = teamId + + await sessions.set(sessionId, session) + console.info('[commissioned-ideas] Participant joined team', { sessionId, teamId, participantId }) + broadcastRegistrationUpdate(ws, sessionId, session.data) + res.json({ ok: true }) + }) + + // ── Student: leave current team ───────────────────────────────────────────── + app.post('/api/commissioned-ideas/:sessionId/leave-team', async (req, res) => { + const { sessionId } = req.params + if (!sessionId) { res.status(400).json({ error: 'Missing sessionId' }); return } + + const session = await getSession(sessions, sessionId) + if (!session) { res.status(404).json({ error: 'Session not found' }); return } + + const participant = checkParticipantAuth(req, session) + if (!participant) { + res.status(403).json({ error: 'Participant authentication required' }) + return + } + + if (session.data.studentGroupingLocked) { + res.status(403).json({ error: 'Grouping is locked' }) + return + } + + if (session.data.groupingMode !== 'manual') { + res.status(403).json({ error: 'Self-grouping is not allowed in random mode' }) + return + } + + if (!participant.teamId) { + res.status(409).json({ error: 'Participant is not in a team' }) + return + } + + const participantId = participant.id + + removeParticipantFromTeam(session.data, participant) + + await sessions.set(sessionId, session) + console.info('[commissioned-ideas] Participant left team', { sessionId, participantId }) + broadcastRegistrationUpdate(ws, sessionId, session.data) + res.json({ ok: true }) + }) + + // ── Instructor: assign participant to a team (or remove with teamId=null) ─── + app.post('/api/commissioned-ideas/:sessionId/assign-participant', async (req, res) => { + const { sessionId } = req.params + if (!sessionId) { res.status(400).json({ error: 'Missing sessionId' }); return } + + const session = await getSession(sessions, sessionId) + if (!session) { res.status(404).json({ error: 'Session not found' }); return } + + if (!checkInstructorAuth(req, session)) { + res.status(403).json({ error: 'Instructor authentication required' }) + return + } + + const body = ensurePlainObject(req.body) + const participantId = typeof body.participantId === 'string' ? body.participantId : null + if (!participantId) { res.status(400).json({ error: 'participantId is required' }); return } + + const participant = session.data.participantRoster[participantId] + if (!participant) { res.status(404).json({ error: 'Participant not found' }); return } + + const targetTeamId = typeof body.teamId === 'string' ? body.teamId : null + + if (targetTeamId !== null) { + const team = session.data.teams[targetTeamId] + if (!team) { res.status(404).json({ error: 'Team not found' }); return } + + const memberCount = team.memberIds.filter((id) => id in session.data.participantRoster).length + if (memberCount >= session.data.maxTeamSize && participant.teamId !== targetTeamId) { + res.status(409).json({ error: 'Team is full' }) + return + } + } + + if (participant.teamId) { + removeParticipantFromTeam(session.data, participant) + } + + if (targetTeamId !== null) { + const team = session.data.teams[targetTeamId] + if (team) { + team.memberIds.push(participantId) + participant.teamId = targetTeamId + } + } + + await sessions.set(sessionId, session) + console.info('[commissioned-ideas] Instructor assigned participant', { sessionId, participantId, targetTeamId }) + broadcastRegistrationUpdate(ws, sessionId, session.data) + res.json({ ok: true }) + }) + + // ── Instructor: randomly assign ungrouped participants ────────────────────── + // When reshuffleOnly=false (default), assigns all ungrouped participants. + // When reshuffleOnly=true, only places ungrouped participants (post-lock mode). + app.post('/api/commissioned-ideas/:sessionId/assign-random', async (req, res) => { + const { sessionId } = req.params + if (!sessionId) { res.status(400).json({ error: 'Missing sessionId' }); return } + + const session = await getSession(sessions, sessionId) + if (!session) { res.status(404).json({ error: 'Session not found' }); return } + + if (!checkInstructorAuth(req, session)) { + res.status(403).json({ error: 'Instructor authentication required' }) + return + } + + const body = ensurePlainObject(req.body) + const reshuffleOnly = Boolean(body.reshuffleOnly) + + assignRandom(session.data, reshuffleOnly) + + await sessions.set(sessionId, session) + console.info('[commissioned-ideas] Random assignment complete', { sessionId, reshuffleOnly }) + broadcastRegistrationUpdate(ws, sessionId, session.data) + res.json({ ok: true }) + }) + + // ── WebSocket ─────────────────────────────────────────────────────────────── + ws.register('/ws/commissioned-ideas', (socket, query) => { + const sessionId = query.get('sessionId') + if (!sessionId) { + socket.close(1008, 'Missing sessionId') + return + } + + const typedSocket = socket as CommissionedIdeasSocket + typedSocket.sessionId = sessionId + typedSocket.participantId = query.get('participantId') ?? null + typedSocket.wantsManager = query.get('role') === 'manager' + + ensureBroadcastSubscription(sessionId) + + ;(async () => { + const session = await getSession(sessions, sessionId) + if (!session) { + socket.send(JSON.stringify({ type: 'commissioned-ideas:error', error: 'Session not found' })) + socket.close(1008, 'Session not found') + return + } + + const participantId = typedSocket.participantId + if (participantId && session.data.participantRoster[participantId]) { + const p = session.data.participantRoster[participantId] + p.connected = true + p.lastSeen = Date.now() + await sessions.set(sessionId, session) + broadcastRegistrationUpdate(ws, sessionId, session.data) + } + + if (!typedSocket.wantsManager) { + socket.send(JSON.stringify({ + type: 'commissioned-ideas:session-state', + sessionId, + data: buildStudentSnapshot(session.data, participantId ?? null), + })) + } + })().catch((err: unknown) => { + console.error('[commissioned-ideas] WS init error', err) + socket.send(JSON.stringify({ type: 'commissioned-ideas:error', error: 'Failed to load session' })) + }) + + socket.on('message', (raw: unknown) => { + void (async () => { + if (typedSocket.isManager || !typedSocket.wantsManager) { + return + } + + const message = parseSocketMessage(raw) + if (message?.type !== 'commissioned-ideas:manager-auth') { + return + } + + const session = await getSession(sessions, sessionId) + if (!session) { + socket.send(JSON.stringify({ type: 'commissioned-ideas:error', error: 'Session not found' })) + socket.close(1008, 'Session not found') + return + } + + const candidatePasscode = normalizeInstructorPasscode(message.instructorPasscode) + if (!candidatePasscode || !verifyPasscode(session.data.instructorPasscode, candidatePasscode)) { + socket.send(JSON.stringify({ type: 'commissioned-ideas:error', error: 'Invalid instructor passcode' })) + socket.close(1008, 'Invalid instructor passcode') + return + } + + typedSocket.isManager = true + socket.send(JSON.stringify({ + type: 'commissioned-ideas:session-state', + sessionId, + data: buildManagerSnapshot(session.data), + })) + })().catch((err: unknown) => { + console.error('[commissioned-ideas] WS manager auth error', err) + socket.send(JSON.stringify({ type: 'commissioned-ideas:error', error: 'Failed to authenticate manager' })) + }) + }) + + socket.on('close', () => { + const participantId = typedSocket.participantId + if (!participantId) return + + void (async () => { + const session = await getSession(sessions, sessionId) + if (!session) return + const p = session.data.participantRoster[participantId] + if (!p) return + p.connected = false + p.lastSeen = Date.now() + await sessions.set(sessionId, session) + broadcastRegistrationUpdate(ws, sessionId, session.data) + })() + }) + }) +} + +// Re-export helpers used by other modules (Phase 2+) +export { + getSession, + normalizeSessionData, + normalizeTeam, + buildStudentSnapshot, + buildManagerSnapshot, + broadcastRegistrationUpdate, + broadcastToAll, + removeParticipantFromTeam, + assignRandom, +} diff --git a/activities/commissioned-ideas/shared/id.ts b/activities/commissioned-ideas/shared/id.ts new file mode 100644 index 00000000..49645d3e --- /dev/null +++ b/activities/commissioned-ideas/shared/id.ts @@ -0,0 +1,19 @@ +const ID_ALPHABET = 'BCDFGHJKLMNPQRSTVWXYZ23456789' +const BYTE_RANGE = 256 +const ACCEPTANCE_BOUND = Math.floor(BYTE_RANGE / ID_ALPHABET.length) * ID_ALPHABET.length + +export function generateShortId(length = 8): string { + if (length <= 0) return '' + + let out = '' + while (out.length < length) { + const chunk = new Uint8Array((length - out.length) * 2) + crypto.getRandomValues(chunk) + for (const value of chunk) { + if (value >= ACCEPTANCE_BOUND) continue + out += ID_ALPHABET.charAt(value % ID_ALPHABET.length) + if (out.length === length) break + } + } + return out +} diff --git a/activities/commissioned-ideas/shared/scoring.ts b/activities/commissioned-ideas/shared/scoring.ts new file mode 100644 index 00000000..40be15c5 --- /dev/null +++ b/activities/commissioned-ideas/shared/scoring.ts @@ -0,0 +1,78 @@ +import type { + CommissionedIdeasBallot, + CommissionedIdeasTeam, + TeamScore, +} from './types.js' + +export function computeTeamScores( + teams: Record, + ballots: Record, +): TeamScore[] { + const scoreMap = new Map() + + for (const team of Object.values(teams)) { + scoreMap.set(team.id, { + teamId: team.id, + groupName: team.groupName, + projectName: team.projectName, + totalDollars: 0, + fiveHundredCount: 0, + threeHundredCount: 0, + oneHundredCount: 0, + registeredAt: team.registeredAt, + }) + } + + for (const ballot of Object.values(ballots)) { + for (const allocation of ballot.allocations) { + const score = scoreMap.get(allocation.teamId) + if (!score) continue + score.totalDollars += allocation.amount + if (allocation.amount === 500) score.fiveHundredCount++ + else if (allocation.amount === 300) score.threeHundredCount++ + else if (allocation.amount === 100) score.oneHundredCount++ + } + } + + const scores = Array.from(scoreMap.values()) + scores.sort((a, b) => { + if (b.totalDollars !== a.totalDollars) return b.totalDollars - a.totalDollars + if (b.fiveHundredCount !== a.fiveHundredCount) return b.fiveHundredCount - a.fiveHundredCount + if (b.threeHundredCount !== a.threeHundredCount) return b.threeHundredCount - a.threeHundredCount + return a.registeredAt - b.registeredAt + }) + + return scores +} + +export function resolveLeadingProposal( + proposals: { id: string; value: string; rejectedByInstructor: boolean }[], + votes: Record, +): string | null { + const eligible = proposals.filter((p) => !p.rejectedByInstructor) + if (eligible.length === 0) return null + + const tally = new Map() + for (const proposal of eligible) { + tally.set(proposal.id, 0) + } + + for (const proposalId of Object.values(votes)) { + if (tally.has(proposalId)) { + tally.set(proposalId, (tally.get(proposalId) ?? 0) + 1) + } + } + + let leader: { id: string; value: string } | null = null + let leaderVotes = -1 + + for (const proposal of eligible) { + const count = tally.get(proposal.id) ?? 0 + if (count > leaderVotes || (count === leaderVotes && leader && proposal.id < leader.id)) { + leader = proposal + leaderVotes = count + } + } + + return leader?.value ?? null +} diff --git a/activities/commissioned-ideas/shared/types.ts b/activities/commissioned-ideas/shared/types.ts new file mode 100644 index 00000000..32b48229 --- /dev/null +++ b/activities/commissioned-ideas/shared/types.ts @@ -0,0 +1,94 @@ +export type CommissionedIdeasPhase = 'registration' | 'presentation' | 'voting' | 'results' + +export type PodiumRevealStep = 'hidden' | 'third' | 'second' | 'winner' | 'complete' + +export type GroupingMode = 'manual' | 'random' + +export interface NameProposal { + id: string + value: string + proposedByParticipantId: string + createdAt: number + rejectedByInstructor: boolean +} + +export interface CommissionedIdeasTeam { + id: string + groupName: string | null + projectName: string | null + registeredAt: number + presenterOrder: number | null + locked: boolean + memberIds: string[] + proposedGroupNames: NameProposal[] + proposedProjectNames: NameProposal[] + /** Maps participantId -> proposalId */ + groupNameVotes: Record + /** Maps participantId -> proposalId */ + projectNameVotes: Record +} + +export interface CommissionedIdeasParticipant { + id: string + name: string + teamId: string | null + connected: boolean + lastSeen: number + rejectedByInstructor: boolean + /** Server-only secret issued at registration; never included in any client snapshot. */ + token: string +} + +/** Student-visible participant shape — no moderation or connection metadata. */ +export interface StudentSafeParticipant { + id: string + name: string + teamId: string | null +} + +export interface BallotAllocation { + teamId: string + amount: 100 | 300 | 500 +} + +export interface CommissionedIdeasBallot { + voterId: string + voterName: string + voterTeamId: string | null + allocations: BallotAllocation[] + submittedAt: number +} + +export interface PresentationHistoryEntry { + round: number + teamId: string + presentedAt: number +} + +export interface CommissionedIdeasSessionData extends Record { + instructorPasscode: string + phase: CommissionedIdeasPhase + studentGroupingLocked: boolean + namingLocked: boolean + maxTeamSize: number + groupingMode: GroupingMode + presentationRound: number + allowLateRegistration: boolean + teams: Record + participantRoster: Record + ballots: Record + presentationHistory: PresentationHistoryEntry[] + currentPresentationTeamId: string | null + podiumRevealStep: PodiumRevealStep +} + +export interface TeamScore { + teamId: string + groupName: string | null + projectName: string | null + totalDollars: number + fiveHundredCount: number + threeHundredCount: number + oneHundredCount: number + registeredAt: number +} diff --git a/activities/commissioned-ideas/shared/validation.ts b/activities/commissioned-ideas/shared/validation.ts new file mode 100644 index 00000000..5689a6d6 --- /dev/null +++ b/activities/commissioned-ideas/shared/validation.ts @@ -0,0 +1,92 @@ +import type { BallotAllocation, CommissionedIdeasSessionData } from './types.js' + +export const BALLOT_AMOUNTS = [100, 300, 500] as const +export type BallotAmount = (typeof BALLOT_AMOUNTS)[number] + +export function sanitizeDisplayName(value: unknown, maxLength = 100): string | null { + if (typeof value !== 'string') return null + const trimmed = value.trim() + if (trimmed.length === 0) return null + return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed +} + +export interface BallotValidationResult { + valid: boolean + error?: string +} + +export function validateBallot( + allocations: unknown, + voterId: string, + session: CommissionedIdeasSessionData, + allowSelfVote = false, +): BallotValidationResult { + if (!Array.isArray(allocations) || allocations.length !== 3) { + return { valid: false, error: 'Ballot must contain exactly three allocations' } + } + + const amounts = new Set() + const teamIds = new Set() + + for (const alloc of allocations) { + if ( + typeof alloc !== 'object' || + alloc === null || + !('teamId' in alloc) || + !('amount' in alloc) + ) { + return { valid: false, error: 'Invalid allocation entry' } + } + + const { teamId, amount } = alloc as Record + + if (typeof teamId !== 'string' || !session.teams[teamId]) { + return { valid: false, error: `Unknown team: ${String(teamId)}` } + } + + if (amount !== 100 && amount !== 300 && amount !== 500) { + return { valid: false, error: `Invalid amount: ${String(amount)}` } + } + + if (amounts.has(amount as number)) { + return { valid: false, error: `Duplicate amount $${String(amount)}` } + } + + if (teamIds.has(teamId as string)) { + return { valid: false, error: 'All three teams must be distinct' } + } + + amounts.add(amount as number) + teamIds.add(teamId as string) + } + + if (!amounts.has(100) || !amounts.has(300) || !amounts.has(500)) { + return { valid: false, error: 'Ballot must include $100, $300, and $500' } + } + + if (!allowSelfVote) { + const voter = session.participantRoster[voterId] + if (voter?.teamId && teamIds.has(voter.teamId)) { + return { valid: false, error: 'Cannot vote for your own team' } + } + } + + return { valid: true } +} + +export function isValidBallotAmount(amount: unknown): amount is BallotAmount { + return amount === 100 || amount === 300 || amount === 500 +} + +export function coerceAllocations(raw: unknown): BallotAllocation[] | null { + if (!Array.isArray(raw)) return null + const result: BallotAllocation[] = [] + for (const item of raw) { + if (typeof item !== 'object' || item === null) return null + const { teamId, amount } = item as Record + if (typeof teamId !== 'string') return null + if (!isValidBallotAmount(amount)) return null + result.push({ teamId, amount }) + } + return result +} diff --git a/client/src/activities/index.test.ts b/client/src/activities/index.test.ts index 98d761b2..01a4e4cb 100644 --- a/client/src/activities/index.test.ts +++ b/client/src/activities/index.test.ts @@ -16,6 +16,7 @@ const __dirname = dirname(__filename) const EXPECTED_ACTIVITIES = [ 'algorithm-demo', + 'commissioned-ideas', 'resonance', 'syncdeck', 'video-sync', diff --git a/client/src/components/common/ActivityLauncher.tsx b/client/src/components/common/ActivityLauncher.tsx index 53611864..0e522814 100644 --- a/client/src/components/common/ActivityLauncher.tsx +++ b/client/src/components/common/ActivityLauncher.tsx @@ -12,6 +12,7 @@ import { } from './activityLauncherUtils' import { persistCreateSessionBootstrapToSessionStorage, + shouldPersistCreateSessionBootstrapPayloadToSessionStorage, storeCreateSessionBootstrapPayload, } from './manageDashboardUtils' @@ -65,7 +66,9 @@ function ActivityLauncherBody({ persistCreateSessionBootstrapToSessionStorage(activity.createSessionBootstrap, payload.id, payload) if (navigationState != null) { - storeCreateSessionBootstrapPayload(activity.id, payload.id, navigationState.createSessionPayload) + storeCreateSessionBootstrapPayload(activity.id, payload.id, navigationState.createSessionPayload, undefined, { + persistToSessionStorage: shouldPersistCreateSessionBootstrapPayloadToSessionStorage(activity.createSessionBootstrap), + }) } setStatus('started') diff --git a/client/src/components/common/ManageDashboard.tsx b/client/src/components/common/ManageDashboard.tsx index e6b45b27..f461c939 100644 --- a/client/src/components/common/ManageDashboard.tsx +++ b/client/src/components/common/ManageDashboard.tsx @@ -26,6 +26,7 @@ import { parseDeepLinkGenerator, parseDeepLinkOptions, resolvePersistentLinkPreflightValue, + shouldPersistCreateSessionBootstrapPayloadToSessionStorage, validateDeepLinkSelection, type DeepLinkSelection, } from './manageDashboardUtils' @@ -272,7 +273,9 @@ export default function ManageDashboard({ persistCreateSessionBootstrapToSessionStorage(activity?.createSessionBootstrap, payload.id, payload) if (navigationState != null) { - storeCreateSessionBootstrapPayload(activityId, payload.id, navigationState.createSessionPayload) + storeCreateSessionBootstrapPayload(activityId, payload.id, navigationState.createSessionPayload, undefined, { + persistToSessionStorage: shouldPersistCreateSessionBootstrapPayloadToSessionStorage(activity?.createSessionBootstrap), + }) } void navigate(buildStandaloneActivityLauncherManagePath(activityId, payload.id, {}), { diff --git a/client/src/components/common/manageDashboardUtils.test.ts b/client/src/components/common/manageDashboardUtils.test.ts index a4c91b6c..eedddea4 100644 --- a/client/src/components/common/manageDashboardUtils.test.ts +++ b/client/src/components/common/manageDashboardUtils.test.ts @@ -18,6 +18,7 @@ import { parseDeepLinkGenerator, persistCreateSessionBootstrapToSessionStorage, parseDeepLinkOptions, + shouldPersistCreateSessionBootstrapPayloadToSessionStorage, storeCreateSessionBootstrapPayload, validateDeepLinkSelection, } from './manageDashboardUtils' @@ -319,16 +320,35 @@ void test('parseCreateSessionBootstrap validates sessionStorage bootstrap metada { keyPrefix: 'x_', responseField: '' }, ], historyState: [' instructorPasscode ', '', 42], + allowSessionStorageFallback: false, }), { sessionStorage: [ { keyPrefix: 'syncdeck_instructor_', responseField: 'instructorPasscode' }, ], historyState: ['instructorPasscode'], + allowSessionStorageFallback: false, }, ) }) +void test('shouldPersistCreateSessionBootstrapPayloadToSessionStorage respects explicit opt-out', () => { + assert.equal( + shouldPersistCreateSessionBootstrapPayloadToSessionStorage({ + historyState: ['instructorPasscode'], + allowSessionStorageFallback: false, + }), + false, + ) + + assert.equal( + shouldPersistCreateSessionBootstrapPayloadToSessionStorage({ + historyState: ['instructorPasscode'], + }), + true, + ) +}) + void test('persistCreateSessionBootstrapToSessionStorage stores declared create response fields', () => { const originalWindow = globalThis.window const { backing: writes, storage: fakeSessionStorage } = createFakeSessionStorage() @@ -473,6 +493,49 @@ void test('consumeCreateSessionBootstrapPayload clears sessionStorage even when } }) +void test('storeCreateSessionBootstrapPayload skips sessionStorage when fallback is disabled', () => { + const originalWindow = globalThis.window + const { backing: sessionStorage, storage: fakeSessionStorage } = createFakeSessionStorage() + + Object.defineProperty(globalThis, 'window', { + value: { + sessionStorage: fakeSessionStorage, + }, + configurable: true, + writable: true, + }) + + try { + storeCreateSessionBootstrapPayload( + 'commissioned-ideas', + 'session-123', + { + instructorPasscode: 'teacher-passcode', + }, + 10, + { persistToSessionStorage: false }, + ) + + assert.equal( + sessionStorage.has('create-session-bootstrap:commissioned-ideas:session-123'), + false, + ) + + assert.deepEqual( + consumeCreateSessionBootstrapPayload('commissioned-ideas', 'session-123', 10), + { + instructorPasscode: 'teacher-passcode', + }, + ) + } finally { + Object.defineProperty(globalThis, 'window', { + value: originalWindow, + configurable: true, + writable: true, + }) + } +}) + void test('consumeCreateSessionBootstrapPayload falls back to sessionStorage for iframe/bootstrap reload contexts', () => { const originalWindow = globalThis.window const { backing: sessionStorage, storage: fakeSessionStorage } = createFakeSessionStorage() diff --git a/client/src/components/common/manageDashboardUtils.ts b/client/src/components/common/manageDashboardUtils.ts index 52c35527..140d8d32 100644 --- a/client/src/components/common/manageDashboardUtils.ts +++ b/client/src/components/common/manageDashboardUtils.ts @@ -38,6 +38,7 @@ export interface CreateSessionBootstrapSessionStorageEntry { export interface CreateSessionBootstrapConfig { sessionStorage: CreateSessionBootstrapSessionStorageEntry[] historyState?: string[] + allowSessionStorageFallback?: boolean } export type DeepLinkOptions = Record @@ -345,6 +346,8 @@ export function parseCreateSessionBootstrap(rawCreateSessionBootstrap: unknown): .filter((entry) => entry.length > 0) : [] + const allowSessionStorageFallback = rawCreateSessionBootstrap.allowSessionStorageFallback !== false + if (sessionStorage.length === 0 && historyState.length === 0) { return null } @@ -352,9 +355,14 @@ export function parseCreateSessionBootstrap(rawCreateSessionBootstrap: unknown): return { sessionStorage, ...(historyState.length > 0 ? { historyState } : {}), + ...(allowSessionStorageFallback ? {} : { allowSessionStorageFallback: false }), } } +export function shouldPersistCreateSessionBootstrapPayloadToSessionStorage(rawCreateSessionBootstrap: unknown): boolean { + return parseCreateSessionBootstrap(rawCreateSessionBootstrap)?.allowSessionStorageFallback !== false +} + export function persistCreateSessionBootstrapToSessionStorage( rawCreateSessionBootstrap: unknown, sessionId: string, @@ -388,12 +396,17 @@ export function storeCreateSessionBootstrapPayload( sessionId: string, payload: Record, nowMs = Date.now(), + options?: { + persistToSessionStorage?: boolean + }, ): void { createSessionBootstrapPayloads.set(`${activityId}:${sessionId}`, { payload, createdAtMs: nowMs, }) - persistCreateSessionBootstrapPayloadToSessionStorage(activityId, sessionId, payload, nowMs, nowMs) + if (options?.persistToSessionStorage !== false) { + persistCreateSessionBootstrapPayloadToSessionStorage(activityId, sessionId, payload, nowMs, nowMs) + } pruneCreateSessionBootstrapPayloads(nowMs) } diff --git a/server/activities/activityRegistry.test.ts b/server/activities/activityRegistry.test.ts index 8ed19be0..aea2c588 100644 --- a/server/activities/activityRegistry.test.ts +++ b/server/activities/activityRegistry.test.ts @@ -19,6 +19,7 @@ let testImportCounter = 0 */ const EXPECTED_ACTIVITIES = [ 'algorithm-demo', + 'commissioned-ideas', 'resonance', 'syncdeck', 'video-sync', diff --git a/types/activity.ts b/types/activity.ts index 288bf523..71b13ab6 100644 --- a/types/activity.ts +++ b/types/activity.ts @@ -67,6 +67,7 @@ export interface ActivityCreateSessionBootstrapSessionStorageEntry { export interface ActivityCreateSessionBootstrapConfig { sessionStorage?: ActivityCreateSessionBootstrapSessionStorageEntry[] historyState?: string[] + allowSessionStorageFallback?: boolean selectedOptionsToSessionData?: string[] } diff --git a/types/activityConfigSchema.ts b/types/activityConfigSchema.ts index f19dff29..759b7146 100644 --- a/types/activityConfigSchema.ts +++ b/types/activityConfigSchema.ts @@ -255,6 +255,10 @@ function parseCreateSessionBootstrap(raw: unknown, context: string): ActivityCre const sessionStorage = parseCreateSessionBootstrapSessionStorage(raw.sessionStorage, `${context}.createSessionBootstrap`) const historyState = parseCreateSessionBootstrapHistoryState(raw.historyState, `${context}.createSessionBootstrap`) + const allowSessionStorageFallback = raw.allowSessionStorageFallback + if (allowSessionStorageFallback !== undefined && typeof allowSessionStorageFallback !== 'boolean') { + throw new Error(`${context}.createSessionBootstrap.allowSessionStorageFallback must be a boolean when provided`) + } const selectedOptionsToSessionData = parseCreateSessionBootstrapSelectedOptionsToSessionData( raw.selectedOptionsToSessionData, `${context}.createSessionBootstrap`, @@ -262,6 +266,7 @@ function parseCreateSessionBootstrap(raw: unknown, context: string): ActivityCre return { ...(sessionStorage !== undefined ? { sessionStorage } : {}), ...(historyState !== undefined ? { historyState } : {}), + ...(allowSessionStorageFallback !== undefined ? { allowSessionStorageFallback } : {}), ...(selectedOptionsToSessionData !== undefined ? { selectedOptionsToSessionData } : {}), } }