diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1cc3dd1..1defdcb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,7 +24,8 @@ Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast 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. +- `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. @@ -35,6 +36,9 @@ Admin mutations = server actions. `src/proxy.ts` (Next.js 16's name for middlewa - 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") @@ -81,9 +85,18 @@ type ScoreboardPayload = { }>; 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. @@ -113,6 +126,26 @@ When frozen, rank/total come from `frozen_snapshot` but `currentGame` stays live 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 `