Next.js 16 App Router with strict TypeScript, Tailwind 4 theme tokens from docs/UI.md, Vitest, and the three Supabase clients. Env vars are validated with zod in next.config.ts so dev and build fail fast with a readable error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3.7 KiB
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} |
Admin mutations = server actions. Middleware protects /admin/*: must be logged in AND email in ADMIN_EMAILS.
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_visibilityand strips scores whenhidden; returnsfrozen_snapshotwhenfrozen. - Light rate limiting on judge PUT (e.g. 20 req/s per token, in-memory is fine for v1).
Realtime (Broadcast "ping then refetch")
Channels (names use tokens/ids; payload is always just { type: "refresh", scope }):
sb:{scoreboardToken}— scoreboard listensgame:{gameId}— all judges of that game listenadmin:{eventId}— admin control room listens (judge submissions land here)
src/lib/realtime.ts exposes notify({ eventId, gameId? }) which looks up the scoreboard token and sends to the right channels from the server.
Use Supabase's server-side broadcast (REST endpoint / httpSend — check current docs).
Debounce refetches client-side (~150ms) so a burst of taps causes one fetch.
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
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;
perGame?: Array<{ gameId: string; points: number }>;
}>;
updatedAt: string;
};
During reveal mode, teams not yet revealed are sent without rank/total.
Folder layout
src/
app/ (routes above)
components/ (ui/, scoreboard/, judge/, 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/