Files
joshuaandClaude Opus 5.5 825eb14f2e Phase 13: questions, sounds and videos on the scoreboard
A game can now carry a list of items the host puts on the scoreboard one at
a time: a question (shown above the standings, with an optional picture and
an answer to reveal), a sound (played through the scoreboard's speakers) or
a video such as a karaoke track (takes over the screen).

- Media is a YouTube link, an https link to a file, or an upload to the
  private game-media bucket. The scoreboard API signs a URL only for the
  item that is on screen.
- What is on screen is state on the event, like the timer, so the ping
  still carries no data. An unrevealed answer and a sound's or video's name
  never reach the scoreboard payload.
- Control room: click an item to show it, or "Random" to have one picked
  (prefers items not played yet). Show/hide answer, pause, take off screen;
  shortcuts Q / A / X.
- Duplicating a game or event copies its items and files; deleting removes
  the files.

Needs supabase/migrations/0006_screen_items.sql applied before deploying.
database.types.ts was edited by hand to match; re-run gen:types after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:54:08 +02:00

162 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architecture
## Stack
Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast) · Tailwind · zod · Vitest.
## Routes
| Route | Who | Notes |
|---|---|---|
| `/login` | admin | Supabase magic link |
| `/admin` | admin | Events list |
| `/admin/events/[eventId]` | admin | Setup: teams, games, criteria, judges, share links, co-hosts |
| `/admin/events/[eventId]/live` | admin | Control room |
| `/s/[scoreboardToken]` | public | Scoreboard |
| `/j/[judgeToken]` | judge | Personal judge page |
| `/jo/[openToken]` | judge | Open link → name form |
| `GET /api/public/scoreboard/[token]` | public | Sanitised scoreboard payload |
| `GET /api/judge/[token]` | judge | Game, criteria, teams, this judge's scores |
| `PUT /api/judge/[token]/scores` | judge | Upsert one score `{teamId, criterionId, value}` |
| `POST /api/judge-open/[openToken]` | judge | `{name}` → `{judgeToken}` |
| `/b/[buzzerToken]` | team | Team buzzer page |
| `GET /api/buzzer/[token]` | team | Team, current game, buzz order |
| `POST /api/buzzer/[token]/buzz` | team | Buzz in (no body) → the same payload |
Admin mutations = server actions. `src/proxy.ts` (Next.js 16's name for middleware) protects `/admin/*`: must be logged in AND email in `ADMIN_EMAILS`. Every server action re-checks via `requireAdmin()`.
## Supabase clients (`src/lib/supabase/`)
- `browser.ts` — anon key, used for Realtime subscribe + admin auth session (which is also what
uploads an admin's media file straight to storage).
- `server.ts` — cookie-based, for admin server actions (RLS applies as the admin user).
- `admin.ts` — service role, **server-only** (`import 'server-only'`). Used by public/judge route handlers after token validation.
## Security model
- RLS ON for every table. Policies only for the authenticated event owner and the co-hosts the owner shared it with. Anon role has no policies → no direct table access.
- Co-hosts are rows in `event_cohosts`, matched on the email in the login token. They get the same access as the owner inside the event; only the owner can delete the event or change its co-hosts (enforced by RLS — the UI just hides those buttons). Sharing is refused for an email that isn't in `ADMIN_EMAILS`, since that person couldn't log in.
- Public/judge access = token in URL → route handler validates with zod → service-role query scoped by that token.
- Judge write checks: judge not revoked, game not locked/done, criterion belongs to judge's game, team belongs to the game's event, value within min/max and on step. (DB trigger also enforces range.)
- Scoreboard API checks `scoreboard_visibility` and strips scores when `hidden`; returns `frozen_snapshot` when `frozen`.
- Buzz checks: the token's team exists, its event's current game has `buzzer_enabled`, isn't done, and nobody has been marked correct for this question. The team and game come from the token alone — the request has no body. One row per (game, team), so a repeat press is a no-op.
- Uploaded media lives in the private `game-media` bucket under `{eventId}/…`. Storage policies
allow the event's admins only; anon has none. The scoreboard API hands out a signed URL (12 h)
for the one item that is on screen, and nothing else. Links (YouTube, files) must be `https://`.
- Light rate limiting on judge PUT (20 req/s per token), buzz POST (5 req/s per token) and open-link join (3 req/s per link), in-memory per server instance. An open link can create at most 50 judges per game.
## Realtime (Broadcast "ping then refetch")
Channels (names use tokens/ids; payload is always just `{ type: "refresh", scope }`):
- `sb:{scoreboardToken}` — scoreboard listens
- `game:{gameId}` — all judges of that game listen (pinged for lock, spotlight, revoke and game edits — not for other judges' scores, which they never see)
- `admin:{eventId}` — admin control room listens (judge submissions and buzzes land here)
- `buzzer:{eventId}` — team buzzer pages listen (pinged for buzzes, clears, game switches and edits to games/teams — not for scores)
`src/lib/realtime.ts` exposes `notify({ eventId, gameId?, buzzer? })` which looks up the scoreboard token and sends to the right channels from the server. `buzzer: true` adds the buzzer channel; score saves leave it out so they don't cost an extra request.
Use Supabase's server-side broadcast (REST endpoint / `httpSend` — check current docs).
Throttle refetches client-side: the first ping refetches immediately, further pings within ~150ms collapse into one trailing refetch, so a burst of taps causes at most two fetches.
Clients also poll every 10s as a safety net, and refetch on reconnect / tab focus.
## Scoring engine (`src/lib/scoring/`)
Pure TS, no DB access. Input = plain data (teams, games, criteria, scores, adjustments). Output = per-game results + standings. See SCORING.md. Fully unit tested.
## Scoreboard payload shape
```ts
type ScoreboardPayload = {
eventName: string;
visibility: 'visible' | 'frozen' | 'hidden';
currentGame: { id: string; title: string; index: number; total: number } | null;
reveal: { active: boolean; revealedCount: number } | null;
teams: Array<{
id: string; name: string; color: string;
// omitted entirely when hidden:
rank?: number; total?: number;
// v1 sends totals only; no per-game points.
}>;
// null while the host has the timer hidden; sent regardless of score visibility
timer: {
status: 'idle' | 'running' | 'paused' | 'finished';
remainingMs: number; // as of updatedAt
endsAt: string | null; // set while running
durationMs: number;
} | null;
// null unless the current game uses buzzers; sent regardless of score visibility
buzzer: {
open: boolean; // false once a team got it, or the game is done
buzzes: Array<{ // in buzz order
teamId: string; name: string; color: string; afterFirstMs: number;
result: 'correct' | 'wrong' | null; // the host's mark
}>;
answeringTeamId: string | null; // earliest unmarked buzz; null once someone got it
} | null;
// null with nothing on screen; sent regardless of score visibility (see "On screen")
onScreen:
| { kind: 'question'; id: string; shownAt: string; text: string;
imageUrl: string | null; answer: string | null } // answer only once revealed
| { kind: 'sound'; id: string; shownAt: string; media: ScreenMedia; paused: boolean;
answer: string | null }
| { kind: 'video'; id: string; shownAt: string; media: ScreenMedia; paused: boolean }
| null;
updatedAt: string; // server clock; screens use it to correct their own
};
```
`ScreenMedia` = `{ type: 'youtube'; videoId; startSeconds }` or `{ type: 'file'; url }`.
During reveal mode, teams not yet revealed are sent without rank/total, alphabetically and before the revealed ones, so their order leaks nothing.
When frozen, rank/total come from `frozen_snapshot` but `currentGame` stays live. A missing or invalid snapshot is treated as hidden, never as live.
## Timer and sound
- Timer state lives on `events` (see DATA_MODEL.md). `src/lib/timer.ts` holds the rules as pure
functions; server actions apply them using the server's clock. Screens count down locally
between pings from `endsAt`, offset by `updatedAt` minus their own clock.
- Sound effects are synthesised in `src/lib/sound.ts` with the Web Audio API. Which sound a
change triggers is decided by pure functions in `src/lib/scoreboard-sounds.ts`; the
scoreboard compares each new payload with the previous one. No sound data travels over Realtime.
## Buzzers
- A buzz is a row in `buzzes`, stamped by the database's clock (`clock_timestamp()`), so the order
doesn't depend on phone clocks or on which server instance took the request. Network delay
between a thumb and the database still counts — fair on shared Wi-Fi, not tournament-grade.
- `src/lib/buzzer.ts` holds the rules as pure functions (`buzzerView` builds the same
`{ open, buzzes }` for the team page, the control room and the scoreboard);
`src/lib/buzzer.server.ts` does the token-gated reads and the write.
- Buzzes follow rule 4 like everything else: the ping carries no data and each screen refetches.
The buzzing phone gets its result in the POST response, so it doesn't wait for the ping.
- Clearing = deleting the current game's rows (admin server action).
- Marking an answer is the `markBuzz` server action. `checkMark` decides whether the mark is
allowed and whether a point moves (+1 for a new "correct", −1 when one is undone);
`bumpScore` gives the new cell value. In a direct game the point is written to the admin's
score row for the game's first criterion — an ordinary score, so the scoring engine turns it
into leaderboard points like any other and the `validate_score` trigger still guards range
and step. The score is written before the mark, so a refused point leaves the answer unmarked.
- A buzz is refused once any buzz of the game is marked correct, until the host clears.
## On screen (questions, sounds, videos)
- A game's items are rows in `game_items`. What is on screen is state on `events`
(`screen_item_id`, `screen_shown_at`, `screen_answer_shown`, `screen_paused`), like the timer.
An item only counts while its game is the current game; switching game clears it.
- `src/lib/screen.ts` holds the rules as pure functions: `buildOnScreen` (the sanitised payload
part — no unrevealed answer, no sound/video name), `parseYouTube`, `pickRandom`,
`nextQuestion`, and the upload checks. Server actions in `live-actions.ts` (show, random,
answer, pause, clear) and `item-actions.ts` (setup CRUD).
- Rule 4 still holds: the ping carries nothing; the scoreboard refetches and plays what the
payload says. Each player is mounted with a key of item id + `shownAt`, so showing an item
again restarts it, and it keeps the media URL it started with (a signed URL differs in every
payload; following it would restart the file on every refetch).
- Playback: files use `<video>` / `<audio>`; YouTube uses the `youtube-nocookie.com` embed, with
mute and pause sent as `postMessage` player commands. The control room decides play/pause and
restart only — there is no shared playback position, so a scoreboard opened mid-song starts
the song from the beginning.
- Uploads go from the admin's browser to storage (a server action can't carry 50 MB); the
action then records the path, after checking it is inside that event's folder. Deleting an
item, game or event deletes its files; duplicating copies them.
## Folder layout
```
src/
app/ (routes above)
components/ (ui/, scoreboard/, judge/, buzzer/, admin/)
lib/
scoring/ (index.ts, *.test.ts)
supabase/ (browser.ts, server.ts, admin.ts)
realtime.ts
tokens.ts (zod schemas)
queries/ (data loaders shared by admin + API)
supabase/migrations/
```