From 27024910968e523f81145dfae4dff62a555f3500 Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Sat, 3 Oct 2026 15:29:33 +0200 Subject: [PATCH] =?UTF-8?q?Phase=207:=20judges=20=E2=80=94=20links,=20phon?= =?UTF-8?q?e=20page,=20open=20link,=20judged=20control=20room?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Setup: nominate judges, copy personal links, QR codes (generated on demand in the browser), revoke/restore, and an open-link toggle. - GET /api/judge/[token] returns only that judge's own scores; PUT .../scores runs every check from ARCHITECTURE.md in checkJudgeWrite (pure, unit-tested) after one parallel lookup, plus a rate limit. - /j/[token]: mobile cards per team, spotlight pinned first, autosave with per-card status, read-only when locked/done, link-off when revoked. - /jo/[token]: name form -> personal link, remembered in localStorage so a re-scan doesn't create a second judge. - Control room: averages with on-request overrides, judge progress matrix, per-judge breakdown, spotlight select and lock toggle. - usePendingEdits shares the optimistic autosave logic between the admin grid and the judge page. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 4 +- package-lock.json | 279 +++++++++++++++++++- package.json | 2 + src/app/admin/judge-actions.ts | 105 ++++++++ src/app/api/judge-open/[openToken]/route.ts | 33 +++ src/app/api/judge/[token]/route.ts | 17 ++ src/app/api/judge/[token]/scores/route.ts | 34 +++ src/app/j/[token]/page.tsx | 18 ++ src/app/jo/[openToken]/page.tsx | 18 ++ src/components/admin/control-room.tsx | 4 + src/components/admin/games-editor.tsx | 2 + src/components/admin/judged-panel.tsx | 165 ++++++++++++ src/components/admin/judges-editor.tsx | 109 ++++++++ src/components/admin/scoring-grid.tsx | 145 +++++----- src/components/admin/share-link.tsx | 68 +++++ src/components/judge/judge-view.tsx | 165 ++++++++++++ src/components/judge/open-link-form.tsx | 97 +++++++ src/lib/hooks/use-pending-edits.ts | 92 +++++++ src/lib/judge.server.ts | 186 +++++++++++++ src/lib/judge.test.ts | 84 ++++++ src/lib/judge.ts | 101 +++++++ src/lib/live-view.ts | 26 ++ src/lib/queries/event.ts | 10 +- src/lib/rate-limit.test.ts | 35 +++ src/lib/rate-limit.ts | 23 ++ 25 files changed, 1737 insertions(+), 85 deletions(-) create mode 100644 src/app/admin/judge-actions.ts create mode 100644 src/app/api/judge-open/[openToken]/route.ts create mode 100644 src/app/api/judge/[token]/route.ts create mode 100644 src/app/api/judge/[token]/scores/route.ts create mode 100644 src/app/j/[token]/page.tsx create mode 100644 src/app/jo/[openToken]/page.tsx create mode 100644 src/components/admin/judged-panel.tsx create mode 100644 src/components/admin/judges-editor.tsx create mode 100644 src/components/admin/share-link.tsx create mode 100644 src/components/judge/judge-view.tsx create mode 100644 src/components/judge/open-link-form.tsx create mode 100644 src/lib/hooks/use-pending-edits.ts create mode 100644 src/lib/judge.server.ts create mode 100644 src/lib/judge.test.ts create mode 100644 src/lib/judge.ts create mode 100644 src/lib/rate-limit.test.ts create mode 100644 src/lib/rate-limit.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 83dccc8..d59a227 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -30,12 +30,12 @@ Admin mutations = server actions. `src/proxy.ts` (Next.js 16's name for middlewa - 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). +- Light rate limiting on judge PUT (20 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 +- `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 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. diff --git a/package-lock.json b/package-lock.json index bf139ad..afcd784 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@supabase/ssr": "^0.12.7", "@supabase/supabase-js": "^2.117.2", "next": "16.3.8", + "qrcode": "^1.5.4", "react": "19.2.8", "react-dom": "19.2.8", "server-only": "^0.0.1", @@ -19,6 +20,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^22", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "@vitest/coverage-v8": "^5.0.3", @@ -2073,6 +2075,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", @@ -2852,11 +2864,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3264,6 +3284,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001814", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", @@ -3317,11 +3346,21 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3334,7 +3373,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -3465,6 +3503,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3518,6 +3565,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4451,6 +4504,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4956,6 +5018,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -6116,6 +6187,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6133,7 +6213,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6175,6 +6254,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6246,6 +6334,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6339,6 +6444,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -6530,6 +6650,12 @@ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "license": "MIT" }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -6778,6 +6904,26 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6892,6 +7038,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -7909,6 +8067,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.24", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", @@ -7954,6 +8118,26 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -7961,6 +8145,93 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 0bd990f..93606ba 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@supabase/ssr": "^0.12.7", "@supabase/supabase-js": "^2.117.2", "next": "16.3.8", + "qrcode": "^1.5.4", "react": "19.2.8", "react-dom": "19.2.8", "server-only": "^0.0.1", @@ -25,6 +26,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^22", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "@vitest/coverage-v8": "^5.0.3", diff --git a/src/app/admin/judge-actions.ts b/src/app/admin/judge-actions.ts new file mode 100644 index 0000000..66aac1c --- /dev/null +++ b/src/app/admin/judge-actions.ts @@ -0,0 +1,105 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { requireAdmin } from "@/lib/auth"; +import { judgeNameSchema } from "@/lib/judge"; +import { notify } from "@/lib/realtime"; +import { idSchema } from "@/lib/tokens"; + +import type { ActionResult } from "./actions"; + +// Server actions for managing judges (setup page) and running a judged game (control room). + +const ok: ActionResult = { ok: true }; +const fail = (error: string): ActionResult => ({ ok: false, error }); +const dbFail = (what: string, message: string) => fail(`${what} failed: ${message}. Try again.`); +const validIds = (...ids: string[]) => ids.every((id) => idSchema.safeParse(id).success); +const BAD_ID = fail("That item no longer exists. Reload the page."); + +function revalidate(eventId: string) { + revalidatePath(`/admin/events/${eventId}`); + revalidatePath(`/admin/events/${eventId}/live`); +} + +/** Confirms the game is in this event (RLS already confirms the event is yours). */ +async function gameInEvent(eventId: string, gameId: string) { + const { supabase } = await requireAdmin(); + if (!validIds(eventId, gameId)) return { supabase, found: false }; + const { data } = await supabase.from("games").select("id").eq("id", gameId).eq("event_id", eventId).maybeSingle(); + return { supabase, found: data !== null }; +} + +/** Nominates a judge by name; the database gives them a personal link token. */ +export async function addJudge(eventId: string, gameId: string, name: string): Promise { + const { supabase, found } = await gameInEvent(eventId, gameId); + if (!found) return BAD_ID; + const parsed = judgeNameSchema.safeParse({ name }); + if (!parsed.success) return fail(parsed.error.issues[0].message.replace("your name", "the judge's name").replace("Your name", "The name")); + + const { error } = await supabase + .from("judges") + .insert({ game_id: gameId, name: parsed.data.name, source: "nominated" }); + if (error) return dbFail("Adding the judge", error.message); + + revalidate(eventId); + await notify({ eventId }); + return ok; +} + +/** Revoking stops the link working and excludes the judge's scores; restoring brings both back. */ +export async function setJudgeRevoked( + eventId: string, + gameId: string, + judgeId: string, + revoked: boolean, +): Promise { + const { supabase, found } = await gameInEvent(eventId, gameId); + if (!found || !validIds(judgeId)) return BAD_ID; + + const { error } = await supabase + .from("judges") + .update({ revoked }) + .eq("id", judgeId) + .eq("game_id", gameId); + if (error) return dbFail("Updating the judge", error.message); + + revalidate(eventId); + // gameId: the revoked judge's own page must find out too. + await notify({ eventId, gameId }); + return ok; +} + +type GameFlags = { open_link_enabled?: boolean; judging_locked?: boolean; spotlight_team_id?: string | null }; + +async function updateGame(eventId: string, gameId: string, what: string, patch: GameFlags) { + const { supabase, found } = await gameInEvent(eventId, gameId); + if (!found) return BAD_ID; + + const { error } = await supabase.from("games").update(patch).eq("id", gameId).eq("event_id", eventId); + if (error) return dbFail(what, error.message); + + revalidate(eventId); + await notify({ eventId, gameId }); + return ok; +} + +export async function setOpenLink(eventId: string, gameId: string, enabled: boolean): Promise { + return updateGame(eventId, gameId, "Updating the open link", { open_link_enabled: enabled === true }); +} + +/** Locked = judges can still see their scores but can't change them. */ +export async function setJudgingLocked(eventId: string, gameId: string, locked: boolean): Promise { + return updateGame(eventId, gameId, "Updating the lock", { judging_locked: locked === true }); +} + +/** The "now performing" team, pinned and highlighted on every judge's phone. */ +export async function setSpotlight(eventId: string, gameId: string, teamId: string | null): Promise { + if (teamId !== null) { + if (!validIds(teamId)) return BAD_ID; + const { supabase } = await requireAdmin(); + const { data } = await supabase.from("teams").select("id").eq("id", teamId).eq("event_id", eventId).maybeSingle(); + if (!data) return BAD_ID; + } + return updateGame(eventId, gameId, "Setting the spotlight", { spotlight_team_id: teamId }); +} diff --git a/src/app/api/judge-open/[openToken]/route.ts b/src/app/api/judge-open/[openToken]/route.ts new file mode 100644 index 0000000..d9961b3 --- /dev/null +++ b/src/app/api/judge-open/[openToken]/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; + +import { judgeNameSchema } from "@/lib/judge"; +import { joinViaOpenLink } from "@/lib/judge.server"; +import { createRateLimiter } from "@/lib/rate-limit"; + +const headers = { "Cache-Control": "no-store" }; +const allow = createRateLimiter(3); + +/** `{ name }` → `{ judgeToken }`: turns a visitor of the open link into a judge. */ +export async function POST(request: Request, context: RouteContext<"/api/judge-open/[openToken]">) { + const { openToken } = await context.params; + if (!allow(openToken)) { + return NextResponse.json( + { error: "Lots of people are joining at once. Wait a second and try again." }, + { status: 429, headers }, + ); + } + + const body = judgeNameSchema.safeParse(await request.json().catch(() => null)); + if (!body.success) { + return NextResponse.json({ error: body.error.issues[0].message }, { status: 400, headers }); + } + + try { + const result = await joinViaOpenLink(openToken, body.data.name); + if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status, headers }); + return NextResponse.json({ judgeToken: result.judgeToken }, { headers }); + } catch (error) { + console.error("judge open-link join failed", error); + return NextResponse.json({ error: "Couldn't join. Try again." }, { status: 500, headers }); + } +} diff --git a/src/app/api/judge/[token]/route.ts b/src/app/api/judge/[token]/route.ts new file mode 100644 index 0000000..1010861 --- /dev/null +++ b/src/app/api/judge/[token]/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; + +import { getJudgePayload } from "@/lib/judge.server"; + +const headers = { "Cache-Control": "no-store" }; + +export async function GET(_request: Request, context: RouteContext<"/api/judge/[token]">) { + const { token } = await context.params; + try { + const result = await getJudgePayload(token); + if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status, headers }); + return NextResponse.json(result.payload, { headers }); + } catch (error) { + console.error("judge API failed", error); + return NextResponse.json({ error: "Could not load the game. Try again." }, { status: 500, headers }); + } +} diff --git a/src/app/api/judge/[token]/scores/route.ts b/src/app/api/judge/[token]/scores/route.ts new file mode 100644 index 0000000..5253a44 --- /dev/null +++ b/src/app/api/judge/[token]/scores/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; + +import { judgeScoreSchema } from "@/lib/judge"; +import { saveJudgeScore } from "@/lib/judge.server"; +import { createRateLimiter } from "@/lib/rate-limit"; + +const headers = { "Cache-Control": "no-store" }; +// Far above what a person can tap, low enough to stop a stuck client or a script. +const allow = createRateLimiter(20); + +/** Upserts one of this judge's scores: `{ teamId, criterionId, value }`; `value: null` clears it. */ +export async function PUT(request: Request, context: RouteContext<"/api/judge/[token]/scores">) { + const { token } = await context.params; + if (!allow(token)) { + return NextResponse.json( + { error: "Too many taps at once. Wait a second and try again." }, + { status: 429, headers }, + ); + } + + const body = judgeScoreSchema.safeParse(await request.json().catch(() => null)); + if (!body.success) { + return NextResponse.json({ error: "That score couldn't be read. Reload the page." }, { status: 400, headers }); + } + + try { + const result = await saveJudgeScore(token, body.data); + if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status, headers }); + return NextResponse.json({ ok: true }, { headers }); + } catch (error) { + console.error("judge score save failed", error); + return NextResponse.json({ error: "That score wasn't saved. Try again." }, { status: 500, headers }); + } +} diff --git a/src/app/j/[token]/page.tsx b/src/app/j/[token]/page.tsx new file mode 100644 index 0000000..fcdf8b9 --- /dev/null +++ b/src/app/j/[token]/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; + +import { JudgeMessage, JudgeView } from "@/components/judge/judge-view"; +import { getJudgePayload } from "@/lib/judge.server"; + +export const metadata: Metadata = { + title: "Judge", + // The link is the judge's only credential; keep it out of search engines. + robots: { index: false, follow: false }, +}; + +export default async function JudgePage(props: PageProps<"/j/[token]">) { + const { token } = await props.params; + const result = await getJudgePayload(token); + + if (!result.ok) return {result.error}; + return ; +} diff --git a/src/app/jo/[openToken]/page.tsx b/src/app/jo/[openToken]/page.tsx new file mode 100644 index 0000000..bf016c5 --- /dev/null +++ b/src/app/jo/[openToken]/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; + +import { JudgeMessage } from "@/components/judge/judge-view"; +import { OpenLinkForm } from "@/components/judge/open-link-form"; +import { getOpenLinkGame } from "@/lib/judge.server"; + +export const metadata: Metadata = { + title: "Join as a judge", + robots: { index: false, follow: false }, +}; + +export default async function OpenJudgePage(props: PageProps<"/jo/[openToken]">) { + const { openToken } = await props.params; + const game = await getOpenLinkGame(openToken); + + if (!game.ok) return {game.error}; + return ; +} diff --git a/src/components/admin/control-room.tsx b/src/components/admin/control-room.tsx index 1dd23c5..c5be641 100644 --- a/src/components/admin/control-room.tsx +++ b/src/components/admin/control-room.tsx @@ -13,6 +13,7 @@ import { channelNames } from "@/lib/realtime-channels"; import { BonusPanel } from "./bonus-panel"; import { ErrorText } from "./error-text"; +import { JudgedPanel } from "./judged-panel"; import { ScoreboardPanel, VISIBILITY_ORDER } from "./scoreboard-panel"; import { ScoringGrid } from "./scoring-grid"; import { useAction } from "./use-action"; @@ -188,6 +189,9 @@ export function ControlRoom({ view }: { view: LiveView }) { <> {/* Keyed by game so half-saved edits never carry over to another game. */} + {currentGame.scoringMode === "judged" && ( + + )} {currentGame.adminNotes && (

Notes (only you see these)

diff --git a/src/components/admin/games-editor.tsx b/src/components/admin/games-editor.tsx index 22d9075..60a32d4 100644 --- a/src/components/admin/games-editor.tsx +++ b/src/components/admin/games-editor.tsx @@ -10,6 +10,7 @@ import type { GameWithCriteria } from "@/lib/queries/event"; import { ErrorText } from "./error-text"; import { GameForm } from "./game-form"; +import { JudgesEditor } from "./judges-editor"; import { useAction } from "./use-action"; const presetKeys = Object.keys(criteriaPresets) as PresetKey[]; @@ -131,6 +132,7 @@ function GameCard({ {/* Re-mount after each save so the form picks up ids of newly created criteria. */} + {game.scoring_mode === "judged" && }
diff --git a/src/components/admin/judged-panel.tsx b/src/components/admin/judged-panel.tsx new file mode 100644 index 0000000..693e1ea --- /dev/null +++ b/src/components/admin/judged-panel.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { setJudgingLocked, setSpotlight } from "@/app/admin/judge-actions"; +import { Button } from "@/components/ui/button"; +import { inputClass } from "@/components/ui/field"; +import { formatScore } from "@/lib/criteria"; +import type { LiveGame, LiveView } from "@/lib/live-view"; + +import { ErrorText } from "./error-text"; +import { useAction } from "./use-action"; + +/** Control-room extras for a judged game: spotlight, lock, who has scored what. */ +export function JudgedPanel({ + eventId, + game, + teams, +}: { + eventId: string; + game: LiveGame; + teams: LiveView["teams"]; +}) { + const { pending, error, run } = useAction(); + const judges = game.judges.filter((judge) => !judge.revoked); + const revokedCount = game.judges.length - judges.length; + const criteriaCount = game.criteria.length; + + const scoredCount = (judge: LiveGame["judges"][number], teamId: string) => + game.criteria.filter((criterion) => `${teamId}|${criterion.id}` in judge.values).length; + + return ( +
+
+ + + {game.judgingLocked && Judges can see their scores but not change them.} +
+ {error} + +
+

Judge progress

+ {judges.length === 0 ? ( +

No judges yet. Add judges or turn on the open link on the setup page.

+ ) : ( +
+ + + + + {teams.map((team) => ( + + ))} + + + + {judges.map((judge) => ( + + + {teams.map((team) => { + const scored = scoredCount(judge, team.id); + const done = criteriaCount > 0 && scored === criteriaCount; + return ( + + ); + })} + + ))} + +
+ Judge + + {team.name} +
+ {judge.name} + + {done ? ( + + ✓ all scored + + ) : scored === 0 ? ( + + ✗ nothing scored + + ) : ( + + {scored}/{criteriaCount} + + )} +
+
+ )} + {revokedCount > 0 && ( +

+ {revokedCount} revoked {revokedCount === 1 ? "judge is" : "judges are"} not counted. +

+ )} +
+ + {judges.length > 0 && ( +
+ Every judge's scores +
+ {teams.map((team) => ( +
+ + + + + + {game.criteria.map((criterion) => ( + + ))} + + + + {judges.map((judge) => ( + + + {game.criteria.map((criterion) => { + const value = judge.values[`${team.id}|${criterion.id}`]; + return ( + + ); + })} + + ))} + +
{team.name}
+ Judge + + {criterion.label} +
+ {judge.name} + + {value === undefined ? "–" : formatScore(value)} +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/components/admin/judges-editor.tsx b/src/components/admin/judges-editor.tsx new file mode 100644 index 0000000..6b581ca --- /dev/null +++ b/src/components/admin/judges-editor.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useState } from "react"; + +import { addJudge, setJudgeRevoked, setOpenLink } from "@/app/admin/judge-actions"; +import { Button } from "@/components/ui/button"; +import { inputClass } from "@/components/ui/field"; +import { publicEnv } from "@/lib/env.public"; +import type { GameWithCriteria } from "@/lib/queries/event"; + +import { ErrorText } from "./error-text"; +import { ShareLink } from "./share-link"; +import { useAction } from "./use-action"; + +const judgeUrl = (token: string) => `${publicEnv.NEXT_PUBLIC_SITE_URL}/j/${token}`; +const openUrl = (token: string) => `${publicEnv.NEXT_PUBLIC_SITE_URL}/jo/${token}`; + +/** Judges for one judged game: personal links, the shared open link, revoking. */ +export function JudgesEditor({ eventId, game }: { eventId: string; game: GameWithCriteria }) { + const [name, setName] = useState(""); + const { pending, error, run } = useAction(); + + return ( +
+

Judges

+ + {game.judges.length === 0 ? ( +

No judges yet. Add them by name below, or turn on the open link.

+ ) : ( +
    + {game.judges.map((judge) => ( +
  • + + {judge.name} + {judge.source === "open_link" && ( + joined via open link + )} + + {judge.revoked ? ( + Revoked — link off, scores not counted + ) : ( + + )} + +
  • + ))} +
+ )} + +
{ + event.preventDefault(); + run(() => addJudge(eventId, game.id, name), () => setName("")); + }} + > + setName(event.target.value)} + required + maxLength={60} + placeholder="Judge's name" + className={`${inputClass} max-w-xs`} + /> + +
+ +
+
+

Open link: {game.open_link_enabled ? "on" : "off"}

+

+ One link for everyone: whoever opens it types their name and becomes a judge. +

+
+ {game.open_link_enabled && ( + + )} + +
+ {error} +
+ ); +} diff --git a/src/components/admin/scoring-grid.tsx b/src/components/admin/scoring-grid.tsx index 5601845..ade928f 100644 --- a/src/components/admin/scoring-grid.tsx +++ b/src/components/admin/scoring-grid.tsx @@ -1,23 +1,18 @@ "use client"; import { useRouter } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { saveAdminScore } from "@/app/admin/live-actions"; +import { Button } from "@/components/ui/button"; import { BlockInput, NumberStepper } from "@/components/ui/score-inputs"; import { formatScore } from "@/lib/criteria"; +import { usePendingEdits } from "@/lib/hooks/use-pending-edits"; import type { LiveGame, LiveView } from "@/lib/live-view"; import { ErrorText } from "./error-text"; -// An edit the server hasn't echoed back yet. Shown instead of the server's value so -// taps feel instant (optimistic UI). -type PendingEdit = { value: number | null; version: number; savedAt: number | null }; - const cellKey = (teamId: string, criterionId: string) => `${teamId}|${criterionId}`; -// A saved edit normally disappears when the refreshed data matches it. If it never -// matches (someone else changed the cell), stop masking the server's value after this long. -const STALE_MS = 5000; export function ScoringGrid({ eventId, @@ -29,59 +24,30 @@ export function ScoringGrid({ teams: LiveView["teams"]; }) { const router = useRouter(); - const [pending, setPending] = useState>({}); - const [savingCount, setSavingCount] = useState(0); - const [error, setError] = useState(null); - const versions = useRef(0); - // Drop optimistic edits once the server data has caught up with them. - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- reconciling local edits with fresh server props - setPending((current) => { - const now = Date.now(); - const kept = Object.entries(current).filter(([key, edit]) => { - if (edit.savedAt === null) return true; - const [teamId, criterionId] = key.split("|"); - const serverValue = game.cells[teamId]?.[criterionId]?.adminValue ?? null; - return serverValue !== edit.value && now - edit.savedAt < STALE_MS; - }); - return kept.length === Object.keys(current).length ? current : Object.fromEntries(kept); - }); - }, [game]); + // The admin's own rows: the scores in a direct game, the overrides in a judged game. + const adminValues = useMemo(() => { + const values: Record = {}; + for (const [teamId, cells] of Object.entries(game.cells)) { + for (const [criterionId, cell] of Object.entries(cells)) { + if (cell.adminValue !== null) values[cellKey(teamId, criterionId)] = cell.adminValue; + } + } + return values; + }, [game.cells]); - const save = (teamId: string, criterionId: string, value: number | null) => { - const key = cellKey(teamId, criterionId); - const version = ++versions.current; - setPending((current) => ({ ...current, [key]: { value, version, savedAt: null } })); - setSavingCount((count) => count + 1); - setError(null); - - // Only the newest edit of a cell may update or clear its pending state. - const settle = (savedAt: number | null) => - setPending((current) => { - if (current[key]?.version !== version) return current; - if (savedAt === null) { - return Object.fromEntries(Object.entries(current).filter(([other]) => other !== key)); - } - return { ...current, [key]: { ...current[key], savedAt } }; - }); - - saveAdminScore(eventId, game.id, teamId, criterionId, value) - .then((result) => { - if (result.ok) { - settle(Date.now()); - router.refresh(); - } else { - settle(null); - setError(result.error); - } - }) - .catch(() => { - settle(null); - setError("That score wasn't saved — couldn't reach the server. Check your connection and enter it again."); - }) - .finally(() => setSavingCount((count) => count - 1)); - }; + const save = useCallback( + (key: string, value: number | null) => { + const [teamId, criterionId] = key.split("|"); + return saveAdminScore(eventId, game.id, teamId, criterionId, value); + }, + [eventId, game.id], + ); + const refresh = useCallback(() => router.refresh(), [router]); + const { edit, valueOf, isSaving, errorFor } = usePendingEdits(adminValues, save, refresh); + // Judged games show averages; the override input for a cell only opens on request, + // which keeps a 5-criteria grid on screen without sideways scrolling. + const [overriding, setOverriding] = useState(null); if (game.criteria.length === 0) { return

This game has no criteria yet. Add some on the setup page.

; @@ -91,6 +57,7 @@ export function ScoringGrid({ } const judged = game.scoringMode === "judged"; + const error = errorFor(); return (
@@ -120,8 +87,9 @@ export function ScoringGrid({ {teams.map((team) => { const result = game.results[team.id]; + const spotlit = game.spotlightTeamId === team.id; return ( - + {team.name} + {spotlit && ★ Now performing} {game.criteria.map((criterion) => { const cell = game.cells[team.id]?.[criterion.id]; - const edit = pending[cellKey(team.id, criterion.id)]; - const value = edit ? edit.value : (cell?.adminValue ?? null); + const key = cellKey(team.id, criterion.id); + const value = valueOf(key); const inputProps = { range: criterion, value, - label: `${criterion.label} for ${team.name}`, - onChange: (next: number | null) => save(team.id, criterion.id, next), + label: `${judged ? "Override value: " : ""}${criterion.label} for ${team.name}`, + onChange: (next: number | null) => edit(key, next), }; return ( - {criterion.inputType === "blocks" ? ( + {judged && ( + + {cell?.judgeAverage == null ? ( + No judge scores yet + ) : ( + <> + + {formatScore(cell.judgeAverage)} + {" "} + + avg of {cell.judgeCount} {cell.judgeCount === 1 ? "judge" : "judges"} + + + )} + {value !== null && ( + + Override {formatScore(value)} + + )} + + )} + {judged && value === null && overriding !== key ? ( + + ) : criterion.inputType === "blocks" ? ( ) : ( )} - {judged && ( - - {cell?.judgeAverage == null - ? "No judge scores" - : `Judges: ${formatScore(cell.judgeAverage)} (${cell.judgeCount})`} - {value !== null && " · overridden"} - - )} ); })} @@ -174,13 +167,13 @@ export function ScoringGrid({

- {savingCount > 0 ? "Saving…" : error ? "" : "All scores saved ✓"} + {isSaving() ? "Saving…" : error ? "" : "All scores saved ✓"}

{error} {judged && (

- This is a judged game. A value you enter here overrides the judges' average for that - cell; clear it to use the judges again. + “Override” replaces the judges' average for that cell until you clear it + (tap the selected block again, or empty the number).

)} diff --git a/src/components/admin/share-link.tsx b/src/components/admin/share-link.tsx new file mode 100644 index 0000000..9221d26 --- /dev/null +++ b/src/components/admin/share-link.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; + +/** "Copy link" button plus a QR code that is only generated when opened. */ +export function ShareLink({ url, copyLabel, qrLabel }: { url: string; copyLabel: string; qrLabel: string }) { + const [copied, setCopied] = useState<"idle" | "copied" | "failed">("idle"); + const [showQr, setShowQr] = useState(false); + + const copy = async () => { + try { + await navigator.clipboard.writeText(url); + setCopied("copied"); + } catch { + // Clipboard access can be blocked (e.g. on http over a LAN address). + setCopied("failed"); + } + setTimeout(() => setCopied("idle"), 2500); + }; + + return ( + <> + + + {(showQr || copied === "failed") && ( +
+ {copied === "failed" &&

Couldn't copy automatically. Select the link below.

} + {showQr && } + {url} +
+ )} + + ); +} + +function QrCode({ url, label }: { url: string; label: string }) { + const [svg, setSvg] = useState(null); + + useEffect(() => { + let cancelled = false; + // Loaded on demand so the QR library isn't in the page until someone asks for a code. + void import("qrcode") + .then((QRCode) => QRCode.toString(url, { type: "svg", margin: 2, errorCorrectionLevel: "M" })) + .then((markup) => { + if (!cancelled) setSvg(markup); + }); + return () => { + cancelled = true; + }; + }, [url]); + + if (!svg) return
; + return ( +
+ ); +} diff --git a/src/components/judge/judge-view.tsx b/src/components/judge/judge-view.tsx new file mode 100644 index 0000000..5c0626c --- /dev/null +++ b/src/components/judge/judge-view.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; + +import { BlockInput, NumberStepper } from "@/components/ui/score-inputs"; +import { usePendingEdits, type SaveResult } from "@/lib/hooks/use-pending-edits"; +import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel"; +import { judgeMessages, type JudgePayload } from "@/lib/judge"; +import { channelNames } from "@/lib/realtime-channels"; + +const cellKey = (teamId: string, criterionId: string) => `${teamId}|${criterionId}`; + +/** The judge's phone page: one card per team, one input per criterion, autosave on every tap. */ +export function JudgeView({ token, initial }: { token: string; initial: JudgePayload }) { + const [payload, setPayload] = useState(initial); + // Set when the link stops working (revoked) while the page is open. + const [linkOff, setLinkOff] = useState(null); + const [offline, setOffline] = useState(false); + + const refetch = useCallback(async () => { + try { + const response = await fetch(`/api/judge/${token}`, { cache: "no-store" }); + if (response.status === 403 || response.status === 404) { + setLinkOff(((await response.json()) as { error: string }).error); + return; + } + if (!response.ok) throw new Error(String(response.status)); + setPayload((await response.json()) as JudgePayload); + setOffline(false); + } catch { + setOffline(true); + } + }, [token]); + + // The game channel pings when the host locks judging, moves the spotlight or edits the game. + useRefreshChannel(channelNames.game(payload.game.id), refetch); + + const serverValues = useMemo( + () => Object.fromEntries(payload.scores.map((score) => [cellKey(score.teamId, score.criterionId), score.value])), + [payload.scores], + ); + + const save = useCallback( + async (key: string, value: number | null): Promise => { + const [teamId, criterionId] = key.split("|"); + const response = await fetch(`/api/judge/${token}/scores`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ teamId, criterionId, value }), + }); + if (response.ok) return { ok: true }; + const { error } = (await response.json().catch(() => ({}))) as { error?: string }; + // Locked, finished or revoked: reload so the page shows why. + if ([403, 404, 409].includes(response.status)) void refetch(); + return { ok: false, error: error ?? "That score wasn't saved. Try again." }; + }, + [token, refetch], + ); + + const { edit, valueOf, isSaving, errorFor } = usePendingEdits(serverValues, save, refetch); + + if (linkOff) return {linkOff}; + + const readOnly = payload.state !== "active"; + // Spotlighted team first; everyone else keeps the host's order. + const teams = [...payload.teams].sort( + (a, b) => Number(b.id === payload.spotlightTeamId) - Number(a.id === payload.spotlightTeamId), + ); + + return ( +
+
+

{payload.game.title}

+

Judging as {payload.judgeName}

+ {payload.game.judgeNotes && ( +
+ Judge notes +

{payload.game.judgeNotes}

+
+ )} +
+ + {readOnly && ( +

+ {judgeMessages[payload.state as Exclude]} +

+ )} + {offline && ( +

+ You're offline. Scores you tap now won't save until the connection is back. +

+ )} + {payload.teams.length === 0 &&

No teams yet. The host is still setting up.

} + + {teams.map((team) => { + const inTeam = (key: string) => key.startsWith(`${team.id}|`); + const spotlit = team.id === payload.spotlightTeamId; + const scored = payload.criteria.filter((criterion) => valueOf(cellKey(team.id, criterion.id)) !== null).length; + const error = errorFor(inTeam); + + return ( +
+

+ + {spotlit && ★ Now} + {team.name} +

+ + {payload.criteria.map((criterion) => { + const key = cellKey(team.id, criterion.id); + const inputProps = { + range: criterion, + value: valueOf(key), + label: `${criterion.label} for ${team.name}`, + disabled: readOnly, + onChange: (next: number | null) => edit(key, next), + }; + return ( +
+

+ {criterion.label} + {criterion.description && ( + {criterion.description} + )} +

+ {criterion.inputType === "blocks" ? ( + + ) : ( + + )} +
+ ); + })} + +

+ {isSaving(inTeam) ? ( + "Saving…" + ) : error ? ( + {error} + ) : scored > 0 ? ( + `Saved ✓${scored < payload.criteria.length ? ` · ${scored} of ${payload.criteria.length} scored` : ""}` + ) : ( + "" + )} +

+
+ ); + })} +
+ ); +} + +export function JudgeMessage({ children }: { children: string }) { + return ( +
+

{children}

+
+ ); +} diff --git a/src/components/judge/open-link-form.tsx b/src/components/judge/open-link-form.tsx new file mode 100644 index 0000000..619c55c --- /dev/null +++ b/src/components/judge/open-link-form.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Field, inputClass } from "@/components/ui/field"; + +const storageKey = (openToken: string) => `gnl-judge:${openToken}`; + +/** Open link: type a name, become a judge, land on the personal judge page. */ +export function OpenLinkForm({ openToken, gameTitle }: { openToken: string; gameTitle: string }) { + const router = useRouter(); + const [name, setName] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + // Until we've looked in localStorage we don't know whether to show the form. + const [checked, setChecked] = useState(false); + + // Someone who already joined on this phone goes straight back to their own page, + // so a refresh or re-scan doesn't create a second judge. + useEffect(() => { + let saved: string | null = null; + try { + saved = localStorage.getItem(storageKey(openToken)); + } catch { + // Storage can be blocked (private mode); they'll just see the form. + } + if (saved && /^[0-9a-f]{32}$/.test(saved)) router.replace(`/j/${saved}`); + // eslint-disable-next-line react-hooks/set-state-in-effect -- localStorage is only readable after mount + else setChecked(true); + }, [openToken, router]); + + const join = async () => { + setPending(true); + setError(null); + try { + const response = await fetch(`/api/judge-open/${openToken}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + const body = (await response.json()) as { judgeToken?: string; error?: string }; + if (!response.ok || !body.judgeToken) { + setError(body.error ?? "Couldn't join. Try again."); + setPending(false); + return; + } + try { + localStorage.setItem(storageKey(openToken), body.judgeToken); + } catch { + // Without storage a refresh of this page would ask for the name again; the + // personal page they are about to land on still works on its own. + } + router.replace(`/j/${body.judgeToken}`); + } catch { + setError("Couldn't reach the server. Check your connection and try again."); + setPending(false); + } + }; + + if (!checked) return null; + + return ( +
+
+

{gameTitle}

+

You're joining as a judge. What's your name?

+
+
{ + event.preventDefault(); + void join(); + }} + > + + setName(event.target.value)} + required + maxLength={60} + autoComplete="name" + autoFocus + className={inputClass} + /> + + +

+ {error} +

+
+
+ ); +} diff --git a/src/lib/hooks/use-pending-edits.ts b/src/lib/hooks/use-pending-edits.ts new file mode 100644 index 0000000..0a85ce9 --- /dev/null +++ b/src/lib/hooks/use-pending-edits.ts @@ -0,0 +1,92 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +// An edit the server data hasn't caught up with yet. Shown instead of the server's value +// so a tap feels instant (optimistic UI). Used by the control room grid and the judge page. +type PendingEdit = { value: number | null; version: number; savedAt: number | null }; + +export type SaveResult = { ok: true } | { ok: false; error: string }; + +// A saved edit normally disappears when refreshed data matches it. If it never matches +// (someone else changed the cell), stop masking the server's value after this long. +const STALE_MS = 5000; + +/** + * Autosave for a set of numeric cells addressed by string keys. + * @param serverValues the latest saved value per key (absent = not scored) + * @param save writes one cell; its promise settling drives the saving/error state + * @param onSaved called after each successful save, e.g. to refetch + */ +export function usePendingEdits( + serverValues: Record, + save: (key: string, value: number | null) => Promise, + onSaved?: () => void, +) { + const [pending, setPending] = useState>({}); + const [errors, setErrors] = useState>({}); + const versions = useRef(0); + + // Drop optimistic edits once the server data has caught up with them. + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reconciling local edits with fresh server data + setPending((current) => { + const now = Date.now(); + const kept = Object.entries(current).filter( + ([key, edit]) => + edit.savedAt === null || + ((serverValues[key] ?? null) !== edit.value && now - edit.savedAt < STALE_MS), + ); + return kept.length === Object.keys(current).length ? current : Object.fromEntries(kept); + }); + }, [serverValues]); + + const edit = useCallback( + (key: string, value: number | null) => { + const version = ++versions.current; + setPending((current) => ({ ...current, [key]: { value, version, savedAt: null } })); + setErrors((current) => without(current, key)); + + // Only the newest edit of a cell may update or clear its pending state. + const settle = (savedAt: number | null) => + setPending((current) => { + if (current[key]?.version !== version) return current; + return savedAt === null ? without(current, key) : { ...current, [key]: { ...current[key], savedAt } }; + }); + const failed = (message: string) => { + settle(null); + setErrors((current) => ({ ...current, [key]: message })); + }; + + save(key, value) + .then((result) => { + if (result.ok) { + settle(Date.now()); + onSaved?.(); + } else { + failed(result.error); + } + }) + .catch(() => + failed("That score wasn't saved — couldn't reach the server. Check your connection and enter it again."), + ); + }, + [save, onSaved], + ); + + /** The value to display: the unsaved edit if there is one, else the server's. */ + const valueOf = (key: string): number | null => + key in pending ? pending[key].value : (serverValues[key] ?? null); + + const isSaving = (matches: (key: string) => boolean = () => true) => + Object.entries(pending).some(([key, { savedAt }]) => savedAt === null && matches(key)); + + const errorFor = (matches: (key: string) => boolean = () => true) => + Object.entries(errors).find(([key]) => matches(key))?.[1] ?? null; + + return { edit, valueOf, isSaving, errorFor }; +} + +function without(record: Record, key: string): Record { + return Object.fromEntries(Object.entries(record).filter(([other]) => other !== key)); +} diff --git a/src/lib/judge.server.ts b/src/lib/judge.server.ts new file mode 100644 index 0000000..d2fbeac --- /dev/null +++ b/src/lib/judge.server.ts @@ -0,0 +1,186 @@ +import "server-only"; + +import { + checkJudgeWrite, + judgeMessages, + judgeState, + type JudgePayload, + type JudgeScoreInput, + type WriteCheck, +} from "@/lib/judge"; +import { notify } from "@/lib/realtime"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { judgeTokenSchema, openTokenSchema } from "@/lib/tokens"; + +// Everything here uses the service-role client, so the token check at the top of each +// function is the only gate (CLAUDE.md rule 2). Every query is scoped by what that token grants. + +const MAX_JUDGES_PER_GAME = 50; + +export type JudgeLookup = + | { ok: true; payload: JudgePayload } + | { ok: false; status: 403 | 404; error: string }; + +export async function getJudgePayload(token: string): Promise { + const parsed = judgeTokenSchema.safeParse(token); + if (!parsed.success) return { ok: false, status: 404, error: judgeMessages.linkOff }; + + const supabase = createAdminClient(); + const { data: judge, error } = await supabase + .from("judges") + .select("id, name, revoked, games!inner(*)") + .eq("token", parsed.data) + .maybeSingle(); + if (error) throw new Error(`Could not load the judge: ${error.message}`); + if (!judge) return { ok: false, status: 404, error: judgeMessages.linkOff }; + if (judge.revoked) return { ok: false, status: 403, error: judgeMessages.linkOff }; + const game = judge.games; + + const [teams, criteria, scores] = await Promise.all([ + supabase + .from("teams") + .select("id, name, color") + .eq("event_id", game.event_id) + .order("sort_order") + .order("created_at"), + supabase.from("criteria").select("*").eq("game_id", game.id).order("sort_order"), + // Only this judge's rows. + supabase.from("scores").select("team_id, criterion_id, value").eq("judge_id", judge.id), + ]); + const loadError = teams.error ?? criteria.error ?? scores.error; + if (loadError) throw new Error(`Could not load the game: ${loadError.message}`); + + return { + ok: true, + payload: { + state: judgeState(game), + judgeName: judge.name, + // Only what a judge should see: no admin notes, tokens or other judges. + game: { id: game.id, title: game.title, judgeNotes: game.judge_notes }, + spotlightTeamId: game.spotlight_team_id, + criteria: (criteria.data ?? []).map((criterion) => ({ + id: criterion.id, + label: criterion.label, + description: criterion.description, + inputType: criterion.input_type, + min: criterion.min_value, + max: criterion.max_value, + step: criterion.step, + })), + teams: teams.data ?? [], + scores: (scores.data ?? []).map((score) => ({ + teamId: score.team_id, + criterionId: score.criterion_id, + value: score.value, + })), + }, + }; +} + +export async function saveJudgeScore(token: string, input: JudgeScoreInput): Promise { + const parsed = judgeTokenSchema.safeParse(token); + if (!parsed.success) return { ok: false, status: 404, error: judgeMessages.linkOff }; + + const supabase = createAdminClient(); + // One round trip for all three lookups; checkJudgeWrite ties them together. + const [judge, criterion, team] = await Promise.all([ + supabase + .from("judges") + .select("id, revoked, game_id, games!inner(id, event_id, scoring_mode, status, judging_locked)") + .eq("token", parsed.data) + .maybeSingle(), + supabase + .from("criteria") + .select("game_id, min_value, max_value, step") + .eq("id", input.criterionId) + .maybeSingle(), + supabase.from("teams").select("event_id").eq("id", input.teamId).maybeSingle(), + ]); + const loadError = judge.error ?? criterion.error ?? team.error; + if (loadError) throw new Error(`Could not check the score: ${loadError.message}`); + + const check = checkJudgeWrite({ + judge: judge.data, + game: judge.data?.games ?? null, + criterion: criterion.data, + team: team.data, + value: input.value, + }); + if (!check.ok || !judge.data) return check; + const { id: judgeId, game_id: gameId, games: game } = judge.data; + + const { error } = + input.value === null + ? await supabase + .from("scores") + .delete() + .eq("judge_id", judgeId) + .eq("team_id", input.teamId) + .eq("criterion_id", input.criterionId) + : await supabase.from("scores").upsert( + { + game_id: gameId, + team_id: input.teamId, + criterion_id: input.criterionId, + judge_id: judgeId, + value: input.value, + }, + { onConflict: "team_id,criterion_id,judge_id" }, + ); + if (error) throw new Error(`Could not save the score: ${error.message}`); + + // No gameId: other judges don't see this score, so only the host and scoreboard are pinged. + await notify({ eventId: game.event_id }); + return { ok: true }; +} + +export type OpenLinkLookup = + | { ok: true; gameId: string; eventId: string; gameTitle: string } + | { ok: false; status: 404; error: string }; + +/** An open link works only while the host has it switched on for a judged game. */ +export async function getOpenLinkGame(openToken: string): Promise { + const off = { ok: false, status: 404, error: judgeMessages.linkOff } as const; + const parsed = openTokenSchema.safeParse(openToken); + if (!parsed.success) return off; + + const { data: game, error } = await createAdminClient() + .from("games") + .select("id, event_id, title, open_link_enabled, scoring_mode") + .eq("open_token", parsed.data) + .maybeSingle(); + if (error) throw new Error(`Could not load the game: ${error.message}`); + if (!game || !game.open_link_enabled || game.scoring_mode !== "judged") return off; + + return { ok: true, gameId: game.id, eventId: game.event_id, gameTitle: game.title }; +} + +export type JoinResult = + | { ok: true; judgeToken: string } + | { ok: false; status: 404 | 409; error: string }; + +export async function joinViaOpenLink(openToken: string, name: string): Promise { + const game = await getOpenLinkGame(openToken); + if (!game.ok) return game; + + const supabase = createAdminClient(); + // Anyone with the link can join, so cap it: a shared link can't flood the game with judges. + const { count, error: countError } = await supabase + .from("judges") + .select("id", { count: "exact", head: true }) + .eq("game_id", game.gameId); + if (countError) throw new Error(`Could not join: ${countError.message}`); + if ((count ?? 0) >= MAX_JUDGES_PER_GAME) { + return { ok: false, status: 409, error: "This game already has the maximum number of judges. Ask the host." }; + } + + const { data: judge, error } = await supabase + .from("judges") + .insert({ game_id: game.gameId, name, source: "open_link" }) + .select("token") + .single(); + if (error) throw new Error(`Could not join: ${error.message}`); + + await notify({ eventId: game.eventId }); + return { ok: true, judgeToken: judge.token }; +} diff --git a/src/lib/judge.test.ts b/src/lib/judge.test.ts new file mode 100644 index 0000000..c60dfa2 --- /dev/null +++ b/src/lib/judge.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { checkJudgeWrite, judgeNameSchema, judgeScoreSchema, judgeState } from "./judge"; + +const game = { id: "g1", event_id: "e1", scoring_mode: "judged", status: "live", judging_locked: false }; +const facts = { + judge: { revoked: false, game_id: "g1" }, + game, + criterion: { game_id: "g1", min_value: 1, max_value: 5, step: 0.5 }, + team: { event_id: "e1" }, + value: 3.5 as number | null, +}; +const status = (overrides: Partial[0]>) => { + const result = checkJudgeWrite({ ...facts, ...overrides }); + return result.ok ? 200 : result.status; +}; + +describe("checkJudgeWrite", () => { + it("accepts a valid score, the range ends, and a clear", () => { + expect(status({})).toBe(200); + expect(status({ value: 1 })).toBe(200); + expect(status({ value: 5 })).toBe(200); + expect(status({ value: null })).toBe(200); + }); + + it("refuses an unknown or revoked judge", () => { + expect(status({ judge: null })).toBe(404); + expect(status({ game: null })).toBe(404); + expect(status({ judge: { revoked: true, game_id: "g1" } })).toBe(403); + }); + + it("refuses when the game is locked, done or not judged — even for a clear", () => { + expect(status({ game: { ...game, judging_locked: true } })).toBe(409); + expect(status({ game: { ...game, status: "done" }, value: null })).toBe(409); + expect(status({ game: { ...game, scoring_mode: "direct" } })).toBe(409); + }); + + it("refuses a criterion from another game or a team from another event", () => { + expect(status({ criterion: { ...facts.criterion, game_id: "g2" } })).toBe(400); + expect(status({ criterion: null })).toBe(400); + expect(status({ team: { event_id: "e2" } })).toBe(400); + expect(status({ team: null })).toBe(400); + }); + + it("refuses values out of range or off step", () => { + expect(status({ value: 0.5 })).toBe(400); + expect(status({ value: 5.5 })).toBe(400); + expect(status({ value: 3.25 })).toBe(400); + }); + + it("tolerates float noise on fractional steps", () => { + const criterion = { game_id: "g1", min_value: 0, max_value: 1, step: 0.1 }; + expect(status({ criterion, value: 0.3 })).toBe(200); + expect(status({ criterion, value: 0.7 })).toBe(200); + }); +}); + +describe("judgeState", () => { + it("reports why a judge is read-only, most fundamental reason first", () => { + expect(judgeState(game)).toBe("active"); + expect(judgeState({ ...game, status: "upcoming" })).toBe("active"); + expect(judgeState({ ...game, judging_locked: true })).toBe("locked"); + expect(judgeState({ ...game, judging_locked: true, status: "done" })).toBe("done"); + expect(judgeState({ ...game, scoring_mode: "direct", status: "done" })).toBe("direct"); + }); +}); + +describe("request schemas", () => { + const id = "3f2b8c1e-9d4a-4e6f-8b7a-1c2d3e4f5a6b"; + + it("validates a score body", () => { + expect(judgeScoreSchema.safeParse({ teamId: id, criterionId: id, value: 4 }).success).toBe(true); + expect(judgeScoreSchema.safeParse({ teamId: id, criterionId: id, value: null }).success).toBe(true); + expect(judgeScoreSchema.safeParse({ teamId: "x", criterionId: id, value: 4 }).success).toBe(false); + expect(judgeScoreSchema.safeParse({ teamId: id, criterionId: id, value: "4" }).success).toBe(false); + expect(judgeScoreSchema.safeParse({ teamId: id, criterionId: id }).success).toBe(false); + }); + + it("trims and bounds a judge name", () => { + expect(judgeNameSchema.parse({ name: " Melissa " }).name).toBe("Melissa"); + expect(judgeNameSchema.safeParse({ name: " " }).success).toBe(false); + expect(judgeNameSchema.safeParse({ name: "x".repeat(61) }).success).toBe(false); + }); +}); diff --git a/src/lib/judge.ts b/src/lib/judge.ts new file mode 100644 index 0000000..5d0bdd9 --- /dev/null +++ b/src/lib/judge.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; + +import { idSchema } from "@/lib/tokens"; + +// Types and rules shared by the judge API and the judge page. Pure: no DB access. + +export const judgeMessages = { + linkOff: "This link has been turned off. Ask the host for a new one.", + locked: "The host has locked judging for this game. Your scores are saved.", + done: "This game is finished. Your scores are saved.", + direct: "The host is scoring this game directly, so your scores aren't needed.", +} as const; + +/** Why a judge can or can't score right now. */ +export type JudgeState = "active" | "locked" | "done" | "direct"; + +export type JudgePayload = { + state: JudgeState; + judgeName: string; + game: { id: string; title: string; judgeNotes: string | null }; + spotlightTeamId: string | null; + criteria: Array<{ + id: string; + label: string; + description: string | null; + inputType: "blocks" | "number"; + min: number; + max: number; + step: number; + }>; + teams: Array<{ id: string; name: string; color: string }>; + /** This judge's own scores only — never other judges' or any totals (PRD 4.4). */ + scores: Array<{ teamId: string; criterionId: string; value: number }>; +}; + +export const judgeScoreSchema = z.object({ + teamId: idSchema, + criterionId: idSchema, + /** null clears the score. */ + value: z.number().min(-999999).max(999999).nullable(), +}); +export type JudgeScoreInput = z.infer; + +export const judgeNameSchema = z.object({ + name: z + .string() + .trim() + .min(1, "Enter your name.") + .max(60, "Your name can be at most 60 characters."), +}); + +type GameFacts = { id: string; event_id: string; scoring_mode: string; status: string; judging_locked: boolean }; + +export function judgeState(game: Pick): JudgeState { + if (game.scoring_mode !== "judged") return "direct"; + if (game.status === "done") return "done"; + if (game.judging_locked) return "locked"; + return "active"; +} + +export type WriteCheck = { ok: true } | { ok: false; status: number; error: string }; + +const refuse = (status: number, error: string): WriteCheck => ({ ok: false, status, error }); + +/** + * Every rule a judge's score must pass (docs/ARCHITECTURE.md, "Judge write checks"). + * Rows are looked up by the ids the phone sent, so each one must be tied back to the + * judge's own game here — otherwise a judge could write into another game or event. + */ +export function checkJudgeWrite(facts: { + judge: { revoked: boolean; game_id: string } | null; + game: GameFacts | null; + criterion: { game_id: string; min_value: number; max_value: number; step: number } | null; + team: { event_id: string } | null; + value: number | null; +}): WriteCheck { + const { judge, game, criterion, team, value } = facts; + if (!judge || !game) return refuse(404, judgeMessages.linkOff); + if (judge.revoked) return refuse(403, judgeMessages.linkOff); + + const state = judgeState(game); + if (state !== "active") return refuse(409, judgeMessages[state]); + + if (!criterion || criterion.game_id !== judge.game_id) { + return refuse(400, "That criterion isn't part of this game. Reload the page."); + } + if (!team || team.event_id !== game.event_id) { + return refuse(400, "That team isn't part of this game night. Reload the page."); + } + + if (value !== null) { + if (value < criterion.min_value || value > criterion.max_value) { + return refuse(400, `That score must be between ${criterion.min_value} and ${criterion.max_value}.`); + } + const steps = (value - criterion.min_value) / criterion.step; + if (Math.abs(steps - Math.round(steps)) > 1e-9) { + return refuse(400, `That score must go up in steps of ${criterion.step}.`); + } + } + return { ok: true }; +} diff --git a/src/lib/live-view.ts b/src/lib/live-view.ts index ac7c8d3..ec03359 100644 --- a/src/lib/live-view.ts +++ b/src/lib/live-view.ts @@ -23,6 +23,14 @@ export type LiveCriterion = { step: number; }; +export type LiveJudge = { + id: string; + name: string; + revoked: boolean; + /** "teamId|criterionId" → this judge's value */ + values: Record; +}; + export type LiveCell = CellResult & { /** The admin's own row: the score in a direct game, the override in a judged game. */ adminValue: number | null; @@ -36,6 +44,10 @@ export type LiveGame = { pointsMode: PointsMode; multiplier: number; adminNotes: string | null; + judgingLocked: boolean; + spotlightTeamId: string | null; + /** Judged games only; never includes judge tokens. */ + judges: LiveJudge[]; criteria: LiveCriterion[]; /** teamId → criterionId → cell */ cells: Record>; @@ -100,6 +112,20 @@ export function buildLiveView(data: EventData): LiveView { pointsMode: game.points_mode, multiplier: game.multiplier, adminNotes: game.admin_notes, + judgingLocked: game.judging_locked, + spotlightTeamId: game.spotlight_team_id, + judges: data.judges + .filter((judge) => judge.game_id === game.id) + .map((judge) => ({ + id: judge.id, + name: judge.name, + revoked: judge.revoked, + values: Object.fromEntries( + data.scores + .filter((score) => score.judge_id === judge.id) + .map((score) => [`${score.team_id}|${score.criterion_id}`, score.value]), + ), + })), criteria: data.criteria .filter((criterion) => criterion.game_id === game.id) .map((criterion) => ({ diff --git a/src/lib/queries/event.ts b/src/lib/queries/event.ts index 13a68bd..10027dc 100644 --- a/src/lib/queries/event.ts +++ b/src/lib/queries/event.ts @@ -4,7 +4,10 @@ import type { Database, Tables } from "@/lib/database.types"; type Client = SupabaseClient; -export type GameWithCriteria = Tables<"games"> & { criteria: Tables<"criteria">[] }; +export type GameWithCriteria = Tables<"games"> & { + criteria: Tables<"criteria">[]; + judges: Tables<"judges">[]; +}; export type EventSetup = { event: Tables<"events">; @@ -24,11 +27,12 @@ export async function loadEventSetup(supabase: Client, eventId: string): Promise .order("created_at"), supabase .from("games") - .select("*, criteria(*)") + .select("*, criteria(*), judges(*)") .eq("event_id", eventId) .order("sort_order") .order("created_at") - .order("sort_order", { referencedTable: "criteria" }), + .order("sort_order", { referencedTable: "criteria" }) + .order("created_at", { referencedTable: "judges" }), ]); const error = event.error ?? teams.error ?? games.error; diff --git a/src/lib/rate-limit.test.ts b/src/lib/rate-limit.test.ts new file mode 100644 index 0000000..d069cbd --- /dev/null +++ b/src/lib/rate-limit.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { createRateLimiter } from "./rate-limit"; + +describe("createRateLimiter", () => { + it("allows a burst up to the limit, then refuses", () => { + const allow = createRateLimiter(3); + expect([allow("a", 0), allow("a", 0), allow("a", 0), allow("a", 0)]).toEqual([true, true, true, false]); + }); + + it("refills over time", () => { + const allow = createRateLimiter(2); + allow("a", 0); + allow("a", 0); + expect(allow("a", 100)).toBe(false); + expect(allow("a", 600)).toBe(true); + expect(allow("a", 600)).toBe(false); + }); + + it("never banks more than one second's worth", () => { + const allow = createRateLimiter(2); + allow("a", 0); + expect([allow("a", 60_000), allow("a", 60_000), allow("a", 60_000)]).toEqual([true, true, false]); + }); + + it("tracks keys separately and forgets the least recently used", () => { + const allow = createRateLimiter(1, 2); + expect(allow("a", 0)).toBe(true); + expect(allow("b", 0)).toBe(true); + expect(allow("a", 0)).toBe(false); + // "c" evicts "b" (least recently used), so "b" starts fresh. + expect(allow("c", 0)).toBe(true); + expect(allow("b", 0)).toBe(true); + }); +}); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..c6a74f2 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,23 @@ +type Bucket = { tokens: number; updatedAt: number }; + +/** + * In-memory token bucket: `perSecond` requests per key, with bursts up to the same amount. + * Per server instance, which is enough for v1: it stops a stuck phone or a script from + * hammering one link, not a determined attacker (docs/ARCHITECTURE.md). + */ +export function createRateLimiter(perSecond: number, maxKeys = 5000) { + const buckets = new Map(); + + return function allow(key: string, now = Date.now()): boolean { + const bucket = buckets.get(key) ?? { tokens: perSecond, updatedAt: now }; + const refilled = Math.min(perSecond, bucket.tokens + ((now - bucket.updatedAt) / 1000) * perSecond); + const allowed = refilled >= 1; + + // Re-insert so the Map's order is least-recently-used first, then evict the oldest. + buckets.delete(key); + buckets.set(key, { tokens: allowed ? refilled - 1 : refilled, updatedAt: now }); + if (buckets.size > maxKeys) buckets.delete(buckets.keys().next().value!); + + return allowed; + }; +}