# 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 | | `/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. - `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. Anon role has no policies → no direct table access. - 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` and isn't done. 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. - 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 the game is done buzzes: Array<{ teamId: string; name: string; color: string; afterFirstMs: number }>; // in buzz order } | null; updatedAt: string; // server clock; screens use it to correct their own }; ``` 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). ## 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/ ```