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>
162 lines
11 KiB
Markdown
162 lines
11 KiB
Markdown
# 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/
|
||
```
|