- /login sends a Supabase magic link (admin emails only); /auth/confirm completes sign-in; proxy.ts guards /admin and every server action re-checks with requireAdmin(), since actions are plain POST endpoints. - /admin lists, creates, duplicates and deletes events. - /admin/events/[eventId] edits teams (colour, order) and games with criteria, presets and notes. All input goes through zod schemas that mirror the database constraints so errors read as plain sentences. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
80 lines
3.8 KiB
Markdown
80 lines
3.8 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 |
|
|
| `/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. `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`.
|
|
- 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 listens
|
|
- `game:{gameId}` — all judges of that game listen
|
|
- `admin:{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
|
|
```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;
|
|
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/
|
|
```
|