diff --git a/.gitignore b/.gitignore
index a278d653..cbdd6f3f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,6 +54,8 @@ next-env.d.ts
*.db
*.db-journal
migrations/
+!packages/judging-db/migrations/
+!packages/judging-db/migrations/**
# Editor
.vscode/*
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 00000000..1680889d
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,444 @@
+# Judging platform plan
+
+**Repo:** `query` · **Drafted:** 2026-10-07 · **Working name:** `panel` (rename freely)
+
+A hackathon judging platform built as ordinary workspaces in this monorepo
+(`packages/judging-*`, scope `@query/judging-*`) and served by the portal
+(`sites/mainweb`). Hacklytics
+is its first user. It is not being split into its own repository.
+
+The current judging code in `packages/api/src/routers/judge` and
+`sites/mainweb` keeps running until cutover, then is deleted.
+
+---
+
+## 0. Why build it this way
+
+**What exists** works for one event: pool dispatch with QR arrival
+(`dispatch.ts`), per-judge z-scores with a Bayesian shrink (`rankings.ts`),
+frozen results, printable table cards. It is also welded to one edition: a
+five-column rubric in `judge_vote`, timers as constants, tracks as `text[]`,
+a `"createx"` string in the matcher, one results track, table distance as
+`abs(n1 - n2)`, auth and roles borrowed from the club portal, cache
+per-instance and polling for every live view.
+
+**What "real infrastructure" means here**
+
+1. **A product, not a feature.** Own data model, own API, own UI, own deploy.
+ Anyone with Postgres can run it. The club site is a client of it.
+2. **Everything is data.** Rubric, tracks, prizes, timers, rooms, scoring
+ weights, dispatch strategy, branding: rows and config, never code.
+3. **Correct under load and partition.** Advisory locks and unique indexes for
+ every write that matters; realtime as a convenience layer that can be
+ switched off without losing correctness.
+4. **Boring and portable.** Postgres, Node, one Docker image, optional Redis.
+ No vendor SDK in the core. Cloud bits live behind adapters.
+5. **Pure core.** Every decision (next table, score, rank) is a function from
+ plain data to plain data, tested without a database.
+
+---
+
+## 1. Layout
+
+```
+packages/judging-core/ @query/judging-core pure TS, zero runtime deps. rubric
+ validation, dispatch, distance,
+ uncertainty, aggregators, phase
+ state machine, timers
+packages/judging-db/ @query/judging-db Drizzle schema, SQL migrations
+ (generated, committed), seed, demo
+packages/judging-server/ @query/judging-server Node service: tRPC over HTTP,
+ OpenAPI mirror, WebSocket hub, auth
+ adapters, outbox + webhooks,
+ Prometheus metrics. A library:
+ mainweb mounts it at /api/panel
+packages/judging-cli/ @query/judging-cli import/export/seed/migrate/doctor
+sites/mainweb/ web portal routes: judge desk
+ (/judge/panel), organizer console
+ (/admin/judging/panel), public
+ board and team feedback (/judging)
+docs/judging/ ADRs, deploy, API, rubric guide
+```
+
+Lint, typecheck, test and build run with the rest of the repo in `ci.yml`.
+`packages/api` imports `@query/judging-*` like any other workspace (§7).
+
+---
+
+## 2. Domain model (`@query/judging-db`)
+
+Multi-tenant from day one so one deployment can host many events; a
+single-event self-host simply has one org and one event.
+
+```
+organization id, slug, name, branding jsonb (logo, colors, tagline)
+event id, org_id, slug, name, starts_at, ends_at,
+ phase enum (setup | submissions_open | submissions_closed |
+ judging_live | judging_closed | published | archived)
+event_config event_id pk, target_seconds, hard_limit_seconds,
+ walk_limit_seconds, submit_grace_seconds,
+ min_looks_per_project, dispatch_strategy (coverage|uncertainty),
+ pairwise_enabled, pairwise_weight, bayesian_c,
+ calibration_looks, calibration_weight,
+ feedback_cards_enabled, leaderboard_public bool,
+ board_poll_seconds (fallback when ws is off)
+
+track id, event_id, slug, name, kind (main|sponsor|special),
+ judge_group text, rubric_id null, position, is_active
+prize id, track_id, place, title, amount, description
+
+rubric id, event_id, name, is_default
+criterion id, rubric_id, position, key, label, description,
+ min, max, weight, anchors jsonb
+
+zone id, event_id, name, position
+table event_id, number, zone_id, x null, y null unique(event_id, number)
+
+project id, event_id, external_id null, name, description,
+ team_name, members jsonb, links jsonb, table_number null,
+ zone_id null, qr_token uuid unique, arrived_first_at,
+ withdrawn_at
+project_track project_id, track_id
+
+judge id, event_id, external_id null, name, email, org, title,
+ status (invited|applied|approved|suspended),
+ track_id null, zone_id null, is_lead
+ unique(event_id, email)
+
+visit id, event_id, judge_id, project_id, judge_group,
+ handed_out_at, arrived_at null, completed_at null,
+ voided_at null, void_reason null
+vote id, visit_id unique, judge_id, project_id, event_id,
+ total numeric, comment, duration_seconds, is_calibration
+vote_score vote_id, criterion_id, value unique(vote_id, criterion_id)
+comparison id, event_id, judge_id, judge_group, a_project_id,
+ b_project_id, outcome (a|b|tie), created_at
+
+result_run id, event_id, computed_at, computed_by, config_snapshot jsonb,
+ published_at null, notes
+result run_id, project_id, track_id, placement, score numeric,
+ rubric_component, pairwise_component, vote_count,
+ comparison_count, flags text[]
+ unique(run_id, project_id, track_id)
+
+event_log id, event_id, kind, actor jsonb, subject jsonb, payload jsonb,
+ created_at -- append only
+outbox id, event_id, topic, payload jsonb, created_at,
+ delivered_at null, attempts
+webhook id, org_id, url, secret, topics text[], is_active
+api_key id, org_id, hashed_key, scopes text[], created_at, revoked_at
+
+user id, email, name -- platform identities
+membership user_id, org_id, role (owner|admin|organizer|volunteer)
+judge_identity judge_id, user_id -- which login is which judge
+```
+
+Design notes:
+
+- `visit` replaces `judge_queue`: it is a record of a hand-out, not a plan.
+ A visit without a vote past the hard limit is voided, not deleted.
+- `vote.total` is derived at write time from `vote_score` and the criterion
+ weights; the score rows are the truth.
+- `result_run` snapshots the config used, so a result is reproducible and an
+ admin can compute twice and compare before publishing one.
+- `external_id` on project and judge is how an integrator (the club site, a
+ Devpost CSV, a Google Form) keeps its own identity in sync.
+- Migrations are generated SQL files committed under `packages/judging-db/migrations`,
+ applied with `panel migrate`. No `drizzle-kit push` in this product; a
+ self-hoster needs reproducible, reviewable migrations.
+
+---
+
+## 3. The core (`@query/judging-core`)
+
+No IO, no dates from the clock (every function takes `now`), no randomness
+without an injected source. Each module has a fixture-based test file.
+
+- `phase.ts` state machine: allowed transitions, what each phase permits
+ (submit, apply, dispatch, vote, compute, publish). Server checks it on
+ every mutation.
+- `rubric.ts` `validateScores(criteria, input)`, `weightedTotal(criteria, scores)`.
+- `timers.ts` `isLive`, `isPastCutoff`, taking the config.
+- `distance.ts` `distance(a, b, zones)`: same zone `abs(n1 - n2)`; different
+ zone a large constant plus the diff; Euclidean when `x, y` set.
+- `dispatch.ts` `pickNext(candidates, judge, config, random)` with strategies:
+ - `coverage`: fewest looks, nearest, random (today's algorithm).
+ - `uncertainty`: coverage until `min_looks_per_project` everywhere, then
+ highest rank-uncertainty first. Uncertainty = variance of normalised
+ scores / sqrt(votes), plus a bonus for contested pairwise neighbours.
+- `aggregate/zscore.ts`, `aggregate/bayes.ts`, `aggregate/bradleyTerry.ts`,
+ `aggregate/blend.ts`, `aggregate/rank.ts` composing them per track and
+ judge group, honouring calibration down-weighting.
+- `uncertainty.ts` per-project vector, consumed by dispatch.
+- `events.ts` typed `kind` union for the log, outbox topics and ws messages,
+ shared by server and web.
+
+Published to npm on release so anyone can run the ranking offline on an
+export.
+
+---
+
+## 4. Server (`@query/judging-server`)
+
+Node 22, Hono as the HTTP shell, tRPC router mounted on it, `trpc-openapi`
+exposing the same procedures as REST with a generated OpenAPI document for
+non-TypeScript integrators. Single process; horizontal scale is stateless
+plus Redis.
+
+**Routers**
+
+- `org`, `event`, `config`, `track`, `prize`, `rubric`, `zone`, `table`:
+ organizer CRUD, phase-gated.
+- `project`: import (API, CSV via CLI), assign tables (per zone, by track
+ balance), withdraw, QR token rotate.
+- `judge`: invite, apply, approve, suspend, assign track and zone.
+- `session` (judge-facing): `next`, `arrive(qrToken)`, `vote`, `compare`,
+ `skip`, `progress`, `rubric`.
+- `results`: `compute` (new `result_run`), `diff(runA, runB)`, `publish`,
+ `unpublish`, `export`.
+- `live`: snapshot for the admin board and public leaderboard.
+- `feedback`: a team's card after publish.
+- `webhook`, `apiKey`: integrator management.
+
+**Concurrency**
+
+- `pg_advisory_xact_lock(hashtext('dispatch:' || event_id))` around `next`,
+ exactly as today. Dispatch is milliseconds and runs a few times a minute
+ even at 60 judges.
+- Unique indexes on `(judge_id, project_id)` across live visits (partial
+ index where `voided_at is null`), on `vote.visit_id`, on
+ `(run_id, project_id, track_id)`.
+- Every mutation writes its `event_log` row and any `outbox` row in the same
+ transaction. The outbox is drained by the same process on a short loop (no
+ external scheduler) and by `panel outbox drain` for operators who want cron.
+
+**Realtime**
+
+- WebSocket hub on `/ws` (`ws` library). Clients subscribe to channels:
+ `event:{id}:board`, `event:{id}:leaderboard`, `judge:{id}`.
+- Fan-out adapter interface: `InMemoryBus` (single instance, default) and
+ `RedisBus` (ioredis pub/sub) selected by `REDIS_URL`. Correctness never
+ depends on the bus; a client that misses a message gets the same state on
+ its next snapshot, and the web falls back to polling at `board_poll_seconds`
+ when the socket is down.
+- Server pushes: dispatch handed out, arrival, vote landed, judge overtime,
+ phase change, results published. Admin can push `recall` to a judge
+ (bring them back to the desk) and `void` a visit.
+
+**Auth adapters** (`server/src/auth/`)
+
+- `magic-link`: email code, built in, zero config beyond SMTP.
+- `oidc`: any provider (Google, GitHub, Okta) via `openid-client`.
+- `trusted-jwt`: an integrator (the club site) mints a short-lived JWT with
+ `{ sub, email, name, org, role, judge_external_id }`; the server verifies
+ with a shared JWKS. This is how mainweb users land in the judge PWA with
+ no second login.
+- `api-key`: for machine integrators (import, export, webhooks).
+
+Roles: `owner`, `admin`, `organizer`, `volunteer` on the org; `judge` is a
+row on the event, not a role, and `is_lead` is a flag. Every procedure
+declares the minimum role; there is no unguarded "protected" alias, the lesson
+from `../PLAN.md` W8.
+
+**Observability**
+
+- `/metrics` Prometheus: dispatch latency, votes per minute, live judges,
+ coverage histogram per event, ws connections, outbox lag.
+- Structured JSON logs with `event_id`, `judge_id`, `visit_id` on every line.
+- `/healthz` (process), `/readyz` (Postgres, bus).
+
+---
+
+## 5. Web (`@query/judging-web`)
+
+Next.js app, standalone output, one Docker image. Branding from
+`organization.branding`; no hardcoded name or colours.
+
+- **Judge PWA** (`/j/[eventSlug]`): installable, works on a phone with one
+ hand. Screens: queue/next, walking (shows table, zone and a count-up),
+ arrive by camera QR or typed table number, score (criteria rendered from
+ rubric rows, anchors as tap targets, comment), compare (previous table vs
+ this one, one tap), done. Scores are held in IndexedDB until the server
+ acknowledges, so a dead Wi-Fi corner loses nothing; the server rejects a
+ late vote past the cutoff and the client shows why.
+- **Admin console** (`/o/[orgSlug]/[eventSlug]`): phase control, rubric and
+ track editors, zone and table layout (grid with drag to set `x, y`),
+ judges (invite, approve, assign, recall), projects (import, tables,
+ withdraw), table cards print view per zone, live board (coverage heatmap by
+ table, judges on the floor, overtime, idle), results (compute, diff two
+ runs, inspect a project's rubric and pairwise components, publish), logs.
+- **Public** (`/e/[orgSlug]/[eventSlug]`): leaderboard after publish, live
+ "judging in progress" board if `leaderboard_public` (no scores, just
+ coverage and phase), prize list per track.
+- **Team feedback** (`/e/.../feedback/[token]`): per-criterion mean vs event
+ median, anonymised comments, placement per track or "not placed". Token
+ minted per project at publish; integrators can fetch it by `external_id`.
+
+---
+
+## 6. CLI (`@query/judging-cli`)
+
+`panel migrate`, `panel seed demo` (an event with 40 projects, 6 judges,
+three tracks, a sponsor rubric), `panel import projects ` (column map in
+a yaml; Devpost export supported out of the box), `panel export results
+ --format csv|json`, `panel outbox drain`, `panel doctor` (checks
+Postgres, Redis, migrations, config).
+
+CSV import was removed from the club site on purpose (`../PLAN.md` §"what not
+to build"). It is in the product because a self-hoster has no other source.
+The club adapter does not use it; it imports through the API.
+
+---
+
+## 7. Integrating the club site
+
+Thin and replaceable.
+
+- `packages/api/src/routers/judging.ts` (new, small): on `promoteSubmissions`
+ calls `@query/judging-server` `project.upsert` with `external_id = hackathon_project.id`;
+ on judge approval calls `judge.upsert`; on publish, pulls results and
+ feedback tokens back into `hackathon_result` so `/hackathons/[id]` keeps
+ rendering from its own tables.
+- The judge desk and organizer console are portal routes on the portal
+ session (`/judge/panel/...`, `/admin/judging/panel/...`). No handoff.
+- Old `routers/judge/*` and the judging UI stay behind a feature flag per
+ edition (`hackathon.judging_backend = legacy | panel`) until one event has
+ run on the new system, then are deleted.
+- Deploy: ships with mainweb. The API is `/api/panel` in the same process,
+ on the club Postgres (ADR-003). No separate service.
+
+---
+
+## 8. Phases
+
+Each phase ships green on `pnpm test`, `lint --max-warnings 0`, `typecheck`,
+`build`, and leaves the current event-day path untouched.
+
+### Phase 0 — Scaffold and core (1 week)
+
+- Workspaces, tsconfig, eslint boundary rule, `judging/README.md` with the
+ vision, MIT `LICENSE`, ADR-001 (why standalone), ADR-002 (pure core).
+- Port `pickNext`, `isLive`, `isPastCutoff`, `projectMatchesTrack`,
+ `zNormalize`, the Bayesian step into `@query/judging-core` as parametrised
+ functions. Bring `dispatch.test.ts` cases with them.
+- Add `distance`, `phase`, `rubric`, `bradleyTerry`, `blend`, `uncertainty`
+ with tests.
+
+**Verify:** `@query/judging-core` has no dependencies and 100% of exported functions
+have a test. The legacy `rankings.ts` output on the seed equals
+`@query/judging-core` `rank()` output byte for byte at `pairwise_weight = 0`.
+
+### Phase 1 — Data and server (2 weeks)
+
+- Schema and first migration. Seed and demo.
+- Hono + tRPC + OpenAPI. Routers from §4 except `live` and `feedback`.
+- Auth: magic-link and trusted-jwt. Roles enforced per procedure.
+- Advisory lock dispatch, visit voiding, outbox table and in-process drain.
+- `event_log` on every mutation. `/metrics`, `/healthz`, `/readyz`.
+- Dockerfile, `docker-compose.yml` (Postgres, server).
+
+**Verify:** DB tests: two concurrent `next` calls never hand out the same
+table; a vote after cutoff is rejected with the configured limit in the
+message; phase machine refuses `vote` outside `judging_live`; every mutation
+in the flow test produces exactly one log row. `panel doctor` passes in the
+compose stack.
+
+### Phase 2 — Judge PWA and admin console (3 weeks)
+
+- Judge flow end to end with offline hold. Camera QR via `getUserMedia`.
+- Admin: phases, rubric, tracks, prizes, zones, tables, judges, projects,
+ table cards, results compute and publish.
+- Branding from org row.
+
+**Verify:** Playwright: seed demo, approve a judge, run a full visit on a
+phone viewport, compute, publish, see the leaderboard. Kill the network
+mid-score, restore, vote lands once.
+
+### Phase 3 — Realtime and live board (1 week)
+
+- WebSocket hub, `InMemoryBus`, `RedisBus`. Live board and leaderboard
+ subscribe; fallback polling.
+- Admin `recall` and `void` pushes.
+
+**Verify:** two server instances behind compose with Redis; a vote on one
+appears on a board connected to the other within one second. Stop Redis;
+boards degrade to polling with a visible indicator and no errors.
+
+### Phase 4 — Hybrid scoring and uncertainty dispatch (2 weeks)
+
+- Compare screen, `comparison` rows, Bradley–Terry in results, blend
+ components visible in the results inspector and the run diff.
+- `uncertainty` strategy with a cached vector per event (in-process, hint
+ only).
+- Team feedback cards and tokens.
+
+**Verify:** property test: consistent comparisons produce the transitive
+order at `w = 1`; `w = 0` reproduces Phase 2 results. Simulation: 200
+projects, 20 judges, three synthetic hours; top-10 Kendall tau is higher
+under `uncertainty` than `coverage` at equal looks. Feedback returns 403
+before publish and for the wrong token.
+
+### Phase 5 — Club integration and first live event (2 weeks plus the event)
+
+- `routers/judging.ts` adapter, trusted-JWT handoff, results pull-back,
+ feature flag per edition.
+- Terraform: two Cloud Run services, Neon connection via Secret Manager,
+ optional Memorystore, uptime check and alert on `/readyz`.
+- Run a small internal event on `panel`, then Hacklytics.
+
+**Verify:** the legacy and new systems produce the same placings on a replay
+of an old event's votes. Post-event retro recorded as ADR-00x.
+
+### Phase 6 — Cleanup (1 week)
+
+- Docs: quick start (`docker compose --profile judging up`, `panel seed demo`),
+ deploy guide, API reference from OpenAPI, rubric design guide, scoring
+ explainer.
+- Delete `routers/judge/*` and the legacy judging UI from the club site.
+
+**Verify:** a fresh clone runs the demo in under five minutes with nothing
+but Docker.
+
+Dropped: splitting judging into its own repository. It stays in this
+monorepo.
+
+---
+
+## 9. Decisions taken and open
+
+**Taken**
+
+- Judging lives in the monorepo as `@query/judging-*` workspaces on the club
+ Postgres (ADR-003). No later extraction.
+- WebSockets on our own server with a bus adapter; Redis optional.
+- Terraform for GCP as an optional module; Docker Compose is the primary
+ documented deploy.
+- Committed SQL migrations, not `push`.
+- Pairwise comparisons against the judge's previous table, not a
+ server-chosen comparator (no extra walk, no recall burden). Revisit after
+ the Phase 4 simulation.
+- CSV import exists in the product; the club adapter does not use it.
+
+**Open**
+
+- **Name.** `panel` is a placeholder; check npm and GitHub availability
+ before Phase 6.
+- **Rank visibility.** Teams outside the podium see "not placed", not a
+ number. Organizer's call per event; default off.
+- **Hosted mode.** Multi-tenant tables exist, but billing, org sign-up and
+ abuse controls are not in this plan. Self-host first.
+- **Peer voting.** Possible as `judge_group = 'peer'` with its own weight.
+ Not designed here.
+
+---
+
+## 10. Not in this plan
+
+- A scheduler or job queue service. The outbox drain runs in-process; cron is
+ an operator option, not a requirement.
+- Devpost API sync. CSV import covers it; an API adapter can come as a
+ community contribution.
+- Replacing the club site's registration, check-in, teams or submissions.
+ Those stay in `packages/api`; only judging moves.
+- Any change to `packages/db` schema beyond the `judging_backend` flag and the
+ feedback token column needed for cutover.
diff --git a/docs/architecture.md b/docs/architecture.md
index 64914af3..af80d523 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -33,7 +33,7 @@
## Two products, one database
-The schema is split on purpose. Mixing them previously made membership vanish when a new hackathon edition was drafted.
+The schema is split on purpose. Mixing them previously made membership vanish when a new hackathon edition was drafted. Panel judging tables live in this same Postgres database.
### Club
diff --git a/docs/judging/README.md b/docs/judging/README.md
new file mode 100644
index 00000000..823e34c5
--- /dev/null
+++ b/docs/judging/README.md
@@ -0,0 +1,72 @@
+# Panel
+
+Hackathon judging inside the portal. Rubrics, tracks, timers, rooms, and
+dispatch strategy are rows, not code. It runs in the mainweb process on the
+club Postgres, and the portal sign-in decides who is judging or organizing.
+
+## Layout
+
+| Path | Package | What it is |
+| --- | --- | --- |
+| `packages/judging-core/` | `@query/judging-core` | Pure functions. No I/O, no clock, no runtime dependencies. |
+| `packages/judging-db/` | `@query/judging-db` | Schema, committed SQL migrations, demo seed. |
+| `packages/judging-server/` | `@query/judging-server` | The judging API (Hono + tRPC) as a library. |
+| `packages/judging-cli/` | `@query/judging-cli` | `panel migrate`, `panel seed demo`, `panel doctor`, `panel outbox drain`. |
+| `packages/api/src/services/panel.ts` | `@query/api/panel` | One API instance per process, and the portal user as a judging actor. |
+
+## In the portal
+
+Judging is per hackathon edition. On `/admin/judging`, staff pick the edition
+and press "Switch to panel judging". That creates the edition's judging event
+(organization `hacklytics`, event slug = the hackathon id) in `setup`, with
+default timers and the club's five criteria out of ten, then copies in the
+submitted projects and the portal's judges. Switching back to classic leaves
+the event in place.
+
+| Route | Who | What |
+| --- | --- | --- |
+| `/api/panel/*` | the pages below | The judging API. Same routes as `docs/judging/api.md`, without the prefix. |
+| `/judge/panel/[hackathonId]` | approved judges | Judge desk: next table, QR or table number, scores, pairwise, offline hold. |
+| `/admin/judging/panel/[hackathonId]` | staff | Organizer console: phases, floor, tables, rubric, tracks, judges, results. |
+| `/judging/[hackathonId]` | anyone | Public board while judging, placements after publish. |
+| `/judging/[hackathonId]/feedback/[token]` | a team | That team's feedback card after publish. |
+
+Roles come from the `admins` row: `super_admin` is owner, other staff are
+admin, a volunteer row is volunteer, a bug tester has none. A judge is matched
+by the email on their portal judge row. Approving a judge in the portal
+approves them in judging, and deactivating suspends them. Promoting
+submissions copies projects in, and pulling results writes published
+placements back into `hackathon_result`.
+
+Live views poll, and only while they can change. The board polls at the
+event's `board_poll_seconds` and the console polls the floor every five
+seconds, both only during `judging_live` and only while the tab is visible. The judge
+desk asks `/v1/session/status` every 15 seconds while a visit is open and the
+screen is on, which is how a recall or a voided visit reaches the phone.
+Neon suspends after five idle minutes and the free plan has 100 compute hours
+a month, so a forgotten board in any other phase must not keep it awake.
+`/api/panel/readyz` and `/api/panel/metrics` are not exposed for the same
+reason. Judging uses the club's connection pool rather than a second one.
+
+## Checks
+
+`pnpm lint`, `pnpm typecheck`, and `pnpm test` at the root cover these
+packages with the rest of the repository.
+
+## Database
+
+Postgres is the one database the rest of the monorepo uses (`DATABASE_URL`).
+Panel migrations add its tables there.
+
+```
+docker compose up -d
+$env:DATABASE_URL = "postgresql://postgres:postgres@localhost:5433/neondb"
+pnpm --filter @query/judging-cli panel migrate
+pnpm --filter @query/judging-cli panel seed demo
+pnpm --filter @query/judging-cli panel doctor
+```
+
+Webhooks queue in `outbox`. Publishing results drains it; delivered rows are
+deleted, since `event_log` keeps the history. A delivery that failed stays
+queued for `panel outbox drain`. A Hacklytics-size event is on the order of
+20 MB across the judging tables.
diff --git a/docs/judging/adr/001-standalone.md b/docs/judging/adr/001-standalone.md
new file mode 100644
index 00000000..0c335096
--- /dev/null
+++ b/docs/judging/adr/001-standalone.md
@@ -0,0 +1,26 @@
+# ADR-001: Standalone product
+
+## Status
+
+Superseded. Panel's packages are ordinary monorepo workspaces
+(`packages/judging-*`) under the `@query/*` scope, served by the portal. The import
+boundary and the plan to split it out were dropped.
+
+## Decision
+
+Panel lives under `judging/` with its own packages and its own deploy. It does
+not share a module graph with the application that hosts the first event.
+ADR-003 puts the tables in that application's Postgres instead of a second
+database.
+
+Internal imports are `@panel/*` only. A lint rule rejects imports from the
+host. The host is allowed to import `@panel/*`. That one-way edge is what
+makes `git subtree split --prefix=judging` a later extraction rather than a
+rewrite.
+
+## Why
+
+Judging welded into one event's tables cannot be given to the next event, or
+to anyone else, without carrying the rest of that application with it.
+The panel tables are prefixed so they can share that database until an
+extraction.
diff --git a/docs/judging/adr/002-pure-core.md b/docs/judging/adr/002-pure-core.md
new file mode 100644
index 00000000..f5bc4aa5
--- /dev/null
+++ b/docs/judging/adr/002-pure-core.md
@@ -0,0 +1,22 @@
+# ADR-002: Pure core
+
+## Status
+
+Accepted
+
+## Decision
+
+Every decision Panel makes — the next table, whether a visit is still live,
+whether a score is valid, how a set of votes becomes a ranking — is a function
+in `@query/judging-core`. Those functions take plain data, including `now` and a
+`random` source, and return plain data. They do not read the clock, generate
+randomness, or touch a database, the network, or the filesystem.
+
+The server is the only place that performs I/O. It loads rows, calls the
+core, and writes the result in the same transaction as the event log.
+
+## Why
+
+A ranking you cannot recompute from the votes is not a result you can defend.
+Tests of the core need no database, so the scoring bugs surface without
+standing up Postgres. The same functions can later run offline on an export.
diff --git a/docs/judging/adr/003-one-database.md b/docs/judging/adr/003-one-database.md
new file mode 100644
index 00000000..aa2d80d5
--- /dev/null
+++ b/docs/judging/adr/003-one-database.md
@@ -0,0 +1,26 @@
+# ADR-003: One database, one server
+
+## Status
+
+Accepted. Supersedes the separate-database sentence in ADR-001.
+
+## Decision
+
+Panel tables live in the same Postgres as the club app. The connection is
+`DATABASE_URL`. `PANEL_DATABASE_URL` is read only when that is unset. The
+tables that would collide with the club schema are `panel_user`,
+`panel_event`, and `panel_judge`.
+
+There is one API process. Redis is optional. When `REDIS_URL` is unset or
+Redis fails, that process keeps delivering live updates in memory.
+
+`judging/` stays in this monorepo. Splitting it out, and deleting the legacy
+judge UI, wait until an event has run on panel. Sign-in mail waits on
+`PANEL_SMTP_URL`. Neither connection is made yet.
+
+## Why
+
+The host is the existing app. A second database and a second API fleet were
+the extraction plan, and they are not how this checkout runs. The replay of
+the published score fixture still matches `@query/judging-core` `rank()` at pairwise
+weight 0, so the scoring check does not need that event.
diff --git a/docs/judging/api.md b/docs/judging/api.md
new file mode 100644
index 00000000..dd35ed3e
--- /dev/null
+++ b/docs/judging/api.md
@@ -0,0 +1,17 @@
+# API
+
+`GET /openapi.json` is the route list (OpenAPI 3.0.3). The same procedures are also mounted at `/trpc`.
+
+Send `Authorization: Bearer` with a JWT from `POST /v1/auth/verify`, or with an API key from `catalog.issueApiKey`. A key whose scopes include `import`, `export`, or `webhooks` acts as an organizer. A revoked key is rejected.
+
+These routes do not require a token:
+
+- `GET /healthz`, `GET /readyz`, `GET /metrics`, `GET /openapi.json`
+- `POST /v1/auth/magic-link` and `POST /v1/auth/verify`
+- `GET /v1/public/{orgSlug}/{eventSlug}`
+- `GET /v1/live/{orgSlug}/{eventSlug}`
+- `GET /v1/feedback/{token}`
+
+`GET /v1/results/{eventId}` requires a bearer token and returns 403 until results are published. Feedback returns 403 for an unknown token and before publish.
+
+Live clients connect to `/ws?channel=event:{eventId}:board`. The channels are `event:{id}:board`, `event:{id}:leaderboard`, and `judge:{id}`.
diff --git a/docs/judging/contributing.md b/docs/judging/contributing.md
new file mode 100644
index 00000000..8048ca15
--- /dev/null
+++ b/docs/judging/contributing.md
@@ -0,0 +1,28 @@
+# Contributing
+
+Panel is `packages/judging-*` plus its portal routes in `sites/mainweb` (listed in `README.md`). `pnpm lint`, `pnpm typecheck`, and `pnpm test` at the root cover it with the rest of the repository.
+
+## Checks
+
+From the repository root:
+
+```
+pnpm --filter @query/judging-core test
+pnpm --filter @query/judging-server test
+pnpm --filter @query/judging-cli test
+pnpm --filter @query/judging-core lint
+pnpm --filter @query/judging-server lint
+pnpm --filter web lint
+pnpm --filter @query/judging-db typecheck
+pnpm --filter @query/judging-server typecheck
+pnpm --filter web typecheck
+pnpm --filter @query/judging-cli typecheck
+```
+
+## Migrations
+
+Schema changes are SQL files in `packages/judging-db/migrations`, applied with `panel migrate`. Do not rename or drop a column in the same change that ships code reading the new shape.
+
+## Pull requests
+
+Describe the behaviour a judge or an organizer will see. Include the command you ran.
diff --git a/docs/judging/deploy.md b/docs/judging/deploy.md
new file mode 100644
index 00000000..f13a850c
--- /dev/null
+++ b/docs/judging/deploy.md
@@ -0,0 +1,23 @@
+# Deploy
+
+Judging ships with mainweb. There is no separate service or image: the API is
+`/api/panel` in the portal, and the pages are portal routes (see `README.md`).
+
+Before the first event on panel, against the production database:
+
+```
+DATABASE_URL=
+pnpm --filter @query/judging-cli panel migrate
+pnpm --filter @query/judging-cli panel doctor
+```
+
+Then switch the edition to panel on `/admin/judging`. Nothing goes in the
+environment: the event is created from the hackathon row.
+
+`packages/db/drizzle.config.ts` hides the judging tables from the deploy's
+`drizzle-kit push` and declares the judging enum types
+(`packages/judging-db/src/enums.ts`), so push neither tries to drop them nor
+stalls on a prompt.
+
+`PANEL_JWT_SECRET` is only needed for callers outside the portal that send a
+bearer token; API keys from `catalog.issueApiKey` work without it.
diff --git a/docs/judging/rubric.md b/docs/judging/rubric.md
new file mode 100644
index 00000000..70b42cc3
--- /dev/null
+++ b/docs/judging/rubric.md
@@ -0,0 +1,7 @@
+# Rubric
+
+A rubric is a named list of criteria. Each criterion has a key, a label, a minimum, a maximum, and a weight. The default rubric is what judges see. A track can point at a different rubric, which is how a sponsor prize asks different questions.
+
+Scores outside a criterion's range are rejected. The visit total is the weighted mean of the criteria. Comments are stored with the vote and shown on the team card only after results are published.
+
+Create one from the organizer console with the track form for a new prize group, or through `catalog.saveRubric` with the criteria in order. Anchors are the numbers a judge can tap; they are the integers from min to max.
diff --git a/docs/judging/scoring.md b/docs/judging/scoring.md
new file mode 100644
index 00000000..65d46488
--- /dev/null
+++ b/docs/judging/scoring.md
@@ -0,0 +1,9 @@
+# Scoring
+
+A project's rubric score is the weighted mean of its criterion values. Each judge's scores are then z-scored against that judge's own mean and spread and placed on the field's mean and spread, so a harsh judge and a lenient judge who ranked the same projects agree.
+
+Projects with few votes are shrunk toward the field average. The config value `bayesian_c` is how many average votes that prior is worth. At 2, two real votes and the prior weigh the same.
+
+Pairwise comparisons are a separate component. `pairwise_weight` is the share of the final score that comes from them. At 0 the ranking is the rubric score only, which matches a results run that never asked judges to compare tables.
+
+Publishing freezes one result run. A later run can be diffed against it before it is published.
diff --git a/package.json b/package.json
index 6dcbb690..3e16ba56 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"lint": "turbo run lint",
"format": "prettier --write .",
"typecheck": "turbo run typecheck",
- "test": "vitest run packages/api packages/auth packages/db sites/mainweb/lib"
+ "test": "vitest run packages/api packages/auth packages/db sites/mainweb/lib packages/judging-core packages/judging-server packages/judging-cli"
},
"dependencies": {
"next": "16.3.7",
diff --git a/packages/api/package.json b/packages/api/package.json
index 4f8b792d..795d7c4e 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -18,7 +18,8 @@
"./eastern-time": "./src/services/eastern-time.ts",
"./metrics": "./src/services/metrics.ts",
"./portal-context": "./src/types/portal-context.ts",
- "./resume-list": "./src/services/resume-list.ts"
+ "./resume-list": "./src/services/resume-list.ts",
+ "./panel": "./src/services/panel.ts"
},
"scripts": {
"lint": "eslint . --max-warnings 0",
@@ -28,6 +29,8 @@
"dependencies": {
"@query/auth": "workspace:*",
"@query/db": "workspace:*",
+ "@query/judging-db": "workspace:*",
+ "@query/judging-server": "workspace:*",
"@tanstack/react-query": "5.103.2",
"@trpc/client": "11.19.0",
"@trpc/next": "11.19.0",
@@ -42,6 +45,7 @@
"zod": "4.6.5"
},
"devDependencies": {
+ "@query/judging-core": "workspace:*",
"@query/tsconfig": "workspace:*",
"@types/sanitize-html": "^2.16.0",
"typescript": "7.0.2",
diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts
index bfa2e79d..c0b65d45 100644
--- a/packages/api/src/routers/judge/admin.ts
+++ b/packages/api/src/routers/judge/admin.ts
@@ -21,6 +21,7 @@ import {
import { CacheKeys, invalidatePortalContext } from "../../middleware/cache";
import type { DrizzleDB } from "@query/db";
import { isLive, loadGroups, loadPool } from "./dispatch";
+import { syncJudgeToPanel, syncProjectsToPanel } from "../../services/panel-sync";
// Judges draw tables from a shared pool (dispatch.ts) instead of holding a
// list built in advance. Clears rows an earlier version built that the judge
@@ -263,7 +264,7 @@ export const judgeAdminRouter = createTRPCRouter({
promoteSubmissions: isAdmin
.input(z.object({ hackathonId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
- return await (ctx.db as DrizzleDB).transaction(async (tx) => {
+ const promoted = await (ctx.db as DrizzleDB).transaction(async (tx) => {
// Serializes concurrent promotions, so two organisers pressing the button
// together cannot both read the same max table number and duplicate it.
await tx
@@ -350,6 +351,8 @@ export const judgeAdminRouter = createTRPCRouter({
total: submissions.length,
};
});
+ await syncProjectsToPanel(ctx.db as DrizzleDB, input.hackathonId);
+ return promoted;
}),
setActive: isAdmin
@@ -467,6 +470,11 @@ export const judgeAdminRouter = createTRPCRouter({
}
}
+ await syncJudgeToPanel(ctx.db as DrizzleDB, {
+ hackathonId: updated.hackathonId,
+ email: updated.email,
+ isActive: input.isActive,
+ });
return { success: true, isActive: input.isActive, queuedProjects };
}),
diff --git a/packages/api/src/routers/judge/portal.ts b/packages/api/src/routers/judge/portal.ts
index b0afda0a..fabca918 100644
--- a/packages/api/src/routers/judge/portal.ts
+++ b/packages/api/src/routers/judge/portal.ts
@@ -21,6 +21,7 @@ import {
lockDispatch,
} from "./dispatch";
import type { DrizzleDB } from "@query/db";
+import { panelConsoleUrl, panelDeskUrl, setJudgingBackend } from "../../services/panel-sync";
export const judgePortalRouter = createTRPCRouter({
@@ -68,6 +69,31 @@ export const judgePortalRouter = createTRPCRouter({
return result;
}),
+ /** The panel judge desk in the portal, only for an edition that opted in. */
+ panelDesk: protectedProcedure.query(async ({ ctx }) => {
+ return panelDeskUrl(ctx.db as DrizzleDB, ctx.userId as string);
+ }),
+
+ /** The panel organizer console in the portal when this edition uses panel. */
+ panelConsole: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ return panelConsoleUrl(ctx.db as DrizzleDB, input.hackathonId);
+ }),
+
+ /** Moves an edition between classic judging and panel. Panel gets its event and a first sync. */
+ setJudgingBackend: isAdmin
+ .input(
+ z.object({
+ hackathonId: z.string().uuid(),
+ backend: z.enum(["legacy", "panel"]),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ await setJudgingBackend(ctx.db as DrizzleDB, input.hackathonId, input.backend);
+ return panelConsoleUrl(ctx.db as DrizzleDB, input.hackathonId);
+ }),
+
getMyAssignments: protectedProcedure.query(async ({ ctx }) => {
const db = ctx.db as DrizzleDB;
diff --git a/packages/api/src/routers/judge/rankings.ts b/packages/api/src/routers/judge/rankings.ts
index 69f72da8..05957556 100644
--- a/packages/api/src/routers/judge/rankings.ts
+++ b/packages/api/src/routers/judge/rankings.ts
@@ -6,7 +6,8 @@ import { and, eq, isNotNull, sql , isNull } from "drizzle-orm";
import { isAdmin } from "../../middleware/procedures";
import { recordAdminAction } from "../../middleware/audit";
import type { DrizzleDB } from "@query/db";
-import { zNormalize } from "./helpers";
+import { weightProjects } from "./weight";
+import { pullPanelResults } from "../../services/panel-sync";
// The whole ranking pipeline in one place, so the live view and the frozen
// snapshot cannot drift: two implementations of a scoring formula are two
@@ -34,75 +35,26 @@ async function computeRanking(db: DrizzleDB, hackathonId: string) {
});
const round2 = (n: number) => Math.round(n * 100) / 100;
-
- // Step 1: raw scores grouped by judge. Per-judge distributions are what
- // Z-score normalization needs to cancel harsh-judge / lenient-judge bias.
type VoteWithJudge = (typeof projects)[number]["votes"][number];
- const scoresByJudge = new Map();
- for (const project of projects) {
- for (const v of project.votes) {
- const existing = scoresByJudge.get(v.judgeId) ?? [];
- existing.push(v.score);
- scoresByJudge.set(v.judgeId, existing);
- }
- }
-
- // ─── Step 2: Compute global score distribution ─────────────────────────
- const allRawScores = [...scoresByJudge.values()].flat();
- const globalMean =
- allRawScores.length > 0
- ? allRawScores.reduce((a, b) => a + b, 0) / allRawScores.length
- : 0;
- const globalVariance =
- allRawScores.length > 0
- ? allRawScores.reduce((s, v) => s + (v - globalMean) ** 2, 0) /
- allRawScores.length
- : 1;
- const globalStd = Math.sqrt(globalVariance) || 1;
-
- // Step 3: per judge, map their raw score index to a Z-normalized score.
- const normalizedScoreLookup = new Map>();
- for (const [judgeId, rawScores] of scoresByJudge.entries()) {
- const normalized = zNormalize(rawScores, globalMean, globalStd);
- // Map raw score value -> normalized value (index-based, preserves order)
- const lookup = new Map();
- rawScores.forEach((raw, i) => {
- // If same raw score appears multiple times, average the normalized values
- const existing = lookup.get(raw);
- lookup.set(
- raw,
- existing !== undefined
- ? (existing + normalized[i]!) / 2
- : normalized[i]!,
- );
- });
- normalizedScoreLookup.set(judgeId, lookup);
- }
-
- const getNormalized = (judgeId: string, rawScore: number): number => {
- const lookup = normalizedScoreLookup.get(judgeId);
- return lookup?.get(rawScore) ?? rawScore;
- };
-
- // ─── Step 4: Build raw + normalized stats per project ─────────────────
- const C = 2; // Bayesian confidence weight
+ const weighted = new Map(
+ weightProjects(
+ projects.map((project) => ({
+ id: project.id,
+ votes: project.votes.map((vote) => ({ judgeId: vote.judgeId, score: vote.score })),
+ })),
+ ).map((row) => [row.id, row]),
+ );
const rawRankings = projects.map((project) => {
+ const scored = weighted.get(project.id);
const voteCount = project.votes.length;
+ const normalizedScores = scored?.normalizedScores ?? [];
+ const normalizedAvg = scored?.normalizedAvg ?? 0;
// Raw scores (unadjusted)
const totalScore = project.votes.reduce((sum, v) => sum + v.score, 0);
const avgScore = voteCount > 0 ? totalScore / voteCount : 0;
- // Z-score normalized scores (bias-corrected)
- const normalizedScores = project.votes.map((v) =>
- getNormalized(v.judgeId, v.score),
- );
- const normalizedAvg =
- voteCount > 0
- ? round2(normalizedScores.reduce((a, b) => a + b, 0) / voteCount)
- : 0;
-
// Per-category averages (raw)
const sumCat = {
creativity: 0,
@@ -182,26 +134,9 @@ async function computeRanking(db: DrizzleDB, hackathonId: string) {
};
});
- // ─── Step 5: Compute global normalized average for Bayesian prior ──────
- const votedProjects = rawRankings.filter((r) => r.voteCount > 0);
- const globalAvg =
- votedProjects.length > 0
- ? round2(
- votedProjects.reduce((sum, r) => sum + r.normalizedAvg, 0) /
- votedProjects.length,
- )
- : 0;
-
- // Step 6: Bayesian + Z-score final. weightedScore blends the normalized
- // average toward the global mean when few judges voted.
const rankings = rawRankings.map((r) => {
const n = r.voteCount;
- const weightedScore =
- n > 0
- ? round2(
- (n / (n + C)) * r.normalizedAvg + (C / (n + C)) * globalAvg,
- )
- : 0;
+ const weightedScore = weighted.get(r.project.id)?.weightedScore ?? 0;
const confidenceLevel: "NONE" | "LOW" | "MEDIUM" | "HIGH" =
n === 0 ? "NONE" : n === 1 ? "LOW" : n === 2 ? "MEDIUM" : "HIGH";
const scoreShift = round2(r.normalizedAvg - r.avgScore); // how much bias-correction shifted this project
@@ -311,6 +246,12 @@ async function computeRanking(db: DrizzleDB, hackathonId: string) {
});
}
+ const votedRows = [...weighted.values()].filter((row) => row.voteCount > 0);
+ const globalAvg =
+ votedRows.length > 0
+ ? round2(votedRows.reduce((sum, row) => sum + row.normalizedAvg, 0) / votedRows.length)
+ : 0;
+
const result = {
rankings,
globalAvg,
@@ -481,6 +422,7 @@ export const judgeRankingsRouter = createTRPCRouter({
publishResults: isAdmin
.input(z.object({ hackathonId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
+ await pullPanelResults(ctx.db as DrizzleDB, input.hackathonId);
const rows = await (ctx.db as DrizzleDB)
.update(hackathonResults)
.set({ publishedAt: new Date() })
diff --git a/packages/api/src/routers/judge/replay.test.ts b/packages/api/src/routers/judge/replay.test.ts
new file mode 100644
index 00000000..7d844a15
--- /dev/null
+++ b/packages/api/src/routers/judge/replay.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { rank } from "@query/judging-core";
+import { weightProjects } from "./weight";
+
+const projects = [
+ {
+ id: "A",
+ votes: [
+ { judgeId: "j1", score: 8 },
+ { judgeId: "j2", score: 9 },
+ ],
+ },
+ {
+ id: "B",
+ votes: [
+ { judgeId: "j1", score: 5 },
+ { judgeId: "j2", score: 9 },
+ ],
+ },
+ {
+ id: "C",
+ votes: [
+ { judgeId: "j1", score: 2 },
+ { judgeId: "j2", score: 6 },
+ ],
+ },
+];
+
+describe("legacy rankings and panel rank", () => {
+ it("places the published fixture the same way at pairwise weight 0", () => {
+ const legacy = weightProjects(projects)
+ .filter((row) => row.voteCount > 0)
+ .sort((a, b) => b.weightedScore - a.weightedScore || a.id.localeCompare(b.id));
+ const panel = rank(
+ projects.map((project) => ({ id: project.id, trackIds: ["overall"] })),
+ projects.flatMap((project) =>
+ project.votes.map((vote) => ({
+ judgeId: vote.judgeId,
+ projectId: project.id,
+ judgeGroup: "main",
+ total: vote.score,
+ })),
+ ),
+ [],
+ { bayesianC: 2, pairwiseWeight: 0, calibrationWeight: 1 },
+ )
+ .filter((row) => row.placement !== null)
+ .sort((a, b) => (a.placement ?? 0) - (b.placement ?? 0));
+
+ expect(panel.map((row) => [row.projectId, row.score])).toEqual(
+ legacy.map((row) => [row.id, row.weightedScore]),
+ );
+ expect(panel.map((row) => row.score)).toEqual([7.71, 6.94, 4.85]);
+ });
+});
diff --git a/packages/api/src/routers/judge/weight.ts b/packages/api/src/routers/judge/weight.ts
new file mode 100644
index 00000000..3f9ada92
--- /dev/null
+++ b/packages/api/src/routers/judge/weight.ts
@@ -0,0 +1,84 @@
+import { zNormalize } from "./helpers";
+
+const round2 = (n: number) => Math.round(n * 100) / 100;
+const C = 2;
+
+export type WeightVote = { judgeId: string; score: number };
+export type WeightProject = { id: string; votes: readonly WeightVote[] };
+
+export type WeightedProject = {
+ id: string;
+ voteCount: number;
+ normalizedAvg: number;
+ weightedScore: number;
+ normalizedScores: number[];
+};
+
+/**
+ * The ranking the club site publishes: z-score each judge, average per
+ * project, shrink toward the mean of those averages with c = 2. Panel's
+ * `rank()` at pairwise weight 0 is checked against this function.
+ */
+export function weightProjects(projects: readonly WeightProject[]): WeightedProject[] {
+ const scoresByJudge = new Map();
+ for (const project of projects) {
+ for (const vote of project.votes) {
+ const existing = scoresByJudge.get(vote.judgeId) ?? [];
+ existing.push(vote.score);
+ scoresByJudge.set(vote.judgeId, existing);
+ }
+ }
+
+ const allRawScores = [...scoresByJudge.values()].flat();
+ const globalMean =
+ allRawScores.length > 0
+ ? allRawScores.reduce((a, b) => a + b, 0) / allRawScores.length
+ : 0;
+ const globalVariance =
+ allRawScores.length > 0
+ ? allRawScores.reduce((sum, value) => sum + (value - globalMean) ** 2, 0) /
+ allRawScores.length
+ : 1;
+ const globalStd = Math.sqrt(globalVariance) || 1;
+
+ const normalizedScoreLookup = new Map>();
+ for (const [judgeId, rawScores] of scoresByJudge.entries()) {
+ const normalized = zNormalize(rawScores, globalMean, globalStd);
+ const lookup = new Map();
+ rawScores.forEach((raw, index) => {
+ const existing = lookup.get(raw);
+ const value = normalized[index]!;
+ lookup.set(raw, existing !== undefined ? (existing + value) / 2 : value);
+ });
+ normalizedScoreLookup.set(judgeId, lookup);
+ }
+
+ const raw = projects.map((project) => {
+ const voteCount = project.votes.length;
+ const normalizedScores = project.votes.map(
+ (vote) => normalizedScoreLookup.get(vote.judgeId)?.get(vote.score) ?? vote.score,
+ );
+ const normalizedAvg =
+ voteCount > 0
+ ? round2(normalizedScores.reduce((sum, value) => sum + value, 0) / voteCount)
+ : 0;
+ return { id: project.id, voteCount, normalizedAvg, normalizedScores };
+ });
+
+ const voted = raw.filter((row) => row.voteCount > 0);
+ const globalAvg =
+ voted.length > 0
+ ? round2(voted.reduce((sum, row) => sum + row.normalizedAvg, 0) / voted.length)
+ : 0;
+
+ return raw.map((row) => ({
+ ...row,
+ weightedScore:
+ row.voteCount > 0
+ ? round2(
+ (row.voteCount / (row.voteCount + C)) * row.normalizedAvg +
+ (C / (row.voteCount + C)) * globalAvg,
+ )
+ : 0,
+ }));
+}
diff --git a/packages/api/src/services/panel-sync.ts b/packages/api/src/services/panel-sync.ts
new file mode 100644
index 00000000..99ed01ff
--- /dev/null
+++ b/packages/api/src/services/panel-sync.ts
@@ -0,0 +1,193 @@
+import { and, eq, inArray, sql } from "drizzle-orm";
+import { hackathonProjects, hackathonResults, hackathons, judges, judgingProjects } from "@query/db";
+import type { DrizzleDB } from "@query/db";
+import { judge as panelJudge } from "@query/judging-db";
+import { publishedPlacements } from "@query/judging-server";
+import { panel, panelAsPortal, panelEventFor } from "./panel";
+
+/**
+ * Copies an edition into judging when that edition has opted in.
+ * A failure here does not undo the club write. The portal stays the source
+ * of registration, and the same action can be run again.
+ */
+export async function syncProjectsToPanel(db: DrizzleDB, hackathonId: string) {
+ try {
+ const target = await panelTarget(db, hackathonId);
+ if (!target) return;
+ const rows = await db
+ .select({
+ id: hackathonProjects.id,
+ name: hackathonProjects.name,
+ })
+ .from(hackathonProjects)
+ .where(
+ and(
+ eq(hackathonProjects.hackathonId, hackathonId),
+ inArray(hackathonProjects.status, ["submitted", "judging"]),
+ ),
+ );
+ const caller = panelAsPortal();
+ for (const row of rows) {
+ await caller.project.upsert({
+ eventId: target.eventId,
+ externalId: row.id,
+ name: row.name,
+ teamName: null,
+ tableNumber: null,
+ });
+ }
+ } catch {
+ return;
+ }
+}
+
+/**
+ * Mirrors a portal judge into the edition's judging event. Approval in the
+ * portal is approval in judging; deactivating suspends them there.
+ */
+export async function syncJudgeToPanel(
+ db: DrizzleDB,
+ judge: { hackathonId: string; email: string | null; isActive: boolean },
+) {
+ try {
+ if (!judge.email) return;
+ const target = await panelTarget(db, judge.hackathonId);
+ if (!target) return;
+ await mirrorJudge(target.eventId, judge.email, judge.isActive);
+ } catch {
+ return;
+ }
+}
+
+async function mirrorJudge(eventId: string, email: string, isActive: boolean) {
+ const caller = panelAsPortal();
+ // Upsert is refused once judging is closed; a judge already there can
+ // still be suspended or reinstated.
+ const stored = await caller.judge
+ .upsert({ eventId, email, name: email, externalId: null })
+ .catch(() => undefined);
+ let judgeId = stored?.id;
+ if (!judgeId) {
+ const [row] = await panel()
+ .db.select({ id: panelJudge.id })
+ .from(panelJudge)
+ .where(and(eq(panelJudge.eventId, eventId), eq(panelJudge.email, email.toLowerCase())));
+ judgeId = row?.id;
+ }
+ if (!judgeId) return;
+ await caller.judge.setStatus({
+ eventId,
+ judgeId,
+ status: isActive ? "approved" : "suspended",
+ });
+}
+
+/**
+ * Moves an edition onto panel judging: creates its event, then copies in the
+ * submitted projects and the judges the portal has approved. Switching back
+ * leaves the panel event in place, so switching again resumes it.
+ */
+export async function setJudgingBackend(
+ db: DrizzleDB,
+ hackathonId: string,
+ backend: "legacy" | "panel",
+) {
+ await db.update(hackathons).set({ judgingBackend: backend }).where(eq(hackathons.id, hackathonId));
+ if (backend === "legacy") return;
+ const target = await panelTarget(db, hackathonId);
+ if (!target) return;
+ await syncProjectsToPanel(db, hackathonId);
+ const rows = await db
+ .select({ email: judges.email, isActive: judges.isActive })
+ .from(judges)
+ .where(eq(judges.hackathonId, hackathonId));
+ for (const row of rows) {
+ if (row.email) await mirrorJudge(target.eventId, row.email, row.isActive);
+ }
+}
+
+async function panelTarget(db: DrizzleDB, hackathonId: string) {
+ const [row] = await db
+ .select({ id: hackathons.id, name: hackathons.name, backend: hackathons.judgingBackend })
+ .from(hackathons)
+ .where(eq(hackathons.id, hackathonId));
+ if (row?.backend !== "panel") return null;
+ return panelEventFor(row);
+}
+
+export async function pullPanelResults(db: DrizzleDB, hackathonId: string) {
+ const target = await panelTarget(db, hackathonId);
+ if (!target) return 0;
+ const body = await publishedPlacements(panel().db, target.eventId);
+ if (!body) throw new Error("Panel event not found");
+ if (!body.published) throw new Error("Panel results are not published");
+ const judging = await db
+ .select({ id: judgingProjects.id, sourceProjectId: judgingProjects.sourceProjectId })
+ .from(judgingProjects)
+ .where(eq(judgingProjects.hackathonId, hackathonId));
+ const bySource = new Map(
+ judging.flatMap((row) =>
+ row.sourceProjectId ? [[row.sourceProjectId, row.id] as const] : [],
+ ),
+ );
+ let written = 0;
+ for (const row of body.rows) {
+ if (!row.externalId || row.placement === null) continue;
+ const projectId = bySource.get(row.externalId);
+ if (!projectId) continue;
+ await db
+ .insert(hackathonResults)
+ .values({
+ hackathonId,
+ projectId,
+ sourceProjectId: row.externalId,
+ track: row.track || "overall",
+ placement: row.placement,
+ weightedScore: row.score.toFixed(2),
+ voteCount: row.voteCount,
+ })
+ .onConflictDoUpdate({
+ target: [
+ hackathonResults.hackathonId,
+ hackathonResults.projectId,
+ hackathonResults.track,
+ ],
+ set: {
+ placement: sql`excluded.placement`,
+ weightedScore: sql`excluded.weighted_score`,
+ voteCount: sql`excluded.vote_count`,
+ sourceProjectId: row.externalId,
+ },
+ });
+ written += 1;
+ }
+ return written;
+}
+
+export async function panelDeskUrl(db: DrizzleDB, userId: string) {
+ const [row] = await db
+ .select({ hackathonId: judges.hackathonId })
+ .from(judges)
+ .innerJoin(hackathons, eq(hackathons.id, judges.hackathonId))
+ .where(
+ and(
+ eq(judges.userId, userId),
+ eq(judges.isActive, true),
+ eq(hackathons.judgingBackend, "panel"),
+ ),
+ );
+ if (!row) return { url: null as string | null };
+ return { url: `/judge/panel/${row.hackathonId}` };
+}
+
+export async function panelConsoleUrl(db: DrizzleDB, hackathonId: string) {
+ const [row] = await db
+ .select({ backend: hackathons.judgingBackend })
+ .from(hackathons)
+ .where(eq(hackathons.id, hackathonId));
+ const backend = row?.backend ?? "legacy";
+ return {
+ backend,
+ url: backend === "panel" ? `/admin/judging/panel/${hackathonId}` : null,
+ };
+}
diff --git a/packages/api/src/services/panel.ts b/packages/api/src/services/panel.ts
new file mode 100644
index 00000000..f22e18c1
--- /dev/null
+++ b/packages/api/src/services/panel.ts
@@ -0,0 +1,133 @@
+import { and, eq } from "drizzle-orm";
+import { admins, db as clubDb, hackathons, judges } from "@query/db";
+import type { DrizzleDB } from "@query/db";
+import { createDbFromPool } from "@query/judging-db";
+import type { PanelDb } from "@query/judging-db";
+import {
+ InMemoryBus,
+ appRouter,
+ createApp,
+ createMetrics,
+ ensureEvent,
+} from "@query/judging-server";
+import type { Actor, Role } from "@query/judging-server";
+import { createContext } from "../context";
+import {
+ isBugTesterRole,
+ isExpiredAdmin,
+ isStaffRole,
+} from "../types/portal-context";
+
+/** Every hackathon edition is one judging event in this organization. */
+export const PANEL_ORG = { slug: "hacklytics", name: "Hacklytics" } as const;
+
+/** The judging event for an edition, keyed by the hackathon id. Created on first use. */
+export async function panelEventFor(hackathon: { id: string; name: string }) {
+ return ensureEvent(panel().db, {
+ orgSlug: PANEL_ORG.slug,
+ orgName: PANEL_ORG.name,
+ eventSlug: hackathon.id,
+ name: hackathon.name,
+ });
+}
+
+type Embedded = {
+ db: PanelDb;
+ app: ReturnType;
+ bus: InMemoryBus;
+ metrics: ReturnType;
+};
+
+const globalForPanel = globalThis as unknown as { panel?: Embedded };
+
+/**
+ * Judging runs inside the portal: same process, same Postgres, and the portal
+ * sign-in decides who the caller is. It borrows the club's pool rather than
+ * opening its own: the club pool is tuned to let Neon scale to zero, and a
+ * second pool would hold a second set of connections to the same database.
+ * One instance per process, so the metrics registry is not rebuilt per request.
+ */
+export function panel(): Embedded {
+ if (globalForPanel.panel) return globalForPanel.panel;
+ if (!clubDb) throw new Error("DATABASE_URL is not set");
+ const { db } = createDbFromPool(clubDb.$client);
+ const metrics = createMetrics();
+ const bus = new InMemoryBus();
+ const app = createApp({
+ db,
+ metrics,
+ bus,
+ jwtSecret: process.env.PANEL_JWT_SECRET ?? "",
+ devAuth: false,
+ busMode: "memory",
+ resolveActor: () => portalActor(),
+ });
+ globalForPanel.panel = { db, app, bus, metrics };
+ return globalForPanel.panel;
+}
+
+/** The judging procedures, called by the portal itself to sync an edition. */
+export function panelAsPortal() {
+ const { db, bus, metrics } = panel();
+ return appRouter.createCaller({
+ db,
+ bus,
+ metrics,
+ now: new Date(),
+ actor: {
+ sub: "portal",
+ email: "portal",
+ name: "Portal",
+ org: null,
+ role: "admin",
+ judgeExternalId: null,
+ },
+ });
+}
+
+/**
+ * The signed-in portal user as a judging actor. Staff organize, a volunteer
+ * row is a volunteer, and a judge on a panel edition is matched by the email
+ * on their judge row, which is the one synced into judging.
+ */
+export async function portalActor(): Promise {
+ const ctx = await createContext();
+ const email = ctx.session?.user?.email;
+ if (!ctx.db || !ctx.userId || !email) return null;
+ const db = ctx.db as DrizzleDB;
+ const [admin, judgeRows] = await Promise.all([
+ db.query.admins.findFirst({
+ where: and(eq(admins.userId, ctx.userId), eq(admins.isActive, true)),
+ }),
+ db
+ .select({ email: judges.email })
+ .from(judges)
+ .innerJoin(hackathons, eq(hackathons.id, judges.hackathonId))
+ .where(
+ and(
+ eq(judges.userId, ctx.userId),
+ eq(judges.isActive, true),
+ eq(hackathons.judgingBackend, "panel"),
+ ),
+ ),
+ ]);
+ return {
+ sub: ctx.userId,
+ email: judgeRows[0]?.email ?? email,
+ name: ctx.session?.user?.name ?? email,
+ org: PANEL_ORG.slug,
+ role: roleFor(admin ?? null),
+ judgeExternalId: null,
+ };
+}
+
+function roleFor(
+ admin: { role: string; expiresAt: Date | null } | null,
+): Role | null {
+ if (!admin || isExpiredAdmin(admin) || isBugTesterRole(admin.role)) {
+ return null;
+ }
+ if (admin.role === "super_admin") return "owner";
+ if (isStaffRole(admin.role)) return "admin";
+ return "volunteer";
+}
diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts
index 140b2ec7..1fa702c4 100644
--- a/packages/db/drizzle.config.ts
+++ b/packages/db/drizzle.config.ts
@@ -1,5 +1,6 @@
import { defineConfig } from "drizzle-kit";
import * as dotenv from "dotenv";
+import fs from "fs";
import path from "path";
// Load .env from root (two levels up)
@@ -9,8 +10,29 @@ if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is not defined in .env file");
}
+// Judging (packages/judging-db) keeps its tables in this database and applies
+// them with its own SQL migrations. Left visible, push sees them as tables to
+// drop and stops at a prompt; the build has no TTY, so the prompt errors, push
+// exits 0, and every club schema change after that is silently skipped.
+// The list comes from the migration files, so a new judging table is covered.
+const judgingMigrations = path.resolve(__dirname, "../judging-db/migrations");
+const judgingTables = [
+ "panel_migration",
+ ...fs
+ .readdirSync(judgingMigrations)
+ .filter((file) => file.endsWith(".sql"))
+ .flatMap((file) =>
+ [
+ ...fs
+ .readFileSync(path.join(judgingMigrations, file), "utf8")
+ .matchAll(/^create table (\w+)/gm),
+ ].map((match) => match[1] as string),
+ ),
+];
+
export default defineConfig({
- schema: "./src/schemas/**/*.ts",
+ schema: ["./src/schemas/**/*.ts", "../judging-db/src/enums.ts"],
+ tablesFilter: judgingTables.map((table) => `!${table}`),
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
diff --git a/packages/db/src/schemas/hackathons.ts b/packages/db/src/schemas/hackathons.ts
index 0ada7f93..1df9860c 100644
--- a/packages/db/src/schemas/hackathons.ts
+++ b/packages/db/src/schemas/hackathons.ts
@@ -56,6 +56,11 @@ export const hackathons = pgTable(
websiteUrl: text("website_url"),
isPublic: boolean("is_public").notNull().default(true),
judgingActive: boolean("judging_active").notNull().default(false),
+ // legacy keeps the current judge portal. panel hands the same edition to
+ // the standalone judging service. Additive: existing rows stay on legacy.
+ judgingBackend: text("judging_backend", { enum: ["legacy", "panel"] })
+ .notNull()
+ .default("legacy"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
diff --git a/packages/judging-cli/eslint.config.mjs b/packages/judging-cli/eslint.config.mjs
new file mode 100644
index 00000000..b1dfd25a
--- /dev/null
+++ b/packages/judging-cli/eslint.config.mjs
@@ -0,0 +1,10 @@
+import { config } from "@query/eslint-config/base";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+ ...config,
+ {
+ files: ["src/**/*.ts"],
+ rules: { "no-console": "off" },
+ },
+];
diff --git a/packages/judging-cli/package.json b/packages/judging-cli/package.json
new file mode 100644
index 00000000..36268c76
--- /dev/null
+++ b/packages/judging-cli/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@query/judging-cli",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "panel": "tsx src/main.ts",
+ "lint": "eslint . --max-warnings 0",
+ "typecheck": "tsc --noEmit",
+ "test": "node ../../node_modules/vitest/vitest.mjs run src/csv.test.ts"
+ },
+ "dependencies": {
+ "@query/judging-db": "workspace:*",
+ "@query/judging-server": "workspace:*",
+ "drizzle-orm": "0.45.3",
+ "tsx": "^4.23.15"
+ },
+ "devDependencies": {
+ "@query/eslint-config": "workspace:*",
+ "@query/tsconfig": "workspace:*",
+ "@types/node": "22.20.4",
+ "typescript": "7.0.2"
+ }
+}
diff --git a/packages/judging-cli/src/csv.test.ts b/packages/judging-cli/src/csv.test.ts
new file mode 100644
index 00000000..b451f79c
--- /dev/null
+++ b/packages/judging-cli/src/csv.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { formatResultsCsv, formatResultsJson, parseProjectCsv } from "./csv";
+
+describe("parseProjectCsv", () => {
+ it("reads a Devpost-style header", () => {
+ const rows = parseProjectCsv(
+ "Project Title,Submission Url,Team Name,Table\nAlpha,https://example.com/a,A Team,4\n",
+ );
+ expect(rows).toEqual([
+ {
+ externalId: "https://example.com/a",
+ name: "Alpha",
+ teamName: "A Team",
+ tableNumber: 4,
+ },
+ ]);
+ });
+
+ it("uses a column map when the headers are not the defaults", () => {
+ const rows = parseProjectCsv("Title,Squad\nBeta,B Team\n", { name: "Title", team: "Squad" });
+ expect(rows[0]?.name).toBe("Beta");
+ expect(rows[0]?.teamName).toBe("B Team");
+ });
+
+ it("rejects a sheet with no name column", () => {
+ expect(() => parseProjectCsv("notes\nhello\n")).toThrow(/name/);
+ });
+});
+
+describe("formatResultsCsv", () => {
+ it("quotes a name that contains a comma", () => {
+ expect(
+ formatResultsCsv([{ project: "A, B", track: "general", placement: 1, score: 7.71 }]),
+ ).toBe('project,track,placement,score\n"A, B",general,1,7.71\n');
+ });
+
+ it("writes the same rows as JSON", () => {
+ const rows = [{ project: "Alpha", track: "general", placement: 1, score: 7.71 }];
+ expect(JSON.parse(formatResultsJson(rows))).toEqual(rows);
+ });
+});
diff --git a/packages/judging-cli/src/csv.ts b/packages/judging-cli/src/csv.ts
new file mode 100644
index 00000000..3414472a
--- /dev/null
+++ b/packages/judging-cli/src/csv.ts
@@ -0,0 +1,127 @@
+export type ImportedProject = {
+ externalId: string;
+ name: string;
+ teamName: string | null;
+ tableNumber: number | null;
+};
+
+/** Devpost exports and a plain name,team,table sheet. The first row is headers.
+ * A column map renames headers: `name: Project Title`.
+ */
+export function parseProjectCsv(
+ text: string,
+ columnMap: Record = {},
+): ImportedProject[] {
+ const rows = parseRows(text);
+ const header = rows[0];
+ if (!header) return [];
+ const index = new Map(header.map((cell, i) => [cell.trim().toLowerCase(), i]));
+ const nameAt = column(index, columnMap.name, ["project title", "name"]);
+ if (nameAt === undefined) {
+ throw new Error("CSV needs a name or Project Title column");
+ }
+ const teamAt = column(index, columnMap.team, ["team name", "team"]);
+ const tableAt = column(index, columnMap.table, ["table", "table number"]);
+ const idAt = column(index, columnMap.externalId, ["submission url", "external id", "id"]);
+
+ return rows.slice(1).flatMap((row) => {
+ const name = row[nameAt]?.trim();
+ if (!name) return [];
+ const tableRaw = tableAt === undefined ? "" : (row[tableAt] ?? "");
+ const tableNumber = tableRaw.trim() === "" ? null : Number(tableRaw);
+ return [
+ {
+ externalId: (idAt === undefined ? "" : (row[idAt] ?? "")).trim() || name,
+ name,
+ teamName: teamAt === undefined ? null : row[teamAt]?.trim() || null,
+ tableNumber: tableNumber !== null && Number.isFinite(tableNumber) ? tableNumber : null,
+ },
+ ];
+ });
+}
+
+export type ExportedResult = {
+ project: string;
+ track: string;
+ placement: number | null;
+ score: number;
+};
+
+export function formatResultsCsv(rows: readonly ExportedResult[]): string {
+ const lines = ["project,track,placement,score"];
+ for (const row of rows) {
+ lines.push(
+ [cell(row.project), cell(row.track), row.placement ?? "", row.score].join(","),
+ );
+ }
+ return `${lines.join("\n")}\n`;
+}
+
+export function formatResultsJson(rows: readonly ExportedResult[]): string {
+ return `${JSON.stringify(rows, null, 2)}\n`;
+}
+
+export function parseColumnMap(text: string): Record {
+ const map: Record = {};
+ for (const line of text.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#")) continue;
+ const split = trimmed.indexOf(":");
+ if (split === -1) continue;
+ map[trimmed.slice(0, split).trim()] = trimmed.slice(split + 1).trim();
+ }
+ return map;
+}
+
+function column(
+ index: Map,
+ mapped: string | undefined,
+ fallbacks: string[],
+): number | undefined {
+ const names = mapped ? [mapped.toLowerCase(), ...fallbacks] : fallbacks;
+ for (const name of names) {
+ const at = index.get(name);
+ if (at !== undefined) return at;
+ }
+ return undefined;
+}
+
+function cell(value: string): string {
+ if (/[",\n]/.test(value)) return `"${value.replaceAll('"', '""')}"`;
+ return value;
+}
+
+function parseRows(text: string): string[][] {
+ const rows: string[][] = [];
+ let row: string[] = [];
+ let field = "";
+ let quoted = false;
+ const source = text.replace(/^\uFEFF/, "");
+ for (let i = 0; i < source.length; i += 1) {
+ const char = source[i];
+ if (quoted) {
+ if (char === '"') {
+ if (source[i + 1] === '"') {
+ field += '"';
+ i += 1;
+ } else quoted = false;
+ } else field += char;
+ continue;
+ }
+ if (char === '"') quoted = true;
+ else if (char === ",") {
+ row.push(field);
+ field = "";
+ } else if (char === "\n") {
+ row.push(field);
+ rows.push(row);
+ row = [];
+ field = "";
+ } else if (char !== "\r") field += char;
+ }
+ if (field.length > 0 || row.length > 0) {
+ row.push(field);
+ rows.push(row);
+ }
+ return rows.filter((item) => item.some((value) => value.trim() !== ""));
+}
diff --git a/packages/judging-cli/src/main.ts b/packages/judging-cli/src/main.ts
new file mode 100644
index 00000000..69ac7081
--- /dev/null
+++ b/packages/judging-cli/src/main.ts
@@ -0,0 +1,155 @@
+import {
+ appliedMigrations,
+ createDb,
+ migrate,
+ migrationFiles,
+ databaseUrl,
+ requireDatabaseUrl,
+ seedDemo,
+} from "@query/judging-db";
+import { drainOutbox } from "@query/judging-server/outbox";
+import { exportResults, importProjects } from "./transfer";
+
+const [command, arg] = process.argv.slice(2);
+
+async function main() {
+ if (command === "import" && arg === "projects") {
+ const positionals = positionalsAfter(4);
+ const file = positionals[0];
+ const eventId = positionals[1];
+ const mapFile = option("--map");
+ if (!file || !eventId) {
+ console.log("usage: panel import projects [--map columns.yaml]");
+ process.exit(1);
+ }
+ const count = await importProjects(file, eventId, mapFile);
+ console.log(count);
+ return;
+ }
+
+ if (command === "export" && arg === "results") {
+ const format = option("--format") === "json" ? "json" : "csv";
+ const positionals = positionalsAfter(4);
+ const eventId = positionals[0];
+ const file = positionals[1] ?? (format === "json" ? "results.json" : "results.csv");
+ if (!eventId) {
+ console.log("usage: panel export results [file] [--format csv|json]");
+ process.exit(1);
+ }
+ const count = await exportResults(eventId, file, format);
+ console.log(count);
+ return;
+ }
+
+ if (command === "migrate") {
+ const { pool } = createDb(requireDatabaseUrl());
+ const fresh = await migrate(pool);
+ console.log(fresh.length > 0 ? fresh.join("\n") : "up to date");
+ await pool.end();
+ return;
+ }
+
+ if (command === "seed" && arg === "demo") {
+ const { db, pool } = createDb(requireDatabaseUrl());
+ await migrate(pool);
+ const seeded = await seedDemo(db);
+ console.log(seeded.eventId);
+ await pool.end();
+ return;
+ }
+
+ if (command === "outbox" && arg === "drain") {
+ const { db, pool } = createDb(requireDatabaseUrl());
+ let total = 0;
+ try {
+ for (;;) {
+ const delivered = await drainOutbox(db);
+ total += delivered;
+ if (delivered === 0) break;
+ }
+ } finally {
+ await pool.end();
+ }
+ console.log(total);
+ return;
+ }
+
+ if (command === "doctor") {
+ const checks: { name: string; ok: boolean; detail: string }[] = [];
+ const url = databaseUrl();
+ if (!url) {
+ checks.push({
+ name: "postgres",
+ ok: false,
+ detail: "DATABASE_URL is not set",
+ });
+ } else {
+ const { pool } = createDb(url);
+ try {
+ await pool.query("select 1");
+ checks.push({ name: "postgres", ok: true, detail: "connected" });
+ const files = await migrationFiles();
+ const applied = await appliedMigrations(pool);
+ const pending = files.filter((file) => !applied.includes(file));
+ checks.push({
+ name: "migrations",
+ ok: pending.length === 0,
+ detail: pending.length > 0 ? `pending ${pending.join(", ")}` : "up to date",
+ });
+ } catch (error) {
+ checks.push({
+ name: "postgres",
+ ok: false,
+ detail: error instanceof Error ? error.message : "connection failed",
+ });
+ } finally {
+ await pool.end();
+ }
+ }
+
+ // Optional: the portal signs judges in with its own session. The secret
+ // only matters for callers outside it that send a bearer token.
+ const secret = process.env.PANEL_JWT_SECRET;
+ checks.push({
+ name: "jwt",
+ ok: true,
+ detail: secret ? "PANEL_JWT_SECRET is set" : "not set (portal sign-in only)",
+ });
+ checks.push({
+ name: "redis",
+ ok: true,
+ detail: process.env.REDIS_URL ? "REDIS_URL is set" : "not configured",
+ });
+
+ for (const check of checks) {
+ console.log(`${check.ok ? "ok" : "fail"} ${check.name} ${check.detail}`);
+ }
+ if (checks.some((check) => !check.ok)) process.exit(1);
+ return;
+ }
+
+ console.log(
+ "usage: panel migrate | panel seed demo | panel doctor | panel outbox drain | panel import projects | panel export results [file]",
+ );
+ process.exit(1);
+}
+
+main().catch((error: unknown) => {
+ console.error(error instanceof Error ? error.message : error);
+ process.exit(1);
+});
+
+function option(name: string): string | undefined {
+ const index = process.argv.indexOf(name);
+ if (index === -1) return undefined;
+ return process.argv[index + 1];
+}
+
+function positionalsAfter(start: number): string[] {
+ const args = process.argv.slice(start);
+ return args.filter((value, index) => {
+ if (value.startsWith("--")) return false;
+ const previous = args[index - 1];
+ return previous !== "--format" && previous !== "--map";
+ });
+}
diff --git a/packages/judging-cli/src/transfer.ts b/packages/judging-cli/src/transfer.ts
new file mode 100644
index 00000000..4469539f
--- /dev/null
+++ b/packages/judging-cli/src/transfer.ts
@@ -0,0 +1,67 @@
+import { readFile, writeFile } from "node:fs/promises";
+import { and, eq } from "drizzle-orm";
+import { createDb, project, requireDatabaseUrl, result, resultRun, track } from "@query/judging-db";
+import { formatResultsCsv, formatResultsJson, parseColumnMap, parseProjectCsv } from "./csv";
+
+export async function importProjects(file: string, eventId: string, mapFile?: string) {
+ const columnMap = mapFile ? parseColumnMap(await readFile(mapFile, "utf8")) : {};
+ const rows = parseProjectCsv(await readFile(file, "utf8"), columnMap);
+ const { db, pool } = createDb(requireDatabaseUrl());
+ try {
+ for (const row of rows) {
+ const [found] = await db
+ .select({ id: project.id })
+ .from(project)
+ .where(and(eq(project.eventId, eventId), eq(project.externalId, row.externalId)));
+ if (found) {
+ await db
+ .update(project)
+ .set({ name: row.name, teamName: row.teamName, tableNumber: row.tableNumber })
+ .where(eq(project.id, found.id));
+ } else {
+ await db.insert(project).values({
+ eventId,
+ externalId: row.externalId,
+ name: row.name,
+ teamName: row.teamName,
+ tableNumber: row.tableNumber,
+ });
+ }
+ }
+ return rows.length;
+ } finally {
+ await pool.end();
+ }
+}
+
+export async function exportResults(
+ eventId: string,
+ file: string,
+ format: "csv" | "json" = "csv",
+) {
+ const { db, pool } = createDb(requireDatabaseUrl());
+ try {
+ const [run] = await db
+ .select({ id: resultRun.id })
+ .from(resultRun)
+ .where(eq(resultRun.eventId, eventId));
+ if (!run) throw new Error("No result run for that event");
+ const rows = await db
+ .select({
+ project: project.name,
+ track: track.slug,
+ placement: result.placement,
+ score: result.score,
+ })
+ .from(result)
+ .innerJoin(project, eq(project.id, result.projectId))
+ .innerJoin(track, eq(track.id, result.trackId))
+ .where(eq(result.runId, run.id));
+ const body =
+ format === "json" ? formatResultsJson(rows) : formatResultsCsv(rows);
+ await writeFile(file, body);
+ return rows.length;
+ } finally {
+ await pool.end();
+ }
+}
diff --git a/packages/judging-cli/tsconfig.json b/packages/judging-cli/tsconfig.json
new file mode 100644
index 00000000..f54bde2c
--- /dev/null
+++ b/packages/judging-cli/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tooling/typescript/base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/judging-core/eslint.config.mjs b/packages/judging-core/eslint.config.mjs
new file mode 100644
index 00000000..09f98a9a
--- /dev/null
+++ b/packages/judging-core/eslint.config.mjs
@@ -0,0 +1,40 @@
+import { config } from "@query/eslint-config/base";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+ ...config,
+ {
+ files: ["src/**/*.ts"],
+ ignores: ["src/**/*.test.ts"],
+ rules: {
+ "no-restricted-imports": [
+ "error",
+ {
+ paths: [
+ {
+ name: "process",
+ importNames: ["env"],
+ message:
+ "@query/judging-core takes its inputs as arguments. It does not read the environment.",
+ },
+ ],
+ patterns: [
+ {
+ group: [
+ "node:*",
+ "fs",
+ "path",
+ "crypto",
+ "node:fs",
+ "node:path",
+ "node:crypto",
+ ],
+ message:
+ "@query/judging-core has no runtime dependencies and does not call Node APIs.",
+ },
+ ],
+ },
+ ],
+ },
+ },
+];
diff --git a/packages/judging-core/package.json b/packages/judging-core/package.json
new file mode 100644
index 00000000..1e961bdd
--- /dev/null
+++ b/packages/judging-core/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@query/judging-core",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "description": "Pure judging decisions: dispatch, rubric, phase, and ranking.",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "files": ["dist"],
+ "publishConfig": {
+ "access": "public",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ }
+ },
+ "scripts": {
+ "lint": "eslint . --max-warnings 0",
+ "build": "tsc -p tsconfig.build.json && node scripts/emit-extensions.mjs",
+ "typecheck": "tsc --noEmit",
+ "test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts"
+ },
+ "devDependencies": {
+ "@query/eslint-config": "workspace:*",
+ "@query/tsconfig": "workspace:*",
+ "@types/node": "22.20.4",
+ "typescript": "7.0.2",
+ "vitest": "^5.0.1"
+ }
+}
diff --git a/packages/judging-core/scripts/emit-extensions.mjs b/packages/judging-core/scripts/emit-extensions.mjs
new file mode 100644
index 00000000..4efdcb3b
--- /dev/null
+++ b/packages/judging-core/scripts/emit-extensions.mjs
@@ -0,0 +1,25 @@
+import { readdir, readFile, writeFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const dist = join(dirname(fileURLToPath(import.meta.url)), "../dist");
+
+await walk(dist);
+
+async function walk(dir) {
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ await walk(path);
+ continue;
+ }
+ if (!entry.name.endsWith(".js")) continue;
+ const source = await readFile(path, "utf8");
+ const next = source.replaceAll(
+ /(from\s+["'])(\.\.?\/[^"']+)(["'])/g,
+ (match, open, spec, close) =>
+ spec.endsWith(".js") ? match : `${open}${spec}.js${close}`,
+ );
+ if (next !== source) await writeFile(path, next);
+ }
+}
diff --git a/packages/judging-core/src/aggregate/bayes.test.ts b/packages/judging-core/src/aggregate/bayes.test.ts
new file mode 100644
index 00000000..df67d118
--- /dev/null
+++ b/packages/judging-core/src/aggregate/bayes.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { bayesianShrink, round2 } from "./bayes";
+
+describe("bayesianShrink", () => {
+ it("is zero when nobody voted", () => {
+ expect(bayesianShrink(9, 0, 5, 2)).toBe(0);
+ });
+
+ it("weighs two votes equally with the prior at c = 2", () => {
+ expect(bayesianShrink(8, 2, 4, 2)).toBe(6);
+ });
+
+ it("approaches the project's own average as votes accumulate", () => {
+ const few = bayesianShrink(10, 1, 0, 2);
+ const many = bayesianShrink(10, 100, 0, 2);
+ expect(many).toBeGreaterThan(few);
+ expect(many).toBeGreaterThan(9.5);
+ });
+});
+
+describe("round2", () => {
+ it("rounds half up to the hundredth", () => {
+ expect(round2(1.234)).toBe(1.23);
+ expect(round2(1.235)).toBe(1.24);
+ expect(round2(7.085)).toBe(7.09);
+ });
+});
diff --git a/packages/judging-core/src/aggregate/bayes.ts b/packages/judging-core/src/aggregate/bayes.ts
new file mode 100644
index 00000000..15f764fe
--- /dev/null
+++ b/packages/judging-core/src/aggregate/bayes.ts
@@ -0,0 +1,19 @@
+/**
+ * Pull a project's normalised average toward the field when few judges have
+ * seen it. `c` is how many average votes the prior is worth. At `c = 2`,
+ * two real votes and the prior weigh the same.
+ */
+export function bayesianShrink(
+ normalizedAvg: number,
+ n: number,
+ globalAvg: number,
+ c: number,
+): number {
+ if (n <= 0) return 0;
+ return (n / (n + c)) * normalizedAvg + (c / (n + c)) * globalAvg;
+}
+
+/** Hundredths, same rounding the published placings use. */
+export function round2(value: number): number {
+ return Math.round(value * 100) / 100;
+}
diff --git a/packages/judging-core/src/aggregate/blend.test.ts b/packages/judging-core/src/aggregate/blend.test.ts
new file mode 100644
index 00000000..d40cf033
--- /dev/null
+++ b/packages/judging-core/src/aggregate/blend.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+import { blend } from "./blend";
+
+describe("blend", () => {
+ it("returns the rubric at weight 0 and the pairwise score at weight 1", () => {
+ expect(blend(4, 10, 0)).toBe(4);
+ expect(blend(4, 10, 1)).toBe(10);
+ });
+
+ it("takes the midpoint at a half", () => {
+ expect(blend(4, 10, 0.5)).toBe(7);
+ });
+
+ it("rejects a weight outside 0 to 1", () => {
+ expect(() => blend(1, 1, 1.1)).toThrow(/weight/);
+ expect(() => blend(1, 1, -0.1)).toThrow(/weight/);
+ });
+});
diff --git a/packages/judging-core/src/aggregate/blend.ts b/packages/judging-core/src/aggregate/blend.ts
new file mode 100644
index 00000000..9b3bba95
--- /dev/null
+++ b/packages/judging-core/src/aggregate/blend.ts
@@ -0,0 +1,11 @@
+/** `weight` is the share of the pairwise component. Zero is rubric only. */
+export function blend(
+ rubric: number,
+ pairwise: number,
+ weight: number,
+): number {
+ if (!Number.isFinite(weight) || weight < 0 || weight > 1) {
+ throw new Error("pairwise weight must be between 0 and 1");
+ }
+ return (1 - weight) * rubric + weight * pairwise;
+}
diff --git a/packages/judging-core/src/aggregate/bradleyTerry.test.ts b/packages/judging-core/src/aggregate/bradleyTerry.test.ts
new file mode 100644
index 00000000..3a54f9b6
--- /dev/null
+++ b/packages/judging-core/src/aggregate/bradleyTerry.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import { bradleyTerry } from "./bradleyTerry";
+
+describe("bradleyTerry", () => {
+ it("returns an empty map when there is nobody", () => {
+ expect(bradleyTerry([], []).size).toBe(0);
+ });
+
+ it("leaves everyone equal when there are no comparisons", () => {
+ const strength = bradleyTerry(["a", "b"], []);
+ expect(strength.get("a")).toBe(1);
+ expect(strength.get("b")).toBe(1);
+ });
+
+ it("ranks a consistent set in transitive order", () => {
+ const strength = bradleyTerry(
+ ["a", "b", "c"],
+ [
+ { a: "a", b: "b", outcome: "a" },
+ { a: "b", b: "c", outcome: "a" },
+ { a: "a", b: "c", outcome: "a" },
+ ],
+ );
+ const a = strength.get("a") ?? 0;
+ const b = strength.get("b") ?? 0;
+ const c = strength.get("c") ?? 0;
+ expect(a).toBeGreaterThan(b);
+ expect(b).toBeGreaterThan(c);
+ });
+
+ it("puts a tie closer together than a sweep", () => {
+ const tied = bradleyTerry(
+ ["a", "b"],
+ [
+ { a: "a", b: "b", outcome: "tie" },
+ { a: "a", b: "b", outcome: "tie" },
+ ],
+ );
+ const swept = bradleyTerry(
+ ["a", "b"],
+ [
+ { a: "a", b: "b", outcome: "a" },
+ { a: "a", b: "b", outcome: "a" },
+ ],
+ );
+ const tieGap = Math.abs((tied.get("a") ?? 0) - (tied.get("b") ?? 0));
+ const sweepGap = Math.abs((swept.get("a") ?? 0) - (swept.get("b") ?? 0));
+ expect(tieGap).toBeLessThan(sweepGap);
+ });
+});
diff --git a/packages/judging-core/src/aggregate/bradleyTerry.ts b/packages/judging-core/src/aggregate/bradleyTerry.ts
new file mode 100644
index 00000000..71132a65
--- /dev/null
+++ b/packages/judging-core/src/aggregate/bradleyTerry.ts
@@ -0,0 +1,72 @@
+import type { Comparison } from "../comparison";
+
+/**
+ * Bradley-Terry strengths from pairwise outcomes. A tie is half a win each.
+ * Strengths are scaled so their mean is 1. Projects that never appear in a
+ * comparison are left at 1. Iteration is the minorization-maximization
+ * update; `iterations` is a cap, not a clock.
+ */
+export function bradleyTerry(
+ projectIds: readonly string[],
+ comparisons: readonly Comparison[],
+ iterations = 50,
+): Map {
+ const ids = [...new Set(projectIds)];
+ const strength = new Map(ids.map((id) => [id, 1]));
+ if (ids.length === 0 || comparisons.length === 0) return strength;
+
+ const wins = new Map(ids.map((id) => [id, 0]));
+ const games = new Map>();
+
+ const addGame = (left: string, right: string) => {
+ const row = games.get(left) ?? new Map();
+ row.set(right, (row.get(right) ?? 0) + 1);
+ games.set(left, row);
+ };
+
+ for (const comparison of comparisons) {
+ if (
+ comparison.a === comparison.b ||
+ !strength.has(comparison.a) ||
+ !strength.has(comparison.b)
+ ) {
+ continue;
+ }
+ if (comparison.outcome === "tie") {
+ wins.set(comparison.a, (wins.get(comparison.a) ?? 0) + 0.5);
+ wins.set(comparison.b, (wins.get(comparison.b) ?? 0) + 0.5);
+ } else if (comparison.outcome === "a") {
+ wins.set(comparison.a, (wins.get(comparison.a) ?? 0) + 1);
+ } else {
+ wins.set(comparison.b, (wins.get(comparison.b) ?? 0) + 1);
+ }
+ addGame(comparison.a, comparison.b);
+ addGame(comparison.b, comparison.a);
+ }
+
+ for (let step = 0; step < iterations; step += 1) {
+ const next = new Map();
+ for (const id of ids) {
+ const opponents = games.get(id);
+ if (!opponents || opponents.size === 0) {
+ next.set(id, strength.get(id) ?? 1);
+ continue;
+ }
+ let denominator = 0;
+ for (const [other, count] of opponents) {
+ const pi = strength.get(id) ?? 1;
+ const pj = strength.get(other) ?? 1;
+ const sum = pi + pj;
+ if (sum > 0) denominator += count / sum;
+ }
+ const won = wins.get(id) ?? 0;
+ next.set(id, denominator > 0 ? won / denominator : 0);
+ }
+ let total = 0;
+ for (const value of next.values()) total += value;
+ const mean = total / ids.length || 1;
+ for (const id of ids) strength.set(id, (next.get(id) ?? 0) / mean);
+ }
+
+ return strength;
+}
diff --git a/packages/judging-core/src/aggregate/rank.test.ts b/packages/judging-core/src/aggregate/rank.test.ts
new file mode 100644
index 00000000..800c28d9
--- /dev/null
+++ b/packages/judging-core/src/aggregate/rank.test.ts
@@ -0,0 +1,244 @@
+import { describe, expect, it } from "vitest";
+import { zNormalize } from "./zscore";
+import { bayesianShrink, round2 } from "./bayes";
+import { rank } from "./rank";
+import type { ProjectInput, RankComparison, RankConfig, VoteInput } from "./rank";
+
+/**
+ * The ranking the current event publishes: z-score per judge against the
+ * population mean and spread, round the project mean, shrink toward the mean
+ * of those means with c = 2, round again. Same raw score maps to one
+ * normalised value because the z-score is linear, so a value-keyed lookup
+ * and an index-aligned one agree.
+ */
+function legacyRank(
+ projects: readonly { id: string }[],
+ votes: readonly { judgeId: string; projectId: string; score: number }[],
+) {
+ const byJudge = new Map();
+ for (const vote of votes) {
+ const list = byJudge.get(vote.judgeId) ?? [];
+ list.push(vote.score);
+ byJudge.set(vote.judgeId, list);
+ }
+ const raw = [...byJudge.values()].flat();
+ const globalMean = raw.length > 0 ? raw.reduce((a, b) => a + b, 0) / raw.length : 0;
+ const globalVariance =
+ raw.length > 0
+ ? raw.reduce((sum, value) => sum + (value - globalMean) ** 2, 0) / raw.length
+ : 1;
+ const globalStd = Math.sqrt(globalVariance) || 1;
+
+ const normalizedByJudge = new Map>();
+ for (const [judgeId, scores] of byJudge) {
+ const normalized = zNormalize(scores, globalMean, globalStd);
+ const lookup = new Map();
+ scores.forEach((score, index) => {
+ const existing = lookup.get(score);
+ const value = normalized[index] ?? globalMean;
+ lookup.set(score, existing !== undefined ? (existing + value) / 2 : value);
+ });
+ normalizedByJudge.set(judgeId, lookup);
+ }
+
+ const rows = projects.map((project) => {
+ const mine = votes.filter((vote) => vote.projectId === project.id);
+ const normalizedScores = mine.map(
+ (vote) => normalizedByJudge.get(vote.judgeId)?.get(vote.score) ?? vote.score,
+ );
+ const normalizedAvg =
+ mine.length > 0
+ ? round2(normalizedScores.reduce((a, b) => a + b, 0) / mine.length)
+ : 0;
+ return { id: project.id, voteCount: mine.length, normalizedAvg };
+ });
+
+ const voted = rows.filter((row) => row.voteCount > 0);
+ const globalAvg =
+ voted.length > 0
+ ? round2(voted.reduce((sum, row) => sum + row.normalizedAvg, 0) / voted.length)
+ : 0;
+
+ return rows
+ .map((row) => ({
+ id: row.id,
+ voteCount: row.voteCount,
+ score:
+ row.voteCount > 0
+ ? round2(bayesianShrink(row.normalizedAvg, row.voteCount, globalAvg, 2))
+ : 0,
+ }))
+ .filter((row) => row.voteCount > 0)
+ .sort((a, b) => b.score - a.score || projects.findIndex((p) => p.id === a.id) - projects.findIndex((p) => p.id === b.id));
+}
+
+const config: RankConfig = {
+ bayesianC: 2,
+ pairwiseWeight: 0,
+ calibrationWeight: 1,
+};
+
+const projects: ProjectInput[] = [
+ { id: "A", trackIds: ["overall"] },
+ { id: "B", trackIds: ["overall"] },
+ { id: "C", trackIds: ["overall"] },
+];
+
+function vote(judgeId: string, projectId: string, total: number): VoteInput {
+ return { judgeId, projectId, judgeGroup: "main", total };
+}
+
+describe("rank matches the current event's pipeline at pairwise weight 0", () => {
+ const votes = [
+ vote("j1", "A", 8),
+ vote("j1", "B", 5),
+ vote("j1", "C", 2),
+ vote("j2", "A", 9),
+ vote("j2", "B", 9),
+ vote("j2", "C", 6),
+ ];
+
+ it("places the same projects at the same hundredths", () => {
+ const legacy = legacyRank(projects, votes.map((item) => ({
+ judgeId: item.judgeId,
+ projectId: item.projectId,
+ score: item.total,
+ })));
+ const ranked = rank(projects, votes, [], config)
+ .filter((row) => row.placement !== null)
+ .sort((a, b) => (a.placement ?? 0) - (b.placement ?? 0));
+
+ expect(ranked.map((row) => [row.projectId, row.score])).toEqual(
+ legacy.map((row) => [row.id, row.score]),
+ );
+ expect(ranked.map((row) => row.score)).toEqual([7.71, 6.94, 4.85]);
+ });
+
+ it("ignores comparisons when the pairwise weight is zero", () => {
+ const withPairs = rank(
+ projects,
+ votes,
+ [
+ {
+ judgeId: "j1",
+ judgeGroup: "main",
+ a: "C",
+ b: "A",
+ outcome: "a",
+ },
+ ],
+ config,
+ );
+ const without = rank(projects, votes, [], config);
+ expect(withPairs.map((row) => row.score)).toEqual(without.map((row) => row.score));
+ });
+});
+
+describe("rank", () => {
+ it("does not place a project nobody scored", () => {
+ const rows = rank(
+ [
+ { id: "seen", trackIds: ["overall"] },
+ { id: "missed", trackIds: ["overall"] },
+ ],
+ [vote("j1", "seen", 8), vote("j2", "seen", 6)],
+ [],
+ config,
+ );
+ const seen = rows.find((row) => row.projectId === "seen");
+ const missed = rows.find((row) => row.projectId === "missed");
+ expect(seen?.placement).toBe(1);
+ expect(missed?.placement).toBeNull();
+ });
+
+ it("keeps judge groups apart", () => {
+ const rows = rank(
+ [{ id: "A", trackIds: ["overall"] }],
+ [
+ { judgeId: "j1", projectId: "A", judgeGroup: "main", total: 10 },
+ { judgeId: "j2", projectId: "A", judgeGroup: "sponsor", total: 1 },
+ ],
+ [],
+ config,
+ );
+ expect(rows).toHaveLength(2);
+ expect(rows.find((row) => row.judgeGroup === "main")?.voteCount).toBe(1);
+ expect(rows.find((row) => row.judgeGroup === "sponsor")?.voteCount).toBe(1);
+ });
+
+ it("drops a calibration vote whose weight is zero", () => {
+ const base = rank(projects, [vote("j1", "A", 8), vote("j1", "B", 4)], [], config);
+ const extra = rank(
+ projects,
+ [
+ vote("j1", "A", 8),
+ vote("j1", "B", 4),
+ { ...vote("j1", "C", 1), isCalibration: true },
+ ],
+ [],
+ { ...config, calibrationWeight: 0 },
+ );
+ const score = (rows: typeof base, id: string) =>
+ rows.find((row) => row.projectId === id)?.score;
+ expect(score(extra, "A")).toBe(score(base, "A"));
+ expect(score(extra, "B")).toBe(score(base, "B"));
+ expect(extra.find((row) => row.projectId === "C")?.voteCount).toBe(0);
+ });
+
+ it("orders a pairwise-only field by who won, at weight 1", () => {
+ const rows = rank(
+ projects,
+ [],
+ [
+ { judgeId: "j1", judgeGroup: "main", a: "A", b: "B", outcome: "a" },
+ { judgeId: "j1", judgeGroup: "main", a: "B", b: "C", outcome: "a" },
+ { judgeId: "j1", judgeGroup: "main", a: "A", b: "C", outcome: "a" },
+ ],
+ { ...config, pairwiseWeight: 1 },
+ );
+ const placed = rows
+ .filter((row) => row.placement !== null)
+ .sort((a, b) => (a.placement ?? 0) - (b.placement ?? 0));
+ expect(placed.map((row) => row.projectId)).toEqual(["A", "B", "C"]);
+ });
+});
+
+describe("rank at pairwise weight 1", () => {
+ it("keeps the true order when every comparison agrees", () => {
+ const ids = ["a", "b", "c", "d", "e"];
+ let seed = 11;
+ const random = () => {
+ seed = (seed * 1664525 + 1013904223) % 4294967296;
+ return seed / 4294967296;
+ };
+ for (let trial = 0; trial < 20; trial += 1) {
+ const truth = [...ids].sort(() => random() - 0.5);
+ const better = new Map(truth.map((id, index) => [id, index]));
+ const comparisons: RankComparison[] = [];
+ for (let left = 0; left < ids.length; left += 1) {
+ for (let right = left + 1; right < ids.length; right += 1) {
+ const a = ids[left] ?? "";
+ const b = ids[right] ?? "";
+ const aWins = (better.get(a) ?? 0) < (better.get(b) ?? 0);
+ comparisons.push({
+ judgeId: "j1",
+ judgeGroup: "main",
+ a,
+ b,
+ outcome: aWins ? ("a" as const) : ("b" as const),
+ });
+ }
+ }
+ const rows = rank(
+ ids.map((id) => ({ id, trackIds: ["overall"] })),
+ [],
+ comparisons,
+ { ...config, pairwiseWeight: 1 },
+ );
+ const placed = rows
+ .filter((row) => row.placement !== null)
+ .sort((left, right) => (left.placement ?? 0) - (right.placement ?? 0));
+ expect(placed.map((row) => row.projectId)).toEqual(truth);
+ }
+ });
+});
diff --git a/packages/judging-core/src/aggregate/rank.ts b/packages/judging-core/src/aggregate/rank.ts
new file mode 100644
index 00000000..6f1fa9d8
--- /dev/null
+++ b/packages/judging-core/src/aggregate/rank.ts
@@ -0,0 +1,312 @@
+import type { Comparison } from "../comparison";
+import { bayesianShrink, round2 } from "./bayes";
+import { bradleyTerry } from "./bradleyTerry";
+import { blend } from "./blend";
+import { zNormalize } from "./zscore";
+
+export type VoteInput = {
+ judgeId: string;
+ projectId: string;
+ judgeGroup: string;
+ total: number;
+ isCalibration?: boolean;
+};
+
+export type RankComparison = Comparison & {
+ judgeId: string;
+ judgeGroup: string;
+};
+
+export type ProjectInput = {
+ id: string;
+ trackIds: readonly string[];
+};
+
+export type RankConfig = {
+ bayesianC: number;
+ pairwiseWeight: number;
+ /** How much a calibration vote counts, relative to a normal vote of 1. */
+ calibrationWeight: number;
+};
+
+export type RankedRow = {
+ projectId: string;
+ trackId: string;
+ judgeGroup: string;
+ placement: number | null;
+ score: number;
+ rubricComponent: number;
+ pairwiseComponent: number;
+ voteCount: number;
+ comparisonCount: number;
+};
+
+/**
+ * Rank each track and judge group.
+ *
+ * Rubric side, at `pairwiseWeight = 0` and `calibrationWeight = 1`: z-score
+ * each judge onto the group's mean and spread, average per project (rounded
+ * to hundredths), shrink toward the mean of those averages with `bayesianC`,
+ * round again, and place by that score. That is the pipeline the existing
+ * event uses, so a replay of its votes lands on the same numbers.
+ *
+ * Pairwise side: Bradley-Terry on comparisons in the group, scaled onto the
+ * rubric scores, then blended. Weight 0 leaves the rubric number untouched.
+ * A calibration vote is down-weighted in the average and in the shrink's
+ * sample size. It still informs that judge's own z-score.
+ */
+export function rank(
+ projects: readonly ProjectInput[],
+ votes: readonly VoteInput[],
+ comparisons: readonly RankComparison[],
+ config: RankConfig,
+): RankedRow[] {
+ const known = new Set(projects.map((project) => project.id));
+ const tracks = trackOrder(projects);
+ const groups = groupOrder(votes, comparisons);
+ const rows: RankedRow[] = [];
+
+ for (const judgeGroup of groups) {
+ const groupVotes = votes.filter(
+ (vote) => vote.judgeGroup === judgeGroup && known.has(vote.projectId),
+ );
+ const normalized = normalize(groupVotes, config);
+ for (const trackId of tracks) {
+ const onTrack = projects.filter((project) =>
+ project.trackIds.includes(trackId),
+ );
+ const onTrackIds = new Set(onTrack.map((project) => project.id));
+ const trackVotes = groupVotes
+ .map((vote, index) => ({
+ vote,
+ normalized: normalized[index] ?? 0,
+ }))
+ .filter(({ vote }) => onTrackIds.has(vote.projectId));
+ const trackComparisons = comparisons.filter(
+ (comparison) =>
+ comparison.judgeGroup === judgeGroup &&
+ onTrackIds.has(comparison.a) &&
+ onTrackIds.has(comparison.b),
+ );
+ rows.push(
+ ...rankTrack(
+ onTrack,
+ trackVotes,
+ trackComparisons,
+ trackId,
+ judgeGroup,
+ config,
+ ),
+ );
+ }
+ }
+
+ return rows;
+}
+
+function weightOf(vote: VoteInput, config: RankConfig): number {
+ return vote.isCalibration ? config.calibrationWeight : 1;
+}
+
+function spread(scores: readonly number[]): { mean: number; std: number } {
+ if (scores.length === 0) return { mean: 0, std: 1 };
+ const mean = scores.reduce((sum, value) => sum + value, 0) / scores.length;
+ const variance =
+ scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / scores.length;
+ return { mean, std: Math.sqrt(variance) || 1 };
+}
+
+function normalize(
+ votes: readonly VoteInput[],
+ config: RankConfig,
+): number[] {
+ const weights = votes.map((vote) => weightOf(vote, config));
+ const raw: number[] = [];
+ for (let index = 0; index < votes.length; index += 1) {
+ const vote = votes[index];
+ if (vote && (weights[index] ?? 0) > 0) raw.push(vote.total);
+ }
+ const { mean, std } = spread(raw);
+
+ const byJudge = new Map();
+ votes.forEach((vote, index) => {
+ if ((weights[index] ?? 0) <= 0) return;
+ const list = byJudge.get(vote.judgeId) ?? [];
+ list.push(index);
+ byJudge.set(vote.judgeId, list);
+ });
+
+ const out = votes.map(() => 0);
+ for (const indexes of byJudge.values()) {
+ const scores = indexes.map((index) => votes[index]?.total ?? 0);
+ const normalized = zNormalize(scores, mean, std);
+ indexes.forEach((index, offset) => {
+ out[index] = normalized[offset] ?? 0;
+ });
+ }
+ return out;
+}
+
+type CarriedVote = { vote: VoteInput; normalized: number };
+
+function rankTrack(
+ projects: readonly ProjectInput[],
+ votes: readonly CarriedVote[],
+ comparisons: readonly RankComparison[],
+ trackId: string,
+ judgeGroup: string,
+ config: RankConfig,
+): RankedRow[] {
+ const stats = new Map();
+ for (const project of projects) {
+ let weightedSum = 0;
+ let valueSum = 0;
+ let count = 0;
+ for (const item of votes) {
+ if (item.vote.projectId !== project.id) continue;
+ const weight = weightOf(item.vote, config);
+ if (weight <= 0) continue;
+ weightedSum += weight;
+ valueSum += item.normalized * weight;
+ count += 1;
+ }
+ stats.set(project.id, {
+ avg: weightedSum > 0 ? round2(valueSum / weightedSum) : 0,
+ n: weightedSum,
+ count,
+ });
+ }
+
+ const votedAverages: number[] = [];
+ for (const row of stats.values()) {
+ if (row.n > 0) votedAverages.push(row.avg);
+ }
+ const globalAvg =
+ votedAverages.length > 0
+ ? round2(
+ votedAverages.reduce((sum, value) => sum + value, 0) /
+ votedAverages.length,
+ )
+ : 0;
+
+ const rubricScore = new Map();
+ const anchors: number[] = [];
+ for (const project of projects) {
+ const row = stats.get(project.id);
+ const n = row?.n ?? 0;
+ const score =
+ n > 0
+ ? round2(bayesianShrink(row?.avg ?? 0, n, globalAvg, config.bayesianC))
+ : 0;
+ rubricScore.set(project.id, score);
+ if (n > 0) anchors.push(score);
+ }
+
+ const compared = new Set();
+ for (const comparison of comparisons) {
+ compared.add(comparison.a);
+ compared.add(comparison.b);
+ }
+ const scaled = scaleStrengths(
+ bradleyTerry([...compared], comparisons),
+ anchors,
+ compared,
+ );
+
+ const draft: RankedRow[] = projects.map((project) => {
+ const row = stats.get(project.id);
+ const rubricComponent = rubricScore.get(project.id) ?? 0;
+ const comparisonCount = comparisons.filter(
+ (comparison) =>
+ comparison.a === project.id || comparison.b === project.id,
+ ).length;
+ const scaledValue = scaled.get(project.id);
+ const pairwiseForBlend = scaledValue ?? rubricComponent;
+ const score =
+ config.pairwiseWeight === 0
+ ? rubricComponent
+ : round2(
+ blend(rubricComponent, pairwiseForBlend, config.pairwiseWeight),
+ );
+ return {
+ projectId: project.id,
+ trackId,
+ judgeGroup,
+ placement: null,
+ score,
+ rubricComponent,
+ pairwiseComponent: scaledValue ?? 0,
+ voteCount: row?.count ?? 0,
+ comparisonCount,
+ };
+ });
+
+ const order = new Map(projects.map((project, index) => [project.id, index]));
+ const placeable = draft.filter(
+ (row) =>
+ row.voteCount > 0 ||
+ (config.pairwiseWeight > 0 && row.comparisonCount > 0),
+ );
+ placeable.sort((a, b) => {
+ if (a.score !== b.score) return b.score - a.score;
+ // Hundredths can tie two neighbours. The unrounded pairwise strength
+ // still has the order the comparisons produced.
+ if (
+ config.pairwiseWeight > 0 &&
+ a.pairwiseComponent !== b.pairwiseComponent
+ ) {
+ return b.pairwiseComponent - a.pairwiseComponent;
+ }
+ return (order.get(a.projectId) ?? 0) - (order.get(b.projectId) ?? 0);
+ });
+ placeable.forEach((row, index) => {
+ row.placement = index + 1;
+ });
+ return draft;
+}
+
+function scaleStrengths(
+ strength: ReadonlyMap,
+ anchors: readonly number[],
+ compared: ReadonlySet,
+): Map {
+ const out = new Map();
+ if (compared.size === 0) return out;
+ const values = [...compared].map((id) => strength.get(id) ?? 0);
+ const lowStrength = Math.min(...values);
+ const highStrength = Math.max(...values);
+ const low = anchors.length > 0 ? Math.min(...anchors) : lowStrength;
+ const high = anchors.length > 0 ? Math.max(...anchors) : highStrength;
+ for (const id of compared) {
+ const value = strength.get(id) ?? 0;
+ if (anchors.length === 0) out.set(id, value);
+ else if (highStrength === lowStrength) out.set(id, (low + high) / 2);
+ else {
+ out.set(
+ id,
+ low +
+ ((value - lowStrength) / (highStrength - lowStrength)) * (high - low),
+ );
+ }
+ }
+ return out;
+}
+
+function trackOrder(projects: readonly ProjectInput[]): string[] {
+ const seen = new Set();
+ for (const project of projects) {
+ for (const trackId of project.trackIds) seen.add(trackId);
+ }
+ return [...seen];
+}
+
+function groupOrder(
+ votes: readonly VoteInput[],
+ comparisons: readonly RankComparison[],
+): string[] {
+ const seen = new Set();
+ for (const vote of votes) seen.add(vote.judgeGroup);
+ for (const comparison of comparisons) seen.add(comparison.judgeGroup);
+ if (seen.size === 0) seen.add("main");
+ return [...seen];
+}
diff --git a/packages/judging-core/src/aggregate/zscore.test.ts b/packages/judging-core/src/aggregate/zscore.test.ts
new file mode 100644
index 00000000..29722da2
--- /dev/null
+++ b/packages/judging-core/src/aggregate/zscore.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import { zNormalize } from "./zscore";
+
+const mean = (xs: number[]) => xs.reduce((sum, value) => sum + value, 0) / xs.length;
+const stddev = (xs: number[]) => {
+ const center = mean(xs);
+ return Math.sqrt(mean(xs.map((value) => (value - center) ** 2)));
+};
+
+describe("zNormalize", () => {
+ it("returns the global mean when fewer than two scores", () => {
+ expect(zNormalize([8], 5, 2)).toEqual([5]);
+ expect(zNormalize([], 5, 2)).toEqual([]);
+ });
+
+ it("returns the global mean when a judge scored everything the same", () => {
+ expect(zNormalize([7, 7, 7], 5, 2)).toEqual([5, 5, 5]);
+ });
+
+ it("spreads scores around the global mean", () => {
+ const normalized = zNormalize([2, 5, 8], 5, 2);
+ expect(normalized[0] ?? 0).toBeLessThan(5);
+ expect(normalized[1] ?? 0).toBeCloseTo(5, 1);
+ expect(normalized[2] ?? 0).toBeGreaterThan(5);
+ });
+
+ it("preserves a judge's own ordering", () => {
+ const normalized = zNormalize([1, 4, 9, 10], 5, 2);
+ for (let index = 1; index < normalized.length; index += 1) {
+ expect(normalized[index] ?? 0).toBeGreaterThan(normalized[index - 1] ?? 0);
+ }
+ });
+
+ it("makes a harsh judge and a lenient judge agree", () => {
+ const harsh = zNormalize([1, 2, 3], 5, 2);
+ const lenient = zNormalize([8, 9, 10], 5, 2);
+ expect(harsh[0] ?? 0).toBeCloseTo(lenient[0] ?? 0, 10);
+ expect(harsh[1] ?? 0).toBeCloseTo(lenient[1] ?? 0, 10);
+ expect(harsh[2] ?? 0).toBeCloseTo(lenient[2] ?? 0, 10);
+ });
+
+ it("makes a wide-spread judge and a narrow-spread judge agree", () => {
+ const wide = zNormalize([0, 5, 10], 5, 2);
+ const narrow = zNormalize([4, 5, 6], 5, 2);
+ expect(wide[0] ?? 0).toBeCloseTo(narrow[0] ?? 0, 10);
+ expect(wide[2] ?? 0).toBeCloseTo(narrow[2] ?? 0, 10);
+ });
+
+ it("centres the normalized scores on the global mean", () => {
+ expect(mean(zNormalize([1, 4, 9, 10], 7, 2))).toBeCloseTo(7, 10);
+ });
+
+ it("gives the normalized scores the global spread", () => {
+ expect(stddev(zNormalize([1, 4, 9, 10], 5, 3))).toBeCloseTo(3, 10);
+ });
+
+ it("never produces NaN or Infinity for degenerate input", () => {
+ const cases = [
+ zNormalize([7, 7], 5, 2),
+ zNormalize([0, 0, 0], 5, 2),
+ zNormalize([5], 5, 0),
+ zNormalize([1, 2], 0, 0),
+ ];
+ for (const result of cases) {
+ for (const value of result) expect(Number.isFinite(value)).toBe(true);
+ }
+ });
+
+ it("handles negative scores and keeps equals equal", () => {
+ const negative = zNormalize([-5, 0, 5], 5, 2);
+ expect(negative[0] ?? 0).toBeLessThan(negative[2] ?? 0);
+ const tied = zNormalize([3, 3, 9], 5, 2);
+ expect(tied[0] ?? 0).toBeCloseTo(tied[1] ?? 0, 10);
+ });
+
+ it("collapses to the global mean when the global spread is zero", () => {
+ expect(zNormalize([1, 5, 9], 6, 0)).toEqual([6, 6, 6]);
+ });
+});
diff --git a/packages/judging-core/src/aggregate/zscore.ts b/packages/judging-core/src/aggregate/zscore.ts
new file mode 100644
index 00000000..e6c96bb4
--- /dev/null
+++ b/packages/judging-core/src/aggregate/zscore.ts
@@ -0,0 +1,22 @@
+/**
+ * Z-score a judge's scores against their own mean and spread, then place
+ * that shape on the global mean and spread. A judge with fewer than two
+ * scores, or who gave everything the same number, has no spread of their
+ * own, so every score becomes the global mean. A global spread of zero
+ * collapses everyone onto the global mean too.
+ */
+export function zNormalize(
+ scores: readonly number[],
+ globalMean: number,
+ globalStd: number,
+): number[] {
+ if (scores.length < 2) return scores.map(() => globalMean);
+ const mean = scores.reduce((sum, value) => sum + value, 0) / scores.length;
+ const variance =
+ scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / scores.length;
+ const std = Math.sqrt(variance);
+ if (std === 0 || globalStd === 0) return scores.map(() => globalMean);
+ return scores.map(
+ (value) => globalMean + ((value - mean) / std) * globalStd,
+ );
+}
diff --git a/packages/judging-core/src/comparison.ts b/packages/judging-core/src/comparison.ts
new file mode 100644
index 00000000..7937600b
--- /dev/null
+++ b/packages/judging-core/src/comparison.ts
@@ -0,0 +1,5 @@
+export type Comparison = {
+ a: string;
+ b: string;
+ outcome: "a" | "b" | "tie";
+};
diff --git a/packages/judging-core/src/dispatch.sim.test.ts b/packages/judging-core/src/dispatch.sim.test.ts
new file mode 100644
index 00000000..fa2f6396
--- /dev/null
+++ b/packages/judging-core/src/dispatch.sim.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from "vitest";
+import { pickNext } from "./dispatch";
+import type { Candidate, DispatchStrategy } from "./dispatch";
+import { DEFAULT_TIMERS } from "./timers";
+import { scoreUncertainty } from "./uncertainty";
+
+/**
+ * Twenty judges, two hundred projects, three hours at the table. The same
+ * noise is available to both strategies; only the choice of who is seen
+ * again changes. The ten truly strongest projects should end up in an order
+ * closer to the truth when extra looks go to the unsettled ones.
+ */
+describe("uncertainty dispatch", () => {
+ it("ranks the true top 10 better than even coverage at the same number of looks", () => {
+ const projects = 200;
+ const judges = 20;
+ const hours = 3;
+ const budget = judges * Math.floor((hours * 3600) / DEFAULT_TIMERS.targetSeconds);
+ const truth = Array.from({ length: projects }, (_, index) => index);
+ const noise = truth.map((_, project) =>
+ Array.from({ length: 40 }, (_, look) =>
+ gaussian(mulberry32(project * 1000 + look + 1)) * noiseScale(project),
+ ),
+ );
+
+ const coverage = simulate("coverage", { projects, judges, budget, truth, noise });
+ const uncertainty = simulate("uncertainty", { projects, judges, budget, truth, noise });
+ const top = truth.map((_, index) => index).slice(-10);
+
+ expect(kendall(top, uncertainty, truth)).toBeGreaterThan(kendall(top, coverage, truth));
+ });
+});
+
+function noiseScale(project: number): number {
+ return project >= 185 ? 18 : 3;
+}
+
+function simulate(
+ strategy: DispatchStrategy,
+ input: {
+ projects: number;
+ judges: number;
+ budget: number;
+ truth: number[];
+ noise: number[][];
+ },
+): number[] {
+ const scores = Array.from({ length: input.projects }, () => [] as number[]);
+ const seen = Array.from({ length: input.judges }, () => new Set());
+ const last: (number | null)[] = Array.from({ length: input.judges }, () => null);
+ let used = 0;
+ let guard = 0;
+ while (used < input.budget && guard < input.budget) {
+ for (let judge = 0; judge < input.judges && used < input.budget; judge += 1) {
+ const candidates: Candidate[] = [];
+ for (let project = 0; project < input.projects; project += 1) {
+ if (seen[judge]?.has(project)) continue;
+ candidates.push({
+ id: String(project),
+ tableNumber: project,
+ coverage: scores[project]?.length ?? 0,
+ claimedAt: null,
+ uncertainty: scoreUncertainty(scores[project] ?? []),
+ });
+ }
+ const pick = pickNext(
+ candidates,
+ { lastTable: last[judge] ?? null },
+ { strategy, minLooksPerProject: 2 },
+ () => 0,
+ );
+ if (!pick) continue;
+ const project = Number(pick.id);
+ const look = scores[project]?.length ?? 0;
+ scores[project]?.push((input.truth[project] ?? 0) + (input.noise[project]?.[look] ?? 0));
+ seen[judge]?.add(project);
+ last[judge] = project;
+ used += 1;
+ }
+ guard += 1;
+ }
+ return scores.map((row, index) =>
+ row.length === 0
+ ? (input.truth[index] ?? 0)
+ : row.reduce((sum, value) => sum + value, 0) / row.length,
+ );
+}
+
+function kendall(ids: number[], estimate: number[], truth: number[]): number {
+ let concordant = 0;
+ let discordant = 0;
+ for (let left = 0; left < ids.length; left += 1) {
+ for (let right = left + 1; right < ids.length; right += 1) {
+ const a = ids[left] ?? 0;
+ const b = ids[right] ?? 0;
+ const truthSign = Math.sign((truth[a] ?? 0) - (truth[b] ?? 0));
+ const estimateSign = Math.sign((estimate[a] ?? 0) - (estimate[b] ?? 0));
+ if (truthSign === 0 || estimateSign === 0) continue;
+ if (truthSign === estimateSign) concordant += 1;
+ else discordant += 1;
+ }
+ }
+ return (concordant - discordant) / (concordant + discordant);
+}
+
+function gaussian(rng: () => number): number {
+ const u = Math.max(rng(), 1e-9);
+ const v = rng();
+ return Math.sqrt(-2 * Math.log(u)) * Math.cos(Math.PI * 2 * v);
+}
+
+function mulberry32(seed: number): () => number {
+ let state = seed;
+ return () => {
+ state = (state + 0x6d2b79f5) | 0;
+ let value = Math.imul(state ^ (state >>> 15), 1 | state);
+ value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
+ };
+}
diff --git a/packages/judging-core/src/dispatch.test.ts b/packages/judging-core/src/dispatch.test.ts
new file mode 100644
index 00000000..9c2f0ec5
--- /dev/null
+++ b/packages/judging-core/src/dispatch.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, it } from "vitest";
+import { pickNext } from "./dispatch";
+import type { Candidate, DispatchConfig } from "./dispatch";
+
+const t0 = new Date("2027-02-28T13:00:00Z");
+const at = (seconds: number) => new Date(t0.getTime() + seconds * 1000);
+const first = () => 0;
+const coverage: DispatchConfig = {
+ strategy: "coverage",
+ minLooksPerProject: 1,
+};
+
+const c = (
+ id: string,
+ tableNumber: number,
+ looks = 0,
+ claimedAt: number | null = null,
+ extra: Partial = {},
+): Candidate => ({
+ id,
+ tableNumber,
+ coverage: looks,
+ claimedAt,
+ ...extra,
+});
+
+describe("pickNext coverage", () => {
+ it("returns nothing when there is nothing left", () => {
+ expect(pickNext([], { lastTable: null }, coverage, first)).toBeNull();
+ });
+
+ it("takes the least-covered project", () => {
+ expect(
+ pickNext(
+ [c("a", 1, 2), c("b", 2, 0), c("c", 3, 1)],
+ { lastTable: null },
+ coverage,
+ first,
+ )?.id,
+ ).toBe("b");
+ });
+
+ it("breaks a coverage tie by the table nearest the judge's last one", () => {
+ const pick = pickNext(
+ [c("far", 90, 1), c("near", 12, 1), c("mid", 40, 1)],
+ { lastTable: 10 },
+ coverage,
+ first,
+ );
+ expect(pick?.id).toBe("near");
+ });
+
+ it("never trades coverage for distance", () => {
+ expect(
+ pickNext(
+ [c("next-door", 11, 2), c("across", 200, 1)],
+ { lastTable: 10 },
+ coverage,
+ first,
+ )?.id,
+ ).toBe("across");
+ });
+
+ it("passes over a table someone is at while a free one exists", () => {
+ expect(
+ pickNext(
+ [c("taken", 1, 0, at(0).getTime()), c("free", 2, 3)],
+ { lastTable: null },
+ coverage,
+ first,
+ )?.id,
+ ).toBe("free");
+ });
+
+ it("sends the judge to the longest-held table when every one is taken", () => {
+ const pick = pickNext(
+ [
+ c("recent", 1, 0, at(100).getTime()),
+ c("oldest", 2, 0, at(0).getTime()),
+ ],
+ { lastTable: null },
+ coverage,
+ first,
+ );
+ expect(pick?.id).toBe("oldest");
+ });
+
+ it("chooses among equals at random", () => {
+ const tier = [c("a", 5, 0), c("b", 15, 0)];
+ expect(pickNext(tier, { lastTable: 10 }, coverage, () => 0)?.id).toBe("a");
+ expect(pickNext(tier, { lastTable: 10 }, coverage, () => 1)?.id).toBe("b");
+ });
+
+ it("prefers the same zone over a closer table number in another zone", () => {
+ const pick = pickNext(
+ [
+ c("other-room", 2, 0, null, { zoneId: "atrium" }),
+ c("same-room", 30, 0, null, { zoneId: "hall" }),
+ ],
+ { lastTable: 4, zoneId: "hall" },
+ coverage,
+ first,
+ );
+ expect(pick?.id).toBe("same-room");
+ });
+});
+
+describe("pickNext uncertainty", () => {
+ const uncertainty: DispatchConfig = {
+ strategy: "uncertainty",
+ minLooksPerProject: 2,
+ };
+
+ it("keeps covering while any project is under the minimum", () => {
+ const pick = pickNext(
+ [
+ c("thin", 1, 0, null, { uncertainty: 0 }),
+ c("shaky", 2, 5, null, { uncertainty: 9 }),
+ ],
+ { lastTable: null },
+ uncertainty,
+ first,
+ );
+ expect(pick?.id).toBe("thin");
+ });
+
+ it("takes the least settled project once coverage is met", () => {
+ const pick = pickNext(
+ [
+ c("settled", 1, 2, null, { uncertainty: 0.1 }),
+ c("open", 9, 2, null, { uncertainty: 4 }),
+ ],
+ { lastTable: 1 },
+ uncertainty,
+ first,
+ );
+ expect(pick?.id).toBe("open");
+ });
+});
diff --git a/packages/judging-core/src/dispatch.ts b/packages/judging-core/src/dispatch.ts
new file mode 100644
index 00000000..aa453f98
--- /dev/null
+++ b/packages/judging-core/src/dispatch.ts
@@ -0,0 +1,128 @@
+import { distance } from "./distance";
+import type { TablePoint } from "./distance";
+
+export type Candidate = {
+ id: string;
+ tableNumber: number;
+ zoneId?: string | null;
+ x?: number | null;
+ y?: number | null;
+ /** Scores plus live claims from judges in the same group. */
+ coverage: number;
+ /** Most recent live claim by another judge, if any (ms epoch). */
+ claimedAt: number | null;
+ /** Used by the uncertainty strategy once every project has its minimum looks. */
+ uncertainty?: number;
+};
+
+export type JudgeSpot = {
+ lastTable: number | null;
+ zoneId?: string | null;
+ x?: number | null;
+ y?: number | null;
+};
+
+export type DispatchStrategy = "coverage" | "uncertainty";
+
+export type DispatchConfig = {
+ strategy: DispatchStrategy;
+ /** Uncertainty waits until every candidate has at least this many looks. */
+ minLooksPerProject: number;
+};
+
+/**
+ * The next project for one judge, from the projects they are eligible for
+ * and have not had.
+ *
+ * Coverage: fewest looks, then the nearest table, then `random`. A table
+ * someone is already at is passed over while any other exists. When every
+ * remaining one is taken, the one claimed longest ago, because an idle
+ * judge is worse than two at a table.
+ *
+ * Uncertainty: the same coverage rule until `minLooksPerProject` is met
+ * everywhere in `candidates`, then the highest uncertainty, then nearest.
+ */
+export function pickNext(
+ candidates: readonly Candidate[],
+ judge: JudgeSpot,
+ config: DispatchConfig,
+ random: (n: number) => number,
+): Candidate | null {
+ if (candidates.length === 0) return null;
+
+ const free = candidates.filter((candidate) => candidate.claimedAt === null);
+ if (free.length === 0) return oldestClaim(candidates);
+
+ const stillCovering =
+ config.strategy === "coverage" ||
+ candidates.some(
+ (candidate) => candidate.coverage < config.minLooksPerProject,
+ );
+
+ const tier = stillCovering ? fewestLooks(free) : highestUncertainty(free);
+ return narrow(tier, judge, random);
+}
+
+function fewestLooks(free: readonly Candidate[]): Candidate[] {
+ const fewest = Math.min(...free.map((candidate) => candidate.coverage));
+ return free.filter((candidate) => candidate.coverage === fewest);
+}
+
+function highestUncertainty(free: readonly Candidate[]): Candidate[] {
+ const highest = Math.max(
+ ...free.map((candidate) => candidate.uncertainty ?? 0),
+ );
+ return free.filter(
+ (candidate) => (candidate.uncertainty ?? 0) === highest,
+ );
+}
+
+function oldestClaim(candidates: readonly Candidate[]): Candidate | null {
+ let best: Candidate | null = null;
+ for (const candidate of candidates) {
+ if (!best || (candidate.claimedAt ?? 0) < (best.claimedAt ?? 0)) {
+ best = candidate;
+ }
+ }
+ return best;
+}
+
+function narrow(
+ tier: readonly Candidate[],
+ judge: JudgeSpot,
+ random: (n: number) => number,
+): Candidate | null {
+ if (tier.length === 0) return null;
+ if (judge.lastTable === null) return choose(tier, random);
+
+ const here: TablePoint = {
+ number: judge.lastTable,
+ zoneId: judge.zoneId ?? null,
+ x: judge.x ?? null,
+ y: judge.y ?? null,
+ };
+ const nearest = Math.min(
+ ...tier.map((candidate) => distance(here, candidatePoint(candidate))),
+ );
+ const closest = tier.filter(
+ (candidate) => distance(here, candidatePoint(candidate)) === nearest,
+ );
+ return choose(closest, random);
+}
+
+function choose(
+ tier: readonly Candidate[],
+ random: (n: number) => number,
+): Candidate | null {
+ if (tier.length === 0) return null;
+ return tier[random(tier.length)] ?? null;
+}
+
+function candidatePoint(candidate: Candidate): TablePoint {
+ return {
+ number: candidate.tableNumber,
+ zoneId: candidate.zoneId ?? null,
+ x: candidate.x ?? null,
+ y: candidate.y ?? null,
+ };
+}
diff --git a/packages/judging-core/src/distance.test.ts b/packages/judging-core/src/distance.test.ts
new file mode 100644
index 00000000..3bd7ee03
--- /dev/null
+++ b/packages/judging-core/src/distance.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { distance, ZONE_HOP } from "./distance";
+
+const table = (
+ number: number,
+ zoneId: string | null = null,
+ x: number | null = null,
+ y: number | null = null,
+) => ({ number, zoneId, x, y });
+
+describe("distance", () => {
+ it("is the table-number gap inside one zone", () => {
+ expect(distance(table(4, "hall"), table(11, "hall"))).toBe(7);
+ });
+
+ it("is the same gap when neither table has a zone", () => {
+ expect(distance(table(4), table(11))).toBe(7);
+ });
+
+ it("adds a zone hop when the rooms differ", () => {
+ expect(distance(table(4, "hall"), table(6, "atrium"))).toBe(ZONE_HOP + 2);
+ });
+
+ it("uses the floor plan when both tables have coordinates", () => {
+ expect(distance(table(1, "hall", 0, 0), table(9, "atrium", 3, 4))).toBe(5);
+ });
+
+ it("falls back to numbers when only one table is placed", () => {
+ expect(distance(table(1, "hall", 0, 0), table(4, "hall"))).toBe(3);
+ });
+});
diff --git a/packages/judging-core/src/distance.ts b/packages/judging-core/src/distance.ts
new file mode 100644
index 00000000..6601a79c
--- /dev/null
+++ b/packages/judging-core/src/distance.ts
@@ -0,0 +1,30 @@
+/**
+ * How far one table is from another.
+ *
+ * Same zone: the difference in table numbers, which is the walk.
+ * Another zone: a hop larger than any table gap, then the number difference,
+ * so a same-zone walk always beats changing rooms.
+ * When both tables have coordinates, the floor plan wins and the distance
+ * is Euclidean.
+ */
+
+export type TablePoint = {
+ number: number;
+ zoneId: string | null;
+ x: number | null;
+ y: number | null;
+};
+
+/** Larger than any table-number gap, so a zone change never sorts as "near". */
+export const ZONE_HOP = 1_000_000;
+
+export function distance(a: TablePoint, b: TablePoint): number {
+ if (a.x !== null && a.y !== null && b.x !== null && b.y !== null) {
+ return Math.hypot(a.x - b.x, a.y - b.y);
+ }
+ const diff = Math.abs(a.number - b.number);
+ if (a.zoneId !== null && b.zoneId !== null && a.zoneId !== b.zoneId) {
+ return ZONE_HOP + diff;
+ }
+ return diff;
+}
diff --git a/packages/judging-core/src/events.test.ts b/packages/judging-core/src/events.test.ts
new file mode 100644
index 00000000..72a63800
--- /dev/null
+++ b/packages/judging-core/src/events.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import {
+ boardChannel,
+ isOutboxTopic,
+ judgeChannel,
+ leaderboardChannel,
+ LOG_KINDS,
+ OUTBOX_TOPICS,
+} from "./events";
+import type { LogKind } from "./events";
+
+describe("events", () => {
+ it("only queues topics the log already knows", () => {
+ for (const topic of OUTBOX_TOPICS) {
+ expect(LOG_KINDS).toContain(topic);
+ expect(isOutboxTopic(topic)).toBe(true);
+ }
+ });
+
+ it("does not queue a log row that nobody subscribes to", () => {
+ expect(isOutboxTopic("project.upserted")).toBe(false);
+ });
+
+ it("names the channels the board, the leaderboard, and a judge listen on", () => {
+ expect(boardChannel("evt")).toBe("event:evt:board");
+ expect(leaderboardChannel("evt")).toBe("event:evt:leaderboard");
+ expect(judgeChannel("j1")).toBe("judge:j1");
+ });
+
+ it("accepts every log kind as a log kind", () => {
+ const kind: LogKind = "vote.cast";
+ expect(LOG_KINDS).toContain(kind);
+ });
+});
diff --git a/packages/judging-core/src/events.ts b/packages/judging-core/src/events.ts
new file mode 100644
index 00000000..63c04d3e
--- /dev/null
+++ b/packages/judging-core/src/events.ts
@@ -0,0 +1,66 @@
+/**
+ * One vocabulary for the append-only log, the outbox, and the websocket.
+ * The server and the web both import this so a topic cannot drift.
+ */
+
+export const LOG_KINDS = [
+ "phase.changed",
+ "project.upserted",
+ "project.withdrawn",
+ "project.table_assigned",
+ "project.qr_rotated",
+ "track.saved",
+ "prize.saved",
+ "zone.saved",
+ "rubric.saved",
+ "table.placed",
+ "webhook.saved",
+ "api_key.created",
+ "config.saved",
+ "judge.upserted",
+ "judge.approved",
+ "judge.suspended",
+ "visit.handed_out",
+ "visit.arrived",
+ "visit.voided",
+ "visit.skipped",
+ "vote.cast",
+ "comparison.cast",
+ "results.computed",
+ "results.published",
+ "results.unpublished",
+ "judge.recalled",
+] as const;
+
+export type LogKind = (typeof LOG_KINDS)[number];
+
+export const OUTBOX_TOPICS = [
+ "visit.handed_out",
+ "visit.arrived",
+ "visit.voided",
+ "vote.cast",
+ "phase.changed",
+ "results.published",
+ "results.unpublished",
+ "judge.recalled",
+] as const;
+
+export type OutboxTopic = (typeof OUTBOX_TOPICS)[number];
+
+const OUTBOX: ReadonlySet = new Set(OUTBOX_TOPICS);
+
+export function isOutboxTopic(kind: LogKind): kind is OutboxTopic {
+ return OUTBOX.has(kind);
+}
+
+export function boardChannel(eventId: string): string {
+ return `event:${eventId}:board`;
+}
+
+export function leaderboardChannel(eventId: string): string {
+ return `event:${eventId}:leaderboard`;
+}
+
+export function judgeChannel(judgeId: string): string {
+ return `judge:${judgeId}`;
+}
diff --git a/packages/judging-core/src/index.ts b/packages/judging-core/src/index.ts
new file mode 100644
index 00000000..a65497e1
--- /dev/null
+++ b/packages/judging-core/src/index.ts
@@ -0,0 +1,56 @@
+export { bayesianShrink, round2 } from "./aggregate/bayes";
+export { bradleyTerry } from "./aggregate/bradleyTerry";
+export { blend } from "./aggregate/blend";
+export { rank } from "./aggregate/rank";
+export { zNormalize } from "./aggregate/zscore";
+export type {
+ ProjectInput,
+ RankComparison,
+ RankConfig,
+ RankedRow,
+ VoteInput,
+} from "./aggregate/rank";
+export type { Comparison } from "./comparison";
+export { pickNext } from "./dispatch";
+export { assignTables } from "./tables";
+export type { Assignment, Seat, SeatedProject } from "./tables";
+export type {
+ Candidate,
+ DispatchConfig,
+ DispatchStrategy,
+ JudgeSpot,
+} from "./dispatch";
+export { distance, ZONE_HOP } from "./distance";
+export type { TablePoint } from "./distance";
+export {
+ boardChannel,
+ isOutboxTopic,
+ judgeChannel,
+ leaderboardChannel,
+ LOG_KINDS,
+ OUTBOX_TOPICS,
+} from "./events";
+export type { LogKind, OutboxTopic } from "./events";
+export { projectMatchesTrack } from "./match";
+export {
+ assertPhaseAllows,
+ assertTransition,
+ canTransition,
+ phaseAllows,
+ PHASES,
+} from "./phase";
+export type { Phase, PhaseAction } from "./phase";
+export { validateScores, weightedTotal } from "./rubric";
+export type { Criterion, ScoreCheck, ScoreInput } from "./rubric";
+export {
+ DEFAULT_TIMERS,
+ isLive,
+ isOverTarget,
+ isPastCutoff,
+} from "./timers";
+export type { TimerConfig, VisitClock } from "./timers";
+export {
+ contestBonus,
+ projectUncertainty,
+ scoreUncertainty,
+} from "./uncertainty";
diff --git a/packages/judging-core/src/match.test.ts b/packages/judging-core/src/match.test.ts
new file mode 100644
index 00000000..9127ca98
--- /dev/null
+++ b/packages/judging-core/src/match.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest";
+import { projectMatchesTrack } from "./match";
+
+describe("projectMatchesTrack", () => {
+ const project = {
+ tracks: ["Finance"],
+ challenges: ["Sponsor"],
+ isCreateX: true,
+ };
+
+ it("matches every project when the judge has no track", () => {
+ expect(projectMatchesTrack(project, null)).toBe(true);
+ });
+
+ it("matches by track, challenge, and the createx flag", () => {
+ expect(projectMatchesTrack(project, "Finance")).toBe(true);
+ expect(projectMatchesTrack(project, "Sponsor")).toBe(true);
+ expect(projectMatchesTrack(project, "createX")).toBe(true);
+ expect(projectMatchesTrack(project, "Health")).toBe(false);
+ });
+});
diff --git a/packages/judging-core/src/match.ts b/packages/judging-core/src/match.ts
new file mode 100644
index 00000000..e2abcea4
--- /dev/null
+++ b/packages/judging-core/src/match.ts
@@ -0,0 +1,20 @@
+/**
+ * Label match for an import that still carries track strings on the project.
+ * The product path is a project_track row. This remains so those strings
+ * route the same way they did before tracks were rows.
+ */
+export function projectMatchesTrack(
+ project: {
+ tracks: string[] | null;
+ challenges: string[] | null;
+ isCreateX?: boolean | null;
+ },
+ track: string | null,
+): boolean {
+ if (!track) return true;
+ const inTracks = project.tracks?.includes(track) ?? false;
+ const inChallenges = project.challenges?.includes(track) ?? false;
+ const matchCreateX =
+ track.toLowerCase() === "createx" && !!project.isCreateX;
+ return inTracks || inChallenges || matchCreateX;
+}
diff --git a/packages/judging-core/src/package.test.ts b/packages/judging-core/src/package.test.ts
new file mode 100644
index 00000000..85a15172
--- /dev/null
+++ b/packages/judging-core/src/package.test.ts
@@ -0,0 +1,14 @@
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+describe("@query/judging-core package", () => {
+ it("has no runtime dependencies", () => {
+ const packageJson = join(dirname(fileURLToPath(import.meta.url)), "../package.json");
+ const pkg = JSON.parse(readFileSync(packageJson, "utf8")) as {
+ dependencies?: Record;
+ };
+ expect(pkg.dependencies ?? {}).toEqual({});
+ });
+});
diff --git a/packages/judging-core/src/phase.test.ts b/packages/judging-core/src/phase.test.ts
new file mode 100644
index 00000000..a4f885ba
--- /dev/null
+++ b/packages/judging-core/src/phase.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+import {
+ assertPhaseAllows,
+ assertTransition,
+ canTransition,
+ phaseAllows,
+} from "./phase";
+
+describe("phase", () => {
+ it("walks an event from setup through archive", () => {
+ const path = [
+ "setup",
+ "submissions_open",
+ "submissions_closed",
+ "judging_live",
+ "judging_closed",
+ "published",
+ "archived",
+ ] as const;
+ for (let index = 1; index < path.length; index += 1) {
+ const from = path[index - 1];
+ const to = path[index];
+ if (!from || !to) throw new Error("path");
+ expect(canTransition(from, to)).toBe(true);
+ }
+ });
+
+ it("allows the steps an organizer actually takes backwards", () => {
+ expect(canTransition("submissions_open", "setup")).toBe(true);
+ expect(canTransition("submissions_closed", "submissions_open")).toBe(true);
+ expect(canTransition("judging_closed", "judging_live")).toBe(true);
+ expect(canTransition("published", "judging_closed")).toBe(true);
+ });
+
+ it("refuses a jump and a move out of the archive", () => {
+ expect(canTransition("setup", "judging_live")).toBe(false);
+ expect(canTransition("archived", "setup")).toBe(false);
+ expect(() => assertTransition("setup", "published")).toThrow(/setup/);
+ });
+
+ it("permits a vote only while judging is live", () => {
+ expect(phaseAllows("judging_live", "vote")).toBe(true);
+ expect(phaseAllows("judging_closed", "vote")).toBe(false);
+ expect(phaseAllows("submissions_open", "vote")).toBe(false);
+ expect(() => assertPhaseAllows("setup", "vote")).toThrow(/vote/);
+ });
+
+ it("permits dispatch only while judging is live, and publish only after it closes", () => {
+ expect(phaseAllows("judging_live", "dispatch")).toBe(true);
+ expect(phaseAllows("judging_closed", "dispatch")).toBe(false);
+ expect(phaseAllows("judging_closed", "publish")).toBe(true);
+ expect(phaseAllows("judging_live", "publish")).toBe(false);
+ expect(phaseAllows("judging_live", "compute")).toBe(true);
+ expect(phaseAllows("submissions_open", "submit")).toBe(true);
+ expect(phaseAllows("judging_live", "submit")).toBe(false);
+ expect(phaseAllows("setup", "apply")).toBe(true);
+ expect(phaseAllows("judging_live", "apply")).toBe(false);
+ });
+});
diff --git a/packages/judging-core/src/phase.ts b/packages/judging-core/src/phase.ts
new file mode 100644
index 00000000..7a947a65
--- /dev/null
+++ b/packages/judging-core/src/phase.ts
@@ -0,0 +1,63 @@
+/**
+ * What an event is allowed to do, and which phase it may move to next.
+ * The server checks this on every mutation. The core does not persist it.
+ */
+
+export const PHASES = [
+ "setup",
+ "submissions_open",
+ "submissions_closed",
+ "judging_live",
+ "judging_closed",
+ "published",
+ "archived",
+] as const;
+
+export type Phase = (typeof PHASES)[number];
+
+export type PhaseAction =
+ | "submit"
+ | "apply"
+ | "dispatch"
+ | "vote"
+ | "compute"
+ | "publish";
+
+const TRANSITIONS: Record = {
+ setup: ["submissions_open"],
+ submissions_open: ["submissions_closed", "setup"],
+ submissions_closed: ["judging_live", "submissions_open"],
+ judging_live: ["judging_closed"],
+ judging_closed: ["published", "judging_live"],
+ published: ["archived", "judging_closed"],
+ archived: [],
+};
+
+const PERMISSIONS: Record = {
+ submit: ["submissions_open"],
+ apply: ["setup", "submissions_open", "submissions_closed"],
+ dispatch: ["judging_live"],
+ vote: ["judging_live"],
+ compute: ["judging_live", "judging_closed"],
+ publish: ["judging_closed"],
+};
+
+export function canTransition(from: Phase, to: Phase): boolean {
+ return TRANSITIONS[from].includes(to);
+}
+
+export function assertTransition(from: Phase, to: Phase): void {
+ if (!canTransition(from, to)) {
+ throw new Error(`Cannot move an event from ${from} to ${to}`);
+ }
+}
+
+export function phaseAllows(phase: Phase, action: PhaseAction): boolean {
+ return PERMISSIONS[action].includes(phase);
+}
+
+export function assertPhaseAllows(phase: Phase, action: PhaseAction): void {
+ if (!phaseAllows(phase, action)) {
+ throw new Error(`${action} is not allowed while the event is ${phase}`);
+ }
+}
diff --git a/packages/judging-core/src/rubric.test.ts b/packages/judging-core/src/rubric.test.ts
new file mode 100644
index 00000000..589db6f1
--- /dev/null
+++ b/packages/judging-core/src/rubric.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+import { validateScores, weightedTotal } from "./rubric";
+import type { Criterion } from "./rubric";
+
+const criteria: Criterion[] = [
+ { id: "creativity", min: 0, max: 10, weight: 1 },
+ { id: "impact", min: 0, max: 10, weight: 3 },
+];
+
+describe("validateScores", () => {
+ it("accepts one in-range value per criterion", () => {
+ expect(
+ validateScores(criteria, [
+ { criterionId: "creativity", value: 0 },
+ { criterionId: "impact", value: 10 },
+ ]),
+ ).toEqual({ ok: true });
+ });
+
+ it("rejects a missing, duplicate, unknown, or out-of-range score", () => {
+ expect(validateScores(criteria, []).ok).toBe(false);
+ expect(
+ validateScores(criteria, [
+ { criterionId: "creativity", value: 1 },
+ { criterionId: "creativity", value: 2 },
+ { criterionId: "impact", value: 3 },
+ ]).ok,
+ ).toBe(false);
+ expect(
+ validateScores(criteria, [
+ { criterionId: "creativity", value: 1 },
+ { criterionId: "nope", value: 1 },
+ ]).ok,
+ ).toBe(false);
+ expect(
+ validateScores(criteria, [
+ { criterionId: "creativity", value: 11 },
+ { criterionId: "impact", value: 1 },
+ ]).ok,
+ ).toBe(false);
+ });
+});
+
+describe("weightedTotal", () => {
+ it("is the weighted mean", () => {
+ expect(
+ weightedTotal(criteria, [
+ { criterionId: "creativity", value: 4 },
+ { criterionId: "impact", value: 8 },
+ ]),
+ ).toBe(7);
+ });
+
+ it("is zero when every weight is zero", () => {
+ const flat = criteria.map((criterion) => ({ ...criterion, weight: 0 }));
+ expect(
+ weightedTotal(flat, [
+ { criterionId: "creativity", value: 4 },
+ { criterionId: "impact", value: 8 },
+ ]),
+ ).toBe(0);
+ });
+
+ it("throws when the scores are not valid", () => {
+ expect(() => weightedTotal(criteria, [])).toThrow(/missing/);
+ });
+});
diff --git a/packages/judging-core/src/rubric.ts b/packages/judging-core/src/rubric.ts
new file mode 100644
index 00000000..14292b8d
--- /dev/null
+++ b/packages/judging-core/src/rubric.ts
@@ -0,0 +1,85 @@
+/**
+ * A score is one number per criterion, inside that criterion's range.
+ * The total is the weighted mean, so it stays on the same scale as the
+ * criteria. The rows are the truth; the total is derived.
+ */
+
+export type Criterion = {
+ id: string;
+ min: number;
+ max: number;
+ weight: number;
+};
+
+export type ScoreInput = {
+ criterionId: string;
+ value: number;
+};
+
+export type ScoreCheck = { ok: true } | { ok: false; reason: string };
+
+export function validateScores(
+ criteria: readonly Criterion[],
+ input: readonly ScoreInput[],
+): ScoreCheck {
+ for (const criterion of criteria) {
+ if (!Number.isFinite(criterion.weight) || criterion.weight < 0) {
+ return {
+ ok: false,
+ reason: `weight for ${criterion.id} must be zero or more`,
+ };
+ }
+ if (
+ !Number.isFinite(criterion.min) ||
+ !Number.isFinite(criterion.max) ||
+ criterion.min > criterion.max
+ ) {
+ return { ok: false, reason: `range for ${criterion.id} is invalid` };
+ }
+ }
+
+ const seen = new Set();
+ for (const score of input) {
+ if (seen.has(score.criterionId)) {
+ return { ok: false, reason: `duplicate score for ${score.criterionId}` };
+ }
+ seen.add(score.criterionId);
+ const criterion = criteria.find((item) => item.id === score.criterionId);
+ if (!criterion) {
+ return { ok: false, reason: `unknown criterion ${score.criterionId}` };
+ }
+ if (
+ !Number.isFinite(score.value) ||
+ score.value < criterion.min ||
+ score.value > criterion.max
+ ) {
+ return {
+ ok: false,
+ reason: `${score.criterionId} must be between ${criterion.min} and ${criterion.max}`,
+ };
+ }
+ }
+
+ for (const criterion of criteria) {
+ if (!seen.has(criterion.id)) {
+ return { ok: false, reason: `missing score for ${criterion.id}` };
+ }
+ }
+
+ return { ok: true };
+}
+
+export function weightedTotal(
+ criteria: readonly Criterion[],
+ input: readonly ScoreInput[],
+): number {
+ const check = validateScores(criteria, input);
+ if (!check.ok) throw new Error(check.reason);
+ const weight = criteria.reduce((sum, criterion) => sum + criterion.weight, 0);
+ if (weight === 0) return 0;
+ const byId = new Map(input.map((score) => [score.criterionId, score.value]));
+ const sum = criteria.reduce((total, criterion) => {
+ return total + (byId.get(criterion.id) ?? 0) * criterion.weight;
+ }, 0);
+ return sum / weight;
+}
diff --git a/packages/judging-core/src/tables.test.ts b/packages/judging-core/src/tables.test.ts
new file mode 100644
index 00000000..b02827c8
--- /dev/null
+++ b/packages/judging-core/src/tables.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { assignTables } from "./tables";
+
+describe("assignTables", () => {
+ it("alternates tracks across table numbers", () => {
+ const seated = assignTables(
+ [
+ { id: "a1", trackId: "sponsor" },
+ { id: "a2", trackId: "sponsor" },
+ { id: "b1", trackId: "general" },
+ { id: "b2", trackId: "general" },
+ ],
+ [
+ { number: 1, zoneId: "hall" },
+ { number: 2, zoneId: "hall" },
+ { number: 3, zoneId: "hall" },
+ { number: 4, zoneId: "hall" },
+ ],
+ );
+ expect(seated.map((row) => row.projectId)).toEqual(["a1", "b1", "a2", "b2"]);
+ expect(seated.map((row) => row.tableNumber)).toEqual([1, 2, 3, 4]);
+ });
+
+ it("leaves extra projects unseated when tables run out", () => {
+ const seated = assignTables(
+ [
+ { id: "a", trackId: null },
+ { id: "b", trackId: null },
+ ],
+ [{ number: 9, zoneId: null }],
+ );
+ expect(seated).toEqual([{ projectId: "a", tableNumber: 9, zoneId: null }]);
+ });
+});
diff --git a/packages/judging-core/src/tables.ts b/packages/judging-core/src/tables.ts
new file mode 100644
index 00000000..af287f1d
--- /dev/null
+++ b/packages/judging-core/src/tables.ts
@@ -0,0 +1,52 @@
+export type Seat = {
+ number: number;
+ zoneId: string | null;
+};
+
+export type SeatedProject = {
+ id: string;
+ trackId: string | null;
+};
+
+export type Assignment = {
+ projectId: string;
+ tableNumber: number;
+ zoneId: string | null;
+};
+
+/**
+ * Seats projects so tracks take turns. Two sponsor projects do not land on
+ * neighbouring tables while another track is still waiting for a seat.
+ */
+export function assignTables(
+ projects: readonly SeatedProject[],
+ tables: readonly Seat[],
+): Assignment[] {
+ const queues = new Map();
+ for (const project of projects) {
+ const key = project.trackId ?? "";
+ const queue = queues.get(key) ?? [];
+ queue.push(project);
+ queues.set(key, queue);
+ }
+ const lanes = [...queues.values()];
+ const ordered: SeatedProject[] = [];
+ while (ordered.length < projects.length) {
+ for (const lane of lanes) {
+ const next = lane.shift();
+ if (next) ordered.push(next);
+ }
+ }
+ const seats = [...tables].sort((a, b) => a.number - b.number);
+ return ordered.slice(0, seats.length).flatMap((project, index) => {
+ const seat = seats[index];
+ if (!seat) return [];
+ return [
+ {
+ projectId: project.id,
+ tableNumber: seat.number,
+ zoneId: seat.zoneId,
+ },
+ ];
+ });
+}
diff --git a/packages/judging-core/src/timers.test.ts b/packages/judging-core/src/timers.test.ts
new file mode 100644
index 00000000..24c3a19d
--- /dev/null
+++ b/packages/judging-core/src/timers.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_TIMERS, isLive, isOverTarget, isPastCutoff } from "./timers";
+
+const t0 = new Date("2027-02-28T13:00:00Z");
+const at = (seconds: number) => new Date(t0.getTime() + seconds * 1000);
+const timers = DEFAULT_TIMERS;
+
+describe("isLive", () => {
+ const walking = {
+ handedOutAt: t0,
+ arrivedAt: null,
+ completedAt: null,
+ };
+ const atTable = {
+ handedOutAt: t0,
+ arrivedAt: at(60),
+ completedAt: null,
+ };
+
+ it("holds a table for the walk limit after hand-out", () => {
+ expect(isLive(walking, at(timers.walkLimitSeconds), timers)).toBe(true);
+ expect(isLive(walking, at(timers.walkLimitSeconds + 1), timers)).toBe(
+ false,
+ );
+ });
+
+ it("holds it through the hard limit and grace after arrival", () => {
+ const end =
+ 60 + timers.hardLimitSeconds + timers.submitGraceSeconds;
+ expect(isLive(atTable, at(end), timers)).toBe(true);
+ expect(isLive(atTable, at(end + 1), timers)).toBe(false);
+ });
+
+ it("measures from arrival, not hand-out, once the judge has tapped in", () => {
+ expect(isLive(atTable, at(timers.walkLimitSeconds + 30), timers)).toBe(
+ true,
+ );
+ });
+
+ it("is never live once completed, or when never handed out", () => {
+ expect(
+ isLive({ ...atTable, completedAt: at(61) }, at(61), timers),
+ ).toBe(false);
+ expect(
+ isLive(
+ { handedOutAt: null, arrivedAt: null, completedAt: null },
+ t0,
+ timers,
+ ),
+ ).toBe(false);
+ });
+
+ it("honours a shorter limit from config", () => {
+ const short = { ...timers, walkLimitSeconds: 30 };
+ expect(isLive(walking, at(30), short)).toBe(true);
+ expect(isLive(walking, at(31), short)).toBe(false);
+ });
+});
+
+describe("isPastCutoff", () => {
+ it("allows a score at the hard limit and within the grace after it", () => {
+ expect(isPastCutoff(t0, at(timers.hardLimitSeconds), timers)).toBe(false);
+ expect(
+ isPastCutoff(
+ t0,
+ at(timers.hardLimitSeconds + timers.submitGraceSeconds),
+ timers,
+ ),
+ ).toBe(false);
+ });
+
+ it("refuses one after the grace", () => {
+ expect(
+ isPastCutoff(
+ t0,
+ at(timers.hardLimitSeconds + timers.submitGraceSeconds + 1),
+ timers,
+ ),
+ ).toBe(true);
+ });
+});
+
+describe("isOverTarget", () => {
+ it("turns over at the target and not before", () => {
+ expect(isOverTarget(t0, at(timers.targetSeconds), timers)).toBe(false);
+ expect(isOverTarget(t0, at(timers.targetSeconds + 1), timers)).toBe(true);
+ });
+});
diff --git a/packages/judging-core/src/timers.ts b/packages/judging-core/src/timers.ts
new file mode 100644
index 00000000..d14bdd5b
--- /dev/null
+++ b/packages/judging-core/src/timers.ts
@@ -0,0 +1,74 @@
+/**
+ * Visit clocks. Every function takes `now` so a replay of an event does not
+ * depend on when the code happens to run.
+ *
+ * The defaults match the timings the first event used: three minutes at a
+ * table, four minutes hard, four minutes to walk, fifteen seconds of grace
+ * for a submit that left the phone on the buzzer.
+ */
+
+export type TimerConfig = {
+ targetSeconds: number;
+ hardLimitSeconds: number;
+ walkLimitSeconds: number;
+ submitGraceSeconds: number;
+};
+
+export const DEFAULT_TIMERS: TimerConfig = {
+ targetSeconds: 180,
+ hardLimitSeconds: 240,
+ walkLimitSeconds: 240,
+ submitGraceSeconds: 15,
+};
+
+export type VisitClock = {
+ handedOutAt: Date | null;
+ arrivedAt: Date | null;
+ completedAt: Date | null;
+};
+
+/**
+ * Whether a visit still holds its table. Walking: until the walk limit after
+ * hand-out. At the table: until the hard limit (plus grace) after arrival.
+ */
+export function isLive(
+ slot: VisitClock,
+ now: Date,
+ config: TimerConfig,
+): boolean {
+ if (slot.completedAt) return false;
+ const t = now.getTime();
+ if (slot.arrivedAt) {
+ return (
+ t <=
+ slot.arrivedAt.getTime() +
+ (config.hardLimitSeconds + config.submitGraceSeconds) * 1000
+ );
+ }
+ if (slot.handedOutAt) {
+ return t <= slot.handedOutAt.getTime() + config.walkLimitSeconds * 1000;
+ }
+ return false;
+}
+
+/** Past the hard cutoff, with grace: a score now arrives too late to count. */
+export function isPastCutoff(
+ arrivedAt: Date,
+ now: Date,
+ config: TimerConfig,
+): boolean {
+ return (
+ now.getTime() >
+ arrivedAt.getTime() +
+ (config.hardLimitSeconds + config.submitGraceSeconds) * 1000
+ );
+}
+
+/** The judge page turns amber here. Measured from arrival, not hand-out. */
+export function isOverTarget(
+ arrivedAt: Date,
+ now: Date,
+ config: TimerConfig,
+): boolean {
+ return now.getTime() > arrivedAt.getTime() + config.targetSeconds * 1000;
+}
diff --git a/packages/judging-core/src/uncertainty.test.ts b/packages/judging-core/src/uncertainty.test.ts
new file mode 100644
index 00000000..d97fc34e
--- /dev/null
+++ b/packages/judging-core/src/uncertainty.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import {
+ contestBonus,
+ projectUncertainty,
+ scoreUncertainty,
+} from "./uncertainty";
+
+describe("scoreUncertainty", () => {
+ it("is infinite when nobody has scored the project", () => {
+ expect(scoreUncertainty([])).toBe(Number.POSITIVE_INFINITY);
+ });
+
+ it("is zero when every score agrees", () => {
+ expect(scoreUncertainty([5, 5, 5])).toBe(0);
+ });
+
+ it("shrinks as the same spread is seen more times", () => {
+ const small = scoreUncertainty([0, 10]);
+ const large = scoreUncertainty([0, 10, 0, 10]);
+ expect(large).toBeLessThan(small);
+ });
+});
+
+describe("contestBonus", () => {
+ it("is zero when one project always wins", () => {
+ expect(
+ contestBonus(
+ [
+ { a: "a", b: "b", outcome: "a" },
+ { a: "a", b: "b", outcome: "a" },
+ ],
+ "a",
+ ),
+ ).toBe(0);
+ });
+
+ it("is one when the two projects split", () => {
+ expect(
+ contestBonus(
+ [
+ { a: "a", b: "b", outcome: "a" },
+ { a: "a", b: "b", outcome: "b" },
+ ],
+ "a",
+ ),
+ ).toBe(1);
+ });
+
+ it("counts a tie as contested", () => {
+ expect(contestBonus([{ a: "a", b: "b", outcome: "tie" }], "a")).toBe(1);
+ });
+});
+
+describe("projectUncertainty", () => {
+ it("adds the contest bonus on top of the score spread", () => {
+ const scores = [0, 10];
+ const bonus = contestBonus([{ a: "a", b: "b", outcome: "tie" }], "a");
+ expect(
+ projectUncertainty(scores, [{ a: "a", b: "b", outcome: "tie" }], "a"),
+ ).toBeCloseTo(scoreUncertainty(scores) + bonus, 10);
+ });
+
+ it("stays infinite when there are no scores yet", () => {
+ expect(projectUncertainty([], [], "a")).toBe(Number.POSITIVE_INFINITY);
+ });
+});
diff --git a/packages/judging-core/src/uncertainty.ts b/packages/judging-core/src/uncertainty.ts
new file mode 100644
index 00000000..07a5b4b2
--- /dev/null
+++ b/packages/judging-core/src/uncertainty.ts
@@ -0,0 +1,66 @@
+import type { Comparison } from "./comparison";
+
+/**
+ * How unsettled a project's rank is.
+ * Score spread shrinks as more judges agree and as the sample grows.
+ * A pairwise neighbour that is split (or tied) adds a bonus, because those
+ * two projects are the ones another look can still reorder.
+ */
+
+export function scoreUncertainty(scores: readonly number[]): number {
+ const n = scores.length;
+ if (n === 0) return Number.POSITIVE_INFINITY;
+ const mean = scores.reduce((sum, value) => sum + value, 0) / n;
+ const variance =
+ scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / n;
+ return variance / Math.sqrt(n);
+}
+
+export function contestBonus(
+ comparisons: readonly Comparison[],
+ projectId: string,
+): number {
+ const against = new Map<
+ string,
+ { wins: number; losses: number; ties: number }
+ >();
+
+ for (const comparison of comparisons) {
+ const opponent =
+ comparison.a === projectId
+ ? comparison.b
+ : comparison.b === projectId
+ ? comparison.a
+ : null;
+ if (!opponent) continue;
+ const row = against.get(opponent) ?? { wins: 0, losses: 0, ties: 0 };
+ if (comparison.outcome === "tie") row.ties += 1;
+ else if (
+ (comparison.outcome === "a" && comparison.a === projectId) ||
+ (comparison.outcome === "b" && comparison.b === projectId)
+ ) {
+ row.wins += 1;
+ } else {
+ row.losses += 1;
+ }
+ against.set(opponent, row);
+ }
+
+ let bonus = 0;
+ for (const row of against.values()) {
+ const n = row.wins + row.losses + row.ties;
+ if (n === 0) continue;
+ bonus += (n - Math.abs(row.wins - row.losses)) / n;
+ }
+ return bonus;
+}
+
+export function projectUncertainty(
+ scores: readonly number[],
+ comparisons: readonly Comparison[],
+ projectId: string,
+): number {
+ const spread = scoreUncertainty(scores);
+ if (!Number.isFinite(spread)) return spread;
+ return spread + contestBonus(comparisons, projectId);
+}
diff --git a/packages/judging-core/tsconfig.build.json b/packages/judging-core/tsconfig.build.json
new file mode 100644
index 00000000..2da00172
--- /dev/null
+++ b/packages/judging-core/tsconfig.build.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "declaration": true,
+ "sourceMap": true,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["src/**/*.test.ts", "node_modules", "dist"]
+}
diff --git a/packages/judging-core/tsconfig.json b/packages/judging-core/tsconfig.json
new file mode 100644
index 00000000..f54bde2c
--- /dev/null
+++ b/packages/judging-core/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tooling/typescript/base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/judging-core/vitest.config.ts b/packages/judging-core/vitest.config.ts
new file mode 100644
index 00000000..ae847ff6
--- /dev/null
+++ b/packages/judging-core/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["src/**/*.test.ts"],
+ },
+});
diff --git a/packages/judging-db/eslint.config.mjs b/packages/judging-db/eslint.config.mjs
new file mode 100644
index 00000000..780d26b6
--- /dev/null
+++ b/packages/judging-db/eslint.config.mjs
@@ -0,0 +1,4 @@
+import { config } from "@query/eslint-config/base";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [...config];
diff --git a/packages/judging-db/migrations/0001_init.sql b/packages/judging-db/migrations/0001_init.sql
new file mode 100644
index 00000000..2037f72f
--- /dev/null
+++ b/packages/judging-db/migrations/0001_init.sql
@@ -0,0 +1,309 @@
+create extension if not exists pgcrypto;
+
+create type event_phase as enum (
+ 'setup',
+ 'submissions_open',
+ 'submissions_closed',
+ 'judging_live',
+ 'judging_closed',
+ 'published',
+ 'archived'
+);
+
+create type track_kind as enum ('main', 'sponsor', 'special');
+
+create type dispatch_strategy as enum ('coverage', 'uncertainty');
+
+create type judge_status as enum ('invited', 'applied', 'approved', 'suspended');
+
+create type comparison_outcome as enum ('a', 'b', 'tie');
+
+create type membership_role as enum ('owner', 'admin', 'organizer', 'volunteer');
+
+create table organization (
+ id uuid primary key default gen_random_uuid(),
+ slug text not null unique,
+ name text not null,
+ branding jsonb not null default '{}'::jsonb
+);
+
+create table panel_user (
+ id uuid primary key default gen_random_uuid(),
+ email text not null unique,
+ name text not null
+);
+
+create table panel_event (
+ id uuid primary key default gen_random_uuid(),
+ org_id uuid not null references organization (id) on delete cascade,
+ slug text not null,
+ name text not null,
+ starts_at timestamptz,
+ ends_at timestamptz,
+ phase event_phase not null default 'setup',
+ unique (org_id, slug)
+);
+
+create table event_config (
+ event_id uuid primary key references panel_event (id) on delete cascade,
+ target_seconds integer not null,
+ hard_limit_seconds integer not null,
+ walk_limit_seconds integer not null,
+ submit_grace_seconds integer not null,
+ min_looks_per_project integer not null,
+ dispatch_strategy dispatch_strategy not null,
+ pairwise_enabled boolean not null default false,
+ pairwise_weight numeric not null default 0,
+ bayesian_c numeric not null default 2,
+ calibration_looks integer not null default 0,
+ calibration_weight numeric not null default 1,
+ feedback_cards_enabled boolean not null default false,
+ leaderboard_public boolean not null default false,
+ board_poll_seconds integer not null default 5
+);
+
+create table rubric (
+ id uuid primary key default gen_random_uuid(),
+ event_id uuid not null references panel_event (id) on delete cascade,
+ name text not null,
+ is_default boolean not null default false
+);
+
+create table criterion (
+ id uuid primary key default gen_random_uuid(),
+ rubric_id uuid not null references rubric (id) on delete cascade,
+ position integer not null,
+ key text not null,
+ label text not null,
+ description text,
+ min numeric not null,
+ max numeric not null,
+ weight numeric not null,
+ anchors jsonb not null default '[]'::jsonb,
+ unique (rubric_id, key)
+);
+
+create table track (
+ id uuid primary key default gen_random_uuid(),
+ event_id uuid not null references panel_event (id) on delete cascade,
+ slug text not null,
+ name text not null,
+ kind track_kind not null,
+ judge_group text not null,
+ rubric_id uuid references rubric (id),
+ position integer not null default 0,
+ is_active boolean not null default true,
+ unique (event_id, slug)
+);
+
+create table prize (
+ id uuid primary key default gen_random_uuid(),
+ track_id uuid not null references track (id) on delete cascade,
+ place integer not null,
+ title text not null,
+ amount numeric,
+ description text,
+ unique (track_id, place)
+);
+
+create table zone (
+ id uuid primary key default gen_random_uuid(),
+ event_id uuid not null references panel_event (id) on delete cascade,
+ name text not null,
+ position integer not null default 0
+);
+
+-- `table` is reserved in SQL, so the floor plan lives in floor_table.
+create table floor_table (
+ event_id uuid not null references panel_event (id) on delete cascade,
+ number integer not null,
+ zone_id uuid references zone (id),
+ x numeric,
+ y numeric,
+ primary key (event_id, number)
+);
+
+create table project (
+ id uuid primary key default gen_random_uuid(),
+ event_id uuid not null references panel_event (id) on delete cascade,
+ external_id text,
+ name text not null,
+ description text,
+ team_name text,
+ members jsonb not null default '[]'::jsonb,
+ links jsonb not null default '{}'::jsonb,
+ table_number integer,
+ zone_id uuid references zone (id),
+ qr_token uuid not null unique default gen_random_uuid(),
+ arrived_first_at timestamptz,
+ withdrawn_at timestamptz
+);
+
+create unique index project_event_external_idx
+ on project (event_id, external_id)
+ where external_id is not null;
+
+create table project_track (
+ project_id uuid not null references project (id) on delete cascade,
+ track_id uuid not null references track (id) on delete cascade,
+ primary key (project_id, track_id)
+);
+
+create table panel_judge (
+ id uuid primary key default gen_random_uuid(),
+ event_id uuid not null references panel_event (id) on delete cascade,
+ external_id text,
+ name text not null,
+ email text not null,
+ org text,
+ title text,
+ status judge_status not null default 'invited',
+ track_id uuid references track (id),
+ zone_id uuid references zone (id),
+ is_lead boolean not null default false,
+ unique (event_id, email)
+);
+
+create unique index judge_event_external_idx
+ on panel_judge (event_id, external_id)
+ where external_id is not null;
+
+create table visit (
+ id uuid primary key default gen_random_uuid(),
+ event_id uuid not null references panel_event (id) on delete cascade,
+ judge_id uuid not null references panel_judge (id) on delete cascade,
+ project_id uuid not null references project (id) on delete cascade,
+ judge_group text not null,
+ handed_out_at timestamptz not null,
+ arrived_at timestamptz,
+ completed_at timestamptz,
+ voided_at timestamptz,
+ void_reason text
+);
+
+-- A completed look still counts: the same judge is not handed that project again.
+create unique index visit_judge_project_open_idx
+ on visit (judge_id, project_id)
+ where voided_at is null;
+
+-- One table in hand at a time. The dispatch lock is the first guard; this is the second.
+create unique index visit_one_open_idx
+ on visit (judge_id)
+ where voided_at is null and completed_at is null;
+
+create table vote (
+ id uuid primary key default gen_random_uuid(),
+ visit_id uuid not null unique references visit (id) on delete cascade,
+ judge_id uuid not null references panel_judge (id) on delete cascade,
+ project_id uuid not null references project (id) on delete cascade,
+ event_id uuid not null references panel_event (id) on delete cascade,
+ total numeric not null,
+ comment text,
+ duration_seconds integer,
+ is_calibration boolean not null default false
+);
+
+create table vote_score (
+ vote_id uuid not null references vote (id) on delete cascade,
+ criterion_id uuid not null references criterion (id),
+ value numeric not null,
+ primary key (vote_id, criterion_id)
+);
+
+create table comparison (
+ id uuid primary key default gen_random_uuid(),
+ event_id uuid not null references panel_event (id) on delete cascade,
+ judge_id uuid not null references panel_judge (id) on delete cascade,
+ judge_group text not null,
+ a_project_id uuid not null references project (id),
+ b_project_id uuid not null references project (id),
+ outcome comparison_outcome not null,
+ created_at timestamptz not null default now()
+);
+
+create table result_run (
+ id uuid primary key default gen_random_uuid(),
+ event_id uuid not null references panel_event (id) on delete cascade,
+ computed_at timestamptz not null default now(),
+ computed_by uuid references panel_user (id),
+ config_snapshot jsonb not null,
+ published_at timestamptz,
+ notes text
+);
+
+create table result (
+ run_id uuid not null references result_run (id) on delete cascade,
+ project_id uuid not null references project (id) on delete cascade,
+ track_id uuid not null references track (id) on delete cascade,
+ placement integer,
+ score numeric not null,
+ rubric_component numeric not null,
+ pairwise_component numeric not null,
+ vote_count integer not null,
+ comparison_count integer not null,
+ flags text[] not null default '{}',
+ primary key (run_id, project_id, track_id)
+);
+
+create table event_log (
+ id uuid primary key default gen_random_uuid(),
+ event_id uuid not null references panel_event (id) on delete cascade,
+ kind text not null,
+ actor jsonb not null default '{}'::jsonb,
+ subject jsonb not null default '{}'::jsonb,
+ payload jsonb not null default '{}'::jsonb,
+ created_at timestamptz not null default now()
+);
+
+create table outbox (
+ id uuid primary key default gen_random_uuid(),
+ event_id uuid not null references panel_event (id) on delete cascade,
+ topic text not null,
+ payload jsonb not null,
+ created_at timestamptz not null default now(),
+ delivered_at timestamptz,
+ attempts integer not null default 0
+);
+
+create table webhook (
+ id uuid primary key default gen_random_uuid(),
+ org_id uuid not null references organization (id) on delete cascade,
+ url text not null,
+ secret text not null,
+ topics text[] not null,
+ is_active boolean not null default true
+);
+
+create table api_key (
+ id uuid primary key default gen_random_uuid(),
+ org_id uuid not null references organization (id) on delete cascade,
+ hashed_key text not null,
+ scopes text[] not null,
+ created_at timestamptz not null default now(),
+ revoked_at timestamptz
+);
+
+create table membership (
+ user_id uuid not null references panel_user (id) on delete cascade,
+ org_id uuid not null references organization (id) on delete cascade,
+ role membership_role not null,
+ primary key (user_id, org_id)
+);
+
+create table judge_identity (
+ judge_id uuid primary key references panel_judge (id) on delete cascade,
+ user_id uuid not null references panel_user (id) on delete cascade
+);
+
+create table login_code (
+ id uuid primary key default gen_random_uuid(),
+ email text not null,
+ code_hash text not null,
+ expires_at timestamptz not null,
+ consumed_at timestamptz
+);
+
+create index event_log_event_idx on event_log (event_id, created_at);
+create index outbox_pending_idx on outbox (created_at) where delivered_at is null;
+create index visit_event_idx on visit (event_id);
+create index project_event_idx on project (event_id);
diff --git a/packages/judging-db/migrations/0002_feedback_token.sql b/packages/judging-db/migrations/0002_feedback_token.sql
new file mode 100644
index 00000000..1fc17747
--- /dev/null
+++ b/packages/judging-db/migrations/0002_feedback_token.sql
@@ -0,0 +1 @@
+alter table project add column feedback_token uuid unique;
diff --git a/packages/judging-db/migrations/0003_shared_database.sql b/packages/judging-db/migrations/0003_shared_database.sql
new file mode 100644
index 00000000..6a29b730
--- /dev/null
+++ b/packages/judging-db/migrations/0003_shared_database.sql
@@ -0,0 +1,37 @@
+-- Panel shares the club Postgres database. user, event, and judge already
+-- belong to that app, so panel's copies use a prefix. Rename only when the
+-- table is the panel one (uuid user id, event.org_id, judge.event_id).
+
+do $$
+begin
+ if exists (
+ select 1
+ from information_schema.columns
+ where table_schema = 'public'
+ and table_name = 'user'
+ and column_name = 'id'
+ and data_type = 'uuid'
+ ) and to_regclass('public.panel_user') is null then
+ alter table "user" rename to panel_user;
+ end if;
+
+ if exists (
+ select 1
+ from information_schema.columns
+ where table_schema = 'public'
+ and table_name = 'event'
+ and column_name = 'org_id'
+ ) and to_regclass('public.panel_event') is null then
+ alter table event rename to panel_event;
+ end if;
+
+ if exists (
+ select 1
+ from information_schema.columns
+ where table_schema = 'public'
+ and table_name = 'judge'
+ and column_name = 'event_id'
+ ) and to_regclass('public.panel_judge') is null then
+ alter table judge rename to panel_judge;
+ end if;
+end $$;
diff --git a/packages/judging-db/package.json b/packages/judging-db/package.json
new file mode 100644
index 00000000..87a7eee0
--- /dev/null
+++ b/packages/judging-db/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@query/judging-db",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "lint": "eslint . --max-warnings 0",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@query/judging-core": "workspace:*",
+ "drizzle-orm": "0.45.3",
+ "pg": "8.23.0"
+ },
+ "devDependencies": {
+ "@query/eslint-config": "workspace:*",
+ "@query/tsconfig": "workspace:*",
+ "@types/node": "22.20.4",
+ "@types/pg": "^8.23.1",
+ "typescript": "7.0.2"
+ }
+}
diff --git a/packages/judging-db/src/client.ts b/packages/judging-db/src/client.ts
new file mode 100644
index 00000000..abb74e26
--- /dev/null
+++ b/packages/judging-db/src/client.ts
@@ -0,0 +1,21 @@
+import { drizzle } from "drizzle-orm/node-postgres";
+import { Pool } from "pg";
+import * as schema from "./schema";
+
+export function createDb(url: string) {
+ const pool = new Pool({ connectionString: url, max: 10 });
+ return createDbFromPool(pool);
+}
+
+/** Judging tables on a pool the host already owns, so one app keeps one set of connections. */
+export function createDbFromPool(pool: Pool) {
+ pool.on("error", () => {
+ // The pool drops a dead client on its own. An unhandled "error" event
+ // would take the process down.
+ });
+ const db = drizzle(pool, { schema });
+ return { db, pool };
+}
+
+export type PanelDb = ReturnType["db"];
+export type PanelTx = Parameters[0]>[0];
diff --git a/packages/judging-db/src/enums.ts b/packages/judging-db/src/enums.ts
new file mode 100644
index 00000000..e7bfb4a1
--- /dev/null
+++ b/packages/judging-db/src/enums.ts
@@ -0,0 +1,39 @@
+import { pgEnum } from "drizzle-orm/pg-core";
+
+// Kept apart from the tables because packages/db's drizzle config loads this
+// file too. Judging creates these types with its own SQL migrations; push
+// must see them as declared, or it tries to drop them from the shared
+// database. Its tables stay hidden from push (tablesFilter there).
+
+export const eventPhase = pgEnum("event_phase", [
+ "setup",
+ "submissions_open",
+ "submissions_closed",
+ "judging_live",
+ "judging_closed",
+ "published",
+ "archived",
+]);
+
+export const trackKind = pgEnum("track_kind", ["main", "sponsor", "special"]);
+
+export const dispatchStrategy = pgEnum("dispatch_strategy", [
+ "coverage",
+ "uncertainty",
+]);
+
+export const judgeStatus = pgEnum("judge_status", [
+ "invited",
+ "applied",
+ "approved",
+ "suspended",
+]);
+
+export const comparisonOutcome = pgEnum("comparison_outcome", ["a", "b", "tie"]);
+
+export const membershipRole = pgEnum("membership_role", [
+ "owner",
+ "admin",
+ "organizer",
+ "volunteer",
+]);
diff --git a/packages/judging-db/src/env.ts b/packages/judging-db/src/env.ts
new file mode 100644
index 00000000..8966aa59
--- /dev/null
+++ b/packages/judging-db/src/env.ts
@@ -0,0 +1,11 @@
+export function databaseUrl(): string | null {
+ return process.env.DATABASE_URL ?? process.env.PANEL_DATABASE_URL ?? null;
+}
+
+export function requireDatabaseUrl(): string {
+ const url = databaseUrl();
+ if (!url) {
+ throw new Error("Set DATABASE_URL to the Postgres database");
+ }
+ return url;
+}
diff --git a/packages/judging-db/src/index.ts b/packages/judging-db/src/index.ts
new file mode 100644
index 00000000..93619fb3
--- /dev/null
+++ b/packages/judging-db/src/index.ts
@@ -0,0 +1,6 @@
+export { createDb, createDbFromPool } from "./client";
+export type { PanelDb, PanelTx } from "./client";
+export { databaseUrl, requireDatabaseUrl } from "./env";
+export { appliedMigrations, migrate, migrationFiles } from "./migrate";
+export * from "./schema";
+export { seedDemo } from "./seed";
diff --git a/packages/judging-db/src/migrate.ts b/packages/judging-db/src/migrate.ts
new file mode 100644
index 00000000..9a5e8c76
--- /dev/null
+++ b/packages/judging-db/src/migrate.ts
@@ -0,0 +1,69 @@
+import { readdir, readFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import type { Pool } from "pg";
+
+const migrationsDir = join(dirname(fileURLToPath(import.meta.url)), "../migrations");
+
+const MIGRATION_TABLE = `
+ create table if not exists panel_migration (
+ id text primary key,
+ applied_at timestamptz not null default now()
+ )
+`;
+
+/** Applies committed SQL files in order. Each file runs in one transaction. */
+export async function migrate(pool: Pool): Promise {
+ const client = await pool.connect();
+ try {
+ await client.query(MIGRATION_TABLE);
+ const applied = await client.query<{ id: string }>(
+ "select id from panel_migration",
+ );
+ const done = new Set(applied.rows.map((row) => row.id));
+ const files = await migrationFiles();
+ const fresh: string[] = [];
+
+ for (const file of files) {
+ if (done.has(file)) continue;
+ const body = await readFile(join(migrationsDir, file), "utf8");
+ try {
+ await client.query("begin");
+ await client.query(body);
+ await client.query("insert into panel_migration (id) values ($1)", [
+ file,
+ ]);
+ await client.query("commit");
+ } catch (error) {
+ await client.query("rollback");
+ throw error;
+ }
+ fresh.push(file);
+ }
+ return fresh;
+ } finally {
+ client.release();
+ }
+}
+
+export async function appliedMigrations(pool: Pool): Promise {
+ const client = await pool.connect();
+ try {
+ const exists = await client.query<{ present: boolean }>(
+ `select to_regclass('panel_migration') is not null as present`,
+ );
+ const row = exists.rows[0];
+ if (!row?.present) return [];
+ const applied = await client.query<{ id: string }>(
+ "select id from panel_migration order by id",
+ );
+ return applied.rows.map((item) => item.id);
+ } finally {
+ client.release();
+ }
+}
+
+export async function migrationFiles(): Promise {
+ const files = await readdir(migrationsDir);
+ return files.filter((name) => name.endsWith(".sql")).sort();
+}
diff --git a/packages/judging-db/src/schema.ts b/packages/judging-db/src/schema.ts
new file mode 100644
index 00000000..365d76a0
--- /dev/null
+++ b/packages/judging-db/src/schema.ts
@@ -0,0 +1,416 @@
+import {
+ boolean,
+ integer,
+ jsonb,
+ numeric,
+ pgTable,
+ primaryKey,
+ text,
+ timestamp,
+ uniqueIndex,
+ uuid,
+} from "drizzle-orm/pg-core";
+import { sql } from "drizzle-orm";
+import {
+ comparisonOutcome,
+ dispatchStrategy,
+ eventPhase,
+ judgeStatus,
+ membershipRole,
+ trackKind,
+} from "./enums";
+
+export {
+ comparisonOutcome,
+ dispatchStrategy,
+ eventPhase,
+ judgeStatus,
+ membershipRole,
+ trackKind,
+};
+
+export const organization = pgTable("organization", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ slug: text("slug").notNull().unique(),
+ name: text("name").notNull(),
+ branding: jsonb("branding").notNull().default({}),
+});
+
+export const user = pgTable("panel_user", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ email: text("email").notNull().unique(),
+ name: text("name").notNull(),
+});
+
+export const event = pgTable(
+ "panel_event",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ orgId: uuid("org_id")
+ .notNull()
+ .references(() => organization.id, { onDelete: "cascade" }),
+ slug: text("slug").notNull(),
+ name: text("name").notNull(),
+ startsAt: timestamp("starts_at", { withTimezone: true }),
+ endsAt: timestamp("ends_at", { withTimezone: true }),
+ phase: eventPhase("phase").notNull().default("setup"),
+ },
+ (table) => [uniqueIndex("event_org_slug_idx").on(table.orgId, table.slug)],
+);
+
+export const eventConfig = pgTable("event_config", {
+ eventId: uuid("event_id")
+ .primaryKey()
+ .references(() => event.id, { onDelete: "cascade" }),
+ targetSeconds: integer("target_seconds").notNull(),
+ hardLimitSeconds: integer("hard_limit_seconds").notNull(),
+ walkLimitSeconds: integer("walk_limit_seconds").notNull(),
+ submitGraceSeconds: integer("submit_grace_seconds").notNull(),
+ minLooksPerProject: integer("min_looks_per_project").notNull(),
+ dispatchStrategy: dispatchStrategy("dispatch_strategy").notNull(),
+ pairwiseEnabled: boolean("pairwise_enabled").notNull().default(false),
+ pairwiseWeight: numeric("pairwise_weight", { mode: "number" }).notNull().default(0),
+ bayesianC: numeric("bayesian_c", { mode: "number" }).notNull().default(2),
+ calibrationLooks: integer("calibration_looks").notNull().default(0),
+ calibrationWeight: numeric("calibration_weight", { mode: "number" })
+ .notNull()
+ .default(1),
+ feedbackCardsEnabled: boolean("feedback_cards_enabled").notNull().default(false),
+ leaderboardPublic: boolean("leaderboard_public").notNull().default(false),
+ boardPollSeconds: integer("board_poll_seconds").notNull().default(5),
+});
+
+export const rubric = pgTable("rubric", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ eventId: uuid("event_id")
+ .notNull()
+ .references(() => event.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ isDefault: boolean("is_default").notNull().default(false),
+});
+
+export const criterion = pgTable(
+ "criterion",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ rubricId: uuid("rubric_id")
+ .notNull()
+ .references(() => rubric.id, { onDelete: "cascade" }),
+ position: integer("position").notNull(),
+ key: text("key").notNull(),
+ label: text("label").notNull(),
+ description: text("description"),
+ min: numeric("min", { mode: "number" }).notNull(),
+ max: numeric("max", { mode: "number" }).notNull(),
+ weight: numeric("weight", { mode: "number" }).notNull(),
+ anchors: jsonb("anchors").notNull().default([]),
+ },
+ (table) => [uniqueIndex("criterion_rubric_key_idx").on(table.rubricId, table.key)],
+);
+
+export const track = pgTable(
+ "track",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ eventId: uuid("event_id")
+ .notNull()
+ .references(() => event.id, { onDelete: "cascade" }),
+ slug: text("slug").notNull(),
+ name: text("name").notNull(),
+ kind: trackKind("kind").notNull(),
+ judgeGroup: text("judge_group").notNull(),
+ rubricId: uuid("rubric_id").references(() => rubric.id),
+ position: integer("position").notNull().default(0),
+ isActive: boolean("is_active").notNull().default(true),
+ },
+ (table) => [uniqueIndex("track_event_slug_idx").on(table.eventId, table.slug)],
+);
+
+export const prize = pgTable(
+ "prize",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ trackId: uuid("track_id")
+ .notNull()
+ .references(() => track.id, { onDelete: "cascade" }),
+ place: integer("place").notNull(),
+ title: text("title").notNull(),
+ amount: numeric("amount", { mode: "number" }),
+ description: text("description"),
+ },
+ (table) => [uniqueIndex("prize_track_place_idx").on(table.trackId, table.place)],
+);
+
+export const zone = pgTable("zone", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ eventId: uuid("event_id")
+ .notNull()
+ .references(() => event.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ position: integer("position").notNull().default(0),
+});
+
+/** Floor plan. The SQL name is floor_table because `table` is reserved. */
+export const floorTable = pgTable(
+ "floor_table",
+ {
+ eventId: uuid("event_id")
+ .notNull()
+ .references(() => event.id, { onDelete: "cascade" }),
+ number: integer("number").notNull(),
+ zoneId: uuid("zone_id").references(() => zone.id),
+ x: numeric("x", { mode: "number" }),
+ y: numeric("y", { mode: "number" }),
+ },
+ (table) => [primaryKey({ columns: [table.eventId, table.number] })],
+);
+
+export const project = pgTable("project", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ eventId: uuid("event_id")
+ .notNull()
+ .references(() => event.id, { onDelete: "cascade" }),
+ externalId: text("external_id"),
+ name: text("name").notNull(),
+ description: text("description"),
+ teamName: text("team_name"),
+ members: jsonb("members").notNull().default([]),
+ links: jsonb("links").notNull().default({}),
+ tableNumber: integer("table_number"),
+ zoneId: uuid("zone_id").references(() => zone.id),
+ qrToken: uuid("qr_token").notNull().unique().defaultRandom(),
+ feedbackToken: uuid("feedback_token").unique(),
+ arrivedFirstAt: timestamp("arrived_first_at", { withTimezone: true }),
+ withdrawnAt: timestamp("withdrawn_at", { withTimezone: true }),
+});
+
+export const projectTrack = pgTable(
+ "project_track",
+ {
+ projectId: uuid("project_id")
+ .notNull()
+ .references(() => project.id, { onDelete: "cascade" }),
+ trackId: uuid("track_id")
+ .notNull()
+ .references(() => track.id, { onDelete: "cascade" }),
+ },
+ (table) => [primaryKey({ columns: [table.projectId, table.trackId] })],
+);
+
+export const judge = pgTable(
+ "panel_judge",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ eventId: uuid("event_id")
+ .notNull()
+ .references(() => event.id, { onDelete: "cascade" }),
+ externalId: text("external_id"),
+ name: text("name").notNull(),
+ email: text("email").notNull(),
+ org: text("org"),
+ title: text("title"),
+ status: judgeStatus("status").notNull().default("invited"),
+ trackId: uuid("track_id").references(() => track.id),
+ zoneId: uuid("zone_id").references(() => zone.id),
+ isLead: boolean("is_lead").notNull().default(false),
+ },
+ (table) => [uniqueIndex("judge_event_email_idx").on(table.eventId, table.email)],
+);
+
+export const visit = pgTable(
+ "visit",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ eventId: uuid("event_id")
+ .notNull()
+ .references(() => event.id, { onDelete: "cascade" }),
+ judgeId: uuid("judge_id")
+ .notNull()
+ .references(() => judge.id, { onDelete: "cascade" }),
+ projectId: uuid("project_id")
+ .notNull()
+ .references(() => project.id, { onDelete: "cascade" }),
+ judgeGroup: text("judge_group").notNull(),
+ handedOutAt: timestamp("handed_out_at", { withTimezone: true }).notNull(),
+ arrivedAt: timestamp("arrived_at", { withTimezone: true }),
+ completedAt: timestamp("completed_at", { withTimezone: true }),
+ voidedAt: timestamp("voided_at", { withTimezone: true }),
+ voidReason: text("void_reason"),
+ },
+ (table) => [
+ uniqueIndex("visit_judge_project_open_idx")
+ .on(table.judgeId, table.projectId)
+ .where(sql`voided_at is null`),
+ uniqueIndex("visit_one_open_idx")
+ .on(table.judgeId)
+ .where(sql`voided_at is null and completed_at is null`),
+ ],
+);
+
+export const vote = pgTable("vote", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ visitId: uuid("visit_id")
+ .notNull()
+ .unique()
+ .references(() => visit.id, { onDelete: "cascade" }),
+ judgeId: uuid("judge_id")
+ .notNull()
+ .references(() => judge.id, { onDelete: "cascade" }),
+ projectId: uuid("project_id")
+ .notNull()
+ .references(() => project.id, { onDelete: "cascade" }),
+ eventId: uuid("event_id")
+ .notNull()
+ .references(() => event.id, { onDelete: "cascade" }),
+ total: numeric("total", { mode: "number" }).notNull(),
+ comment: text("comment"),
+ durationSeconds: integer("duration_seconds"),
+ isCalibration: boolean("is_calibration").notNull().default(false),
+});
+
+export const voteScore = pgTable(
+ "vote_score",
+ {
+ voteId: uuid("vote_id")
+ .notNull()
+ .references(() => vote.id, { onDelete: "cascade" }),
+ criterionId: uuid("criterion_id")
+ .notNull()
+ .references(() => criterion.id),
+ value: numeric("value", { mode: "number" }).notNull(),
+ },
+ (table) => [primaryKey({ columns: [table.voteId, table.criterionId] })],
+);
+
+export const comparison = pgTable("comparison", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ eventId: uuid("event_id")
+ .notNull()
+ .references(() => event.id, { onDelete: "cascade" }),
+ judgeId: uuid("judge_id")
+ .notNull()
+ .references(() => judge.id, { onDelete: "cascade" }),
+ judgeGroup: text("judge_group").notNull(),
+ aProjectId: uuid("a_project_id")
+ .notNull()
+ .references(() => project.id),
+ bProjectId: uuid("b_project_id")
+ .notNull()
+ .references(() => project.id),
+ outcome: comparisonOutcome("outcome").notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+});
+
+export const resultRun = pgTable("result_run", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ eventId: uuid("event_id")
+ .notNull()
+ .references(() => event.id, { onDelete: "cascade" }),
+ computedAt: timestamp("computed_at", { withTimezone: true }).notNull().defaultNow(),
+ computedBy: uuid("computed_by").references(() => user.id),
+ configSnapshot: jsonb("config_snapshot").notNull(),
+ publishedAt: timestamp("published_at", { withTimezone: true }),
+ notes: text("notes"),
+});
+
+export const result = pgTable(
+ "result",
+ {
+ runId: uuid("run_id")
+ .notNull()
+ .references(() => resultRun.id, { onDelete: "cascade" }),
+ projectId: uuid("project_id")
+ .notNull()
+ .references(() => project.id, { onDelete: "cascade" }),
+ trackId: uuid("track_id")
+ .notNull()
+ .references(() => track.id, { onDelete: "cascade" }),
+ placement: integer("placement"),
+ score: numeric("score", { mode: "number" }).notNull(),
+ rubricComponent: numeric("rubric_component", { mode: "number" }).notNull(),
+ pairwiseComponent: numeric("pairwise_component", { mode: "number" }).notNull(),
+ voteCount: integer("vote_count").notNull(),
+ comparisonCount: integer("comparison_count").notNull(),
+ flags: text("flags").array().notNull().default([]),
+ },
+ (table) => [
+ primaryKey({ columns: [table.runId, table.projectId, table.trackId] }),
+ ],
+);
+
+export const eventLog = pgTable("event_log", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ eventId: uuid("event_id")
+ .notNull()
+ .references(() => event.id, { onDelete: "cascade" }),
+ kind: text("kind").notNull(),
+ actor: jsonb("actor").notNull().default({}),
+ subject: jsonb("subject").notNull().default({}),
+ payload: jsonb("payload").notNull().default({}),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+});
+
+export const outbox = pgTable("outbox", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ eventId: uuid("event_id")
+ .notNull()
+ .references(() => event.id, { onDelete: "cascade" }),
+ topic: text("topic").notNull(),
+ payload: jsonb("payload").notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ deliveredAt: timestamp("delivered_at", { withTimezone: true }),
+ attempts: integer("attempts").notNull().default(0),
+});
+
+export const webhook = pgTable("webhook", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ orgId: uuid("org_id")
+ .notNull()
+ .references(() => organization.id, { onDelete: "cascade" }),
+ url: text("url").notNull(),
+ secret: text("secret").notNull(),
+ topics: text("topics").array().notNull(),
+ isActive: boolean("is_active").notNull().default(true),
+});
+
+export const apiKey = pgTable("api_key", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ orgId: uuid("org_id")
+ .notNull()
+ .references(() => organization.id, { onDelete: "cascade" }),
+ hashedKey: text("hashed_key").notNull(),
+ scopes: text("scopes").array().notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ revokedAt: timestamp("revoked_at", { withTimezone: true }),
+});
+
+export const membership = pgTable(
+ "membership",
+ {
+ userId: uuid("user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ orgId: uuid("org_id")
+ .notNull()
+ .references(() => organization.id, { onDelete: "cascade" }),
+ role: membershipRole("role").notNull(),
+ },
+ (table) => [primaryKey({ columns: [table.userId, table.orgId] })],
+);
+
+export const judgeIdentity = pgTable("judge_identity", {
+ judgeId: uuid("judge_id")
+ .primaryKey()
+ .references(() => judge.id, { onDelete: "cascade" }),
+ userId: uuid("user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+});
+
+export const loginCode = pgTable("login_code", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ email: text("email").notNull(),
+ codeHash: text("code_hash").notNull(),
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
+ consumedAt: timestamp("consumed_at", { withTimezone: true }),
+});
diff --git a/packages/judging-db/src/seed.ts b/packages/judging-db/src/seed.ts
new file mode 100644
index 00000000..9a11405a
--- /dev/null
+++ b/packages/judging-db/src/seed.ts
@@ -0,0 +1,238 @@
+import { eq, sql } from "drizzle-orm";
+import { DEFAULT_TIMERS } from "@query/judging-core";
+import type { PanelDb, PanelTx } from "./client";
+import {
+ criterion,
+ event,
+ eventConfig,
+ floorTable,
+ judge,
+ membership,
+ organization,
+ prize,
+ project,
+ projectTrack,
+ rubric,
+ track,
+ user,
+ zone,
+} from "./schema";
+
+const CRITERIA = [
+ ["creativity", "Creativity"],
+ ["impact", "Impact"],
+ ["scope", "Scope"],
+ ["clarity", "Clarity"],
+ ["soundness", "Soundness"],
+] as const;
+
+/**
+ * One event a self-hoster can click through: 40 projects, 6 judges, three
+ * tracks, and a second rubric on the sponsor track. Re-running replaces the
+ * demo organization.
+ */
+export async function seedDemo(db: PanelDb): Promise<{ eventId: string }> {
+ return db.transaction(async (tx) => {
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext('panel:seed:demo'))`);
+ return insertDemo(tx);
+ });
+}
+
+async function insertDemo(db: PanelTx): Promise<{ eventId: string }> {
+ const existing = await db
+ .select({ id: organization.id })
+ .from(organization)
+ .where(eq(organization.slug, "demo"));
+ const previous = existing[0];
+ if (previous) {
+ await db.delete(organization).where(eq(organization.id, previous.id));
+ }
+
+ const [org] = await db
+ .insert(organization)
+ .values({
+ slug: "demo",
+ name: "Demo",
+ branding: { tagline: "A practice floor" },
+ })
+ .returning({ id: organization.id });
+ if (!org) throw new Error("demo organization was not created");
+
+ await db.delete(user).where(eq(user.email, "organizer@demo.panel"));
+ const [organizer] = await db
+ .insert(user)
+ .values({ email: "organizer@demo.panel", name: "Demo organizer" })
+ .returning({ id: user.id });
+ if (!organizer) throw new Error("demo organizer was not created");
+
+ await db.insert(membership).values({
+ userId: organizer.id,
+ orgId: org.id,
+ role: "owner",
+ });
+
+ const [demoEvent] = await db
+ .insert(event)
+ .values({
+ orgId: org.id,
+ slug: "demo",
+ name: "Demo",
+ phase: "judging_live",
+ })
+ .returning({ id: event.id });
+ if (!demoEvent) throw new Error("demo event was not created");
+
+ await db.insert(eventConfig).values({
+ eventId: demoEvent.id,
+ targetSeconds: DEFAULT_TIMERS.targetSeconds,
+ hardLimitSeconds: DEFAULT_TIMERS.hardLimitSeconds,
+ walkLimitSeconds: DEFAULT_TIMERS.walkLimitSeconds,
+ submitGraceSeconds: DEFAULT_TIMERS.submitGraceSeconds,
+ minLooksPerProject: 1,
+ dispatchStrategy: "coverage",
+ pairwiseEnabled: false,
+ pairwiseWeight: 0,
+ bayesianC: 2,
+ calibrationLooks: 0,
+ calibrationWeight: 1,
+ feedbackCardsEnabled: false,
+ leaderboardPublic: false,
+ boardPollSeconds: 5,
+ });
+
+ const [mainRubric] = await db
+ .insert(rubric)
+ .values({ eventId: demoEvent.id, name: "Main", isDefault: true })
+ .returning({ id: rubric.id });
+ const [sponsorRubric] = await db
+ .insert(rubric)
+ .values({ eventId: demoEvent.id, name: "Sponsor", isDefault: false })
+ .returning({ id: rubric.id });
+ if (!mainRubric || !sponsorRubric) throw new Error("demo rubrics were not created");
+
+ await db.insert(criterion).values(
+ CRITERIA.map(([key, label], position) => ({
+ rubricId: mainRubric.id,
+ position,
+ key,
+ label,
+ min: 0,
+ max: 10,
+ weight: 1,
+ })),
+ );
+ await db.insert(criterion).values([
+ {
+ rubricId: sponsorRubric.id,
+ position: 0,
+ key: "fit",
+ label: "Fit",
+ min: 0,
+ max: 10,
+ weight: 1,
+ },
+ {
+ rubricId: sponsorRubric.id,
+ position: 1,
+ key: "execution",
+ label: "Execution",
+ min: 0,
+ max: 10,
+ weight: 1,
+ },
+ ]);
+
+ const tracks = await db
+ .insert(track)
+ .values([
+ {
+ eventId: demoEvent.id,
+ slug: "general",
+ name: "General",
+ kind: "main" as const,
+ judgeGroup: "main",
+ rubricId: mainRubric.id,
+ position: 0,
+ },
+ {
+ eventId: demoEvent.id,
+ slug: "sponsor",
+ name: "Sponsor",
+ kind: "sponsor" as const,
+ judgeGroup: "sponsor",
+ rubricId: sponsorRubric.id,
+ position: 1,
+ },
+ {
+ eventId: demoEvent.id,
+ slug: "beginner",
+ name: "Beginner",
+ kind: "special" as const,
+ judgeGroup: "beginner",
+ rubricId: mainRubric.id,
+ position: 2,
+ },
+ ])
+ .returning({ id: track.id, slug: track.slug });
+
+ const general = tracks.find((item) => item.slug === "general");
+ const sponsor = tracks.find((item) => item.slug === "sponsor");
+ const beginner = tracks.find((item) => item.slug === "beginner");
+ if (!general || !sponsor || !beginner) throw new Error("demo tracks were not created");
+
+ await db.insert(prize).values([
+ { trackId: general.id, place: 1, title: "First", amount: 1000 },
+ { trackId: sponsor.id, place: 1, title: "Sponsor prize", amount: 500 },
+ ]);
+
+ const [hall] = await db
+ .insert(zone)
+ .values({ eventId: demoEvent.id, name: "Hall", position: 0 })
+ .returning({ id: zone.id });
+ if (!hall) throw new Error("demo zone was not created");
+
+ await db.insert(floorTable).values(
+ Array.from({ length: 40 }, (_, index) => ({
+ eventId: demoEvent.id,
+ number: index + 1,
+ zoneId: hall.id,
+ })),
+ );
+
+ const projects = await db
+ .insert(project)
+ .values(
+ Array.from({ length: 40 }, (_, index) => ({
+ eventId: demoEvent.id,
+ name: `Project ${index + 1}`,
+ teamName: `Team ${index + 1}`,
+ tableNumber: index + 1,
+ zoneId: hall.id,
+ })),
+ )
+ .returning({ id: project.id, tableNumber: project.tableNumber });
+
+ await db.insert(projectTrack).values(
+ projects.flatMap((item) => {
+ const links = [{ projectId: item.id, trackId: general.id }];
+ const number = item.tableNumber ?? 0;
+ if (number % 5 === 0) links.push({ projectId: item.id, trackId: sponsor.id });
+ if (number % 4 === 0) links.push({ projectId: item.id, trackId: beginner.id });
+ return links;
+ }),
+ );
+
+ const sponsorTrack = tracks.find((item) => item.slug === "sponsor");
+ await db.insert(judge).values(
+ Array.from({ length: 6 }, (_, index) => ({
+ eventId: demoEvent.id,
+ name: `Judge ${index + 1}`,
+ email: `judge${index + 1}@demo.panel`,
+ status: "approved" as const,
+ trackId: index === 5 ? sponsorTrack?.id : null,
+ zoneId: hall.id,
+ })),
+ );
+
+ return { eventId: demoEvent.id };
+}
diff --git a/packages/judging-db/tsconfig.json b/packages/judging-db/tsconfig.json
new file mode 100644
index 00000000..f54bde2c
--- /dev/null
+++ b/packages/judging-db/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tooling/typescript/base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/judging-server/eslint.config.mjs b/packages/judging-server/eslint.config.mjs
new file mode 100644
index 00000000..2d326cc0
--- /dev/null
+++ b/packages/judging-server/eslint.config.mjs
@@ -0,0 +1,10 @@
+import { config } from "@query/eslint-config/base";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+ ...config,
+ {
+ files: ["src/log.ts", "src/index.ts"],
+ rules: { "no-console": "off" },
+ },
+];
diff --git a/packages/judging-server/package.json b/packages/judging-server/package.json
new file mode 100644
index 00000000..da7cabd3
--- /dev/null
+++ b/packages/judging-server/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@query/judging-server",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./outbox": "./src/services/outbox.ts"
+ },
+ "scripts": {
+ "lint": "eslint . --max-warnings 0",
+ "typecheck": "tsc --noEmit",
+ "test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts"
+ },
+ "dependencies": {
+ "@query/judging-core": "workspace:*",
+ "@query/judging-db": "workspace:*",
+ "@trpc/server": "11.19.0",
+ "drizzle-orm": "0.45.3",
+ "hono": "^4.9.0",
+ "ioredis": "^5.8.2",
+ "jose": "^6.1.0",
+ "nodemailer": "^10.0.10",
+ "prom-client": "15.1.3",
+ "zod": "4.6.5"
+ },
+ "devDependencies": {
+ "@query/eslint-config": "workspace:*",
+ "@query/tsconfig": "workspace:*",
+ "@types/node": "22.20.4",
+ "typescript": "7.0.2",
+ "vitest": "^5.0.1"
+ }
+}
diff --git a/packages/judging-server/src/app.test.ts b/packages/judging-server/src/app.test.ts
new file mode 100644
index 00000000..d31f2ae7
--- /dev/null
+++ b/packages/judging-server/src/app.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+import { createApp } from "./app";
+import { createMetrics } from "./metrics";
+
+describe("health", () => {
+ it("answers /healthz without a database", async () => {
+ const app = createApp({
+ db: {} as never,
+ metrics: createMetrics(),
+ jwtSecret: "test",
+ devAuth: false,
+ busMode: "memory",
+ });
+ const response = await app.request("/healthz");
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ ok: true });
+ });
+});
+
+describe("session actor", () => {
+ it("asks resolveActor when there is no bearer token", async () => {
+ const seen: string[] = [];
+ const app = createApp({
+ db: {} as never,
+ metrics: createMetrics(),
+ jwtSecret: "test",
+ devAuth: false,
+ busMode: "memory",
+ resolveActor: async (request) => {
+ seen.push(new URL(request.url).pathname);
+ return null;
+ },
+ });
+ const response = await app.request(
+ "/v1/session/progress?eventId=00000000-0000-4000-8000-000000000000",
+ );
+ expect(response.status).toBe(401);
+ expect(seen).toEqual(["/v1/session/progress"]);
+ });
+});
diff --git a/packages/judging-server/src/app.ts b/packages/judging-server/src/app.ts
new file mode 100644
index 00000000..b72251ca
--- /dev/null
+++ b/packages/judging-server/src/app.ts
@@ -0,0 +1,385 @@
+import { Hono } from "hono";
+import { cors } from "hono/cors";
+import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
+import { TRPCError } from "@trpc/server";
+import { and, eq, isNull, sql } from "drizzle-orm";
+import { z } from "zod";
+import type { PanelDb } from "@query/judging-db";
+import { event, loginCode, membership, organization, user, visit } from "@query/judging-db";
+import { hashCode, newLoginCode, roleAtLeast, signActor, verifyActor } from "./auth";
+import { sendLoginCode } from "./mail";
+import type { Actor, Role } from "./auth";
+import type { Bus } from "./bus";
+import { InMemoryBus } from "./bus";
+import { feedbackCard, feedbackTokenForExternal, actorForApiKey } from "./services/catalog";
+import { organizerFloor } from "./services/floor";
+import { liveSnapshot } from "./services/live";
+import { inspectRun, publishedPlacements } from "./services/results";
+import type { Metrics } from "./metrics";
+import { appRouter } from "./router";
+import type { Context } from "./router";
+
+const openApi = {
+ openapi: "3.0.3",
+ info: {
+ title: "Panel",
+ version: "0.0.0",
+ description:
+ "REST mirror of the tRPC procedures. Send Authorization: Bearer with a JWT or an API key, except for the public routes.",
+ },
+ paths: {
+ "/healthz": { get: { summary: "Process is up." } },
+ "/readyz": { get: { summary: "Postgres answers. The bus is this process unless Redis is set." } },
+ "/metrics": { get: { summary: "Prometheus metrics." } },
+ "/openapi.json": { get: { summary: "This document." } },
+ "/v1/auth/magic-link": { post: { summary: "Email a sign-in code. Dev auth returns the code." } },
+ "/v1/auth/verify": { post: { summary: "Exchange a code for a JWT." } },
+ "/v1/judges/apply": { post: { summary: "Apply to judge this event with the signed-in email." } },
+ "/v1/public/{orgSlug}/{eventSlug}": { get: { summary: "Event name, phase, and branding." } },
+ "/v1/session/next": { post: { summary: "Hand the signed-in judge their next table." } },
+ "/v1/session/arrive": { post: { summary: "Mark arrival by table number or QR token." } },
+ "/v1/session/vote": { post: { summary: "Store the score for the open visit." } },
+ "/v1/session/skip": { post: { summary: "Pass on the open table without a score." } },
+ "/v1/session/progress": { get: { summary: "How many projects this judge has scored." } },
+ "/v1/session/status": { get: { summary: "Whether the open visit was voided or the judge recalled." } },
+ "/v1/session/compare": { post: { summary: "Record which of the last two tables was better." } },
+ "/v1/session/rubric": { get: { summary: "Criteria for the event's default rubric." } },
+ "/v1/catalog/projects": { get: { summary: "Projects for table cards. Organizer." } },
+ "/v1/catalog/logs": { get: { summary: "Recent event log rows. Organizer." } },
+ "/v1/catalog/tables": { get: { summary: "Floor tables and coordinates. Organizer." } },
+ "/v1/catalog/judges": { get: { summary: "Judges and their status. Organizer." } },
+ "/v1/live/{orgSlug}/{eventSlug}": { get: { summary: "Board snapshot: coverage, or placements after publish." } },
+ "/v1/floor/{eventId}": { get: { summary: "Organizer floor: looks per table, and idle, walking, or overtime judges." } },
+ "/v1/results/run/{runId}": { get: { summary: "One run, with rubric and pairwise components. Organizer." } },
+ "/v1/results/{eventId}": { get: { summary: "Published placements. 403 before publish." } },
+ "/v1/feedback/{token}": { get: { summary: "A team's card. 403 for an unknown token or before publish." } },
+ },
+};
+
+export function createApp(options: {
+ db: PanelDb;
+ metrics: Metrics;
+ jwtSecret: string;
+ jwksUrl?: string;
+ devAuth: boolean;
+ smtpUrl?: string;
+ busMode: "memory" | "redis";
+ bus?: Bus;
+ /** Who is signed in when the request carries no bearer token, e.g. a portal session cookie. */
+ resolveActor?: (request: Request) => Promise;
+}) {
+ const bus = options.bus ?? new InMemoryBus();
+ const app = new Hono();
+ app.use(
+ "*",
+ cors({
+ origin: "*",
+ allowHeaders: ["Authorization", "Content-Type"],
+ allowMethods: ["GET", "POST", "OPTIONS"],
+ }),
+ );
+
+ app.get("/healthz", (c) => c.json({ ok: true }));
+
+ app.get("/readyz", async (c) => {
+ try {
+ await options.db.execute(sql`select 1`);
+ return c.json({ ok: true, bus: options.busMode });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "database";
+ return c.json({ ok: false, message }, 503);
+ }
+ });
+
+ app.get("/metrics", async (c) => {
+ const [open] = await options.db
+ .select({ n: sql`count(distinct ${visit.judgeId})::int` })
+ .from(visit)
+ .where(and(isNull(visit.completedAt), isNull(visit.voidedAt)));
+ options.metrics.liveJudges.set(open?.n ?? 0);
+ c.header("content-type", options.metrics.register.contentType);
+ return c.body(await options.metrics.register.metrics());
+ });
+
+ app.get("/openapi.json", (c) => c.json(openApi));
+
+ app.post("/v1/auth/magic-link", async (c) => {
+ const body = z.object({ email: z.string().email() }).parse(await c.req.json());
+ if (!options.devAuth && !options.smtpUrl) {
+ return c.json({ ok: false, message: "SMTP is not configured" }, 503);
+ }
+ const code = newLoginCode();
+ const email = body.email.toLowerCase();
+ await options.db.insert(loginCode).values({
+ email,
+ codeHash: hashCode(code),
+ expiresAt: new Date(Date.now() + 10 * 60 * 1000),
+ });
+ if (options.smtpUrl) {
+ await sendLoginCode(options.smtpUrl, email, code);
+ return c.json({ ok: true });
+ }
+ return c.json({ ok: true, code });
+ });
+
+ app.post("/v1/auth/verify", async (c) => {
+ const body = z
+ .object({
+ email: z.string().email(),
+ code: z.string().length(6),
+ org: z.string().optional(),
+ })
+ .parse(await c.req.json());
+ const email = body.email.toLowerCase();
+ const [row] = await options.db
+ .select()
+ .from(loginCode)
+ .where(
+ and(
+ eq(loginCode.email, email),
+ eq(loginCode.codeHash, hashCode(body.code)),
+ isNull(loginCode.consumedAt),
+ ),
+ );
+ if (!row || row.expiresAt.getTime() < Date.now()) {
+ return c.json({ ok: false, message: "Code is not valid" }, 401);
+ }
+ await options.db
+ .update(loginCode)
+ .set({ consumedAt: new Date() })
+ .where(eq(loginCode.id, row.id));
+
+ const [person] = await options.db
+ .select()
+ .from(user)
+ .where(eq(user.email, email));
+ let role: Role | null = null;
+ let orgSlug: string | null = body.org ?? null;
+ if (person) {
+ const rows = await options.db
+ .select({ role: membership.role, slug: organization.slug })
+ .from(membership)
+ .innerJoin(organization, eq(organization.id, membership.orgId))
+ .where(eq(membership.userId, person.id));
+ const chosen = orgSlug
+ ? rows.find((item) => item.slug === orgSlug)
+ : rows.length === 1
+ ? rows[0]
+ : undefined;
+ if (chosen) {
+ role = chosen.role;
+ orgSlug = chosen.slug;
+ }
+ }
+
+ if (!options.jwtSecret) {
+ return c.json({ ok: false, message: "PANEL_JWT_SECRET is not set" }, 503);
+ }
+ const token = await signActor(
+ {
+ sub: person?.id ?? email,
+ email,
+ name: person?.name ?? email,
+ org: orgSlug,
+ role,
+ judgeExternalId: null,
+ },
+ options.jwtSecret,
+ 60 * 60 * 12,
+ );
+ return c.json({ ok: true, token });
+ });
+
+ app.post("/v1/judges/apply", async (c) => {
+ const body = z
+ .object({ eventId: z.string().uuid(), name: z.string().min(1) })
+ .parse(await c.req.json());
+ return rest((caller) => caller.judge.applyToEvent(body))(c);
+ });
+
+ app.get("/v1/public/:orgSlug/:eventSlug", async (c) => {
+ const [row] = await options.db
+ .select({
+ eventId: event.id,
+ name: event.name,
+ phase: event.phase,
+ branding: organization.branding,
+ orgName: organization.name,
+ })
+ .from(event)
+ .innerJoin(organization, eq(organization.id, event.orgId))
+ .where(
+ and(
+ eq(organization.slug, c.req.param("orgSlug")),
+ eq(event.slug, c.req.param("eventSlug")),
+ ),
+ );
+ if (!row) return c.json({ ok: false }, 404);
+ return c.json(row);
+ });
+
+ const contextFor = async (c: {
+ req: { header: (name: string) => string | undefined; raw: Request };
+ }): Promise => {
+ const header = c.req.header("authorization");
+ let actor: Context["actor"] = null;
+ if (header?.startsWith("Bearer ")) {
+ const token = header.slice("Bearer ".length);
+ try {
+ actor = await verifyActor(token, {
+ secret: options.jwtSecret,
+ jwksUrl: options.jwksUrl,
+ });
+ } catch {
+ actor = await actorForApiKey(options.db, token);
+ }
+ } else if (options.resolveActor) {
+ actor = await options.resolveActor(c.req.raw);
+ }
+ return { db: options.db, actor, now: new Date(), bus, metrics: options.metrics };
+ };
+
+ const rest = (run: (caller: ReturnType) => Promise) => {
+ return async (c: Parameters[0] & { json: (body: unknown, status?: number) => Response }) => {
+ try {
+ return c.json(await run(appRouter.createCaller(await contextFor(c))));
+ } catch (error) {
+ const message = error instanceof TRPCError ? error.message : "Request failed";
+ const status = error instanceof TRPCError && error.code === "UNAUTHORIZED" ? 401 : 400;
+ return c.json({ message }, status);
+ }
+ };
+ };
+
+ app.post("/v1/session/next", async (c) => {
+ const body = z.object({ eventId: z.string().uuid() }).parse(await c.req.json());
+ return rest((caller) => caller.session.next(body))(c);
+ });
+ app.post("/v1/session/arrive", async (c) => {
+ const body = z
+ .object({
+ eventId: z.string().uuid(),
+ qrToken: z.string().uuid().optional(),
+ tableNumber: z.number().int().optional(),
+ })
+ .parse(await c.req.json());
+ return rest((caller) => caller.session.arrive(body))(c);
+ });
+ app.post("/v1/session/vote", async (c) => {
+ const body = z
+ .object({
+ eventId: z.string().uuid(),
+ visitId: z.string().uuid(),
+ comment: z.string().nullable().optional(),
+ scores: z.array(z.object({ criterionId: z.string().uuid(), value: z.number() })),
+ })
+ .parse(await c.req.json());
+ return rest((caller) => caller.session.vote({ ...body, comment: body.comment ?? null }))(c);
+ });
+ app.get("/v1/session/rubric", async (c) => {
+ const eventId = z.string().uuid().parse(c.req.query("eventId"));
+ return rest((caller) => caller.session.rubric({ eventId }))(c);
+ });
+ app.post("/v1/session/compare", async (c) => {
+ const body = z
+ .object({ eventId: z.string().uuid(), outcome: z.enum(["a", "b", "tie"]) })
+ .parse(await c.req.json());
+ return rest((caller) => caller.session.compare(body))(c);
+ });
+ app.post("/v1/session/skip", async (c) => {
+ const body = z
+ .object({ eventId: z.string().uuid(), visitId: z.string().uuid() })
+ .parse(await c.req.json());
+ return rest((caller) => caller.session.skip(body))(c);
+ });
+ app.get("/v1/session/status", async (c) => {
+ const eventId = z.string().uuid().parse(c.req.query("eventId"));
+ const visitId = z.string().uuid().parse(c.req.query("visitId"));
+ return rest((caller) => caller.session.status({ eventId, visitId }))(c);
+ });
+ app.get("/v1/session/progress", async (c) => {
+ const eventId = z.string().uuid().parse(c.req.query("eventId"));
+ return rest((caller) => caller.session.progress({ eventId }))(c);
+ });
+
+ app.get("/v1/catalog/projects", async (c) => {
+ const eventId = z.string().uuid().parse(c.req.query("eventId"));
+ return rest((caller) => caller.catalog.projects({ eventId }))(c);
+ });
+ app.get("/v1/catalog/logs", async (c) => {
+ const eventId = z.string().uuid().parse(c.req.query("eventId"));
+ return rest((caller) => caller.catalog.logs({ eventId }))(c);
+ });
+ app.get("/v1/catalog/tables", async (c) => {
+ const eventId = z.string().uuid().parse(c.req.query("eventId"));
+ return rest((caller) => caller.catalog.tables({ eventId }))(c);
+ });
+ app.get("/v1/catalog/judges", async (c) => {
+ const eventId = z.string().uuid().parse(c.req.query("eventId"));
+ return rest((caller) => caller.catalog.judges({ eventId }))(c);
+ });
+
+ app.get("/v1/floor/:eventId", async (c) => {
+ const ctx = await contextFor(c);
+ if (!ctx.actor || !roleAtLeast(ctx.actor.role, "organizer")) {
+ return c.json({ message: "Unauthorized" }, 401);
+ }
+ const eventId = z.string().uuid().parse(c.req.param("eventId"));
+ const floor = await organizerFloor(options.db, eventId, ctx.now);
+ if (!floor) return c.json({ message: "Not found" }, 404);
+ return c.json(floor);
+ });
+
+ app.get("/v1/live/:orgSlug/:eventSlug", async (c) => {
+ const snapshot = await liveSnapshot(
+ options.db,
+ c.req.param("orgSlug"),
+ c.req.param("eventSlug"),
+ );
+ if (!snapshot) return c.json({ ok: false }, 404);
+ return c.json(snapshot);
+ });
+
+ app.get("/v1/results/run/:runId", async (c) => {
+ if (!(await contextFor(c)).actor) return c.json({ message: "Unauthorized" }, 401);
+ const runId = z.string().uuid().parse(c.req.param("runId"));
+ return c.json(await inspectRun(options.db, runId));
+ });
+
+ app.get("/v1/results/:eventId", async (c) => {
+ if (!(await contextFor(c)).actor) return c.json({ message: "Unauthorized" }, 401);
+ const eventId = z.string().uuid().parse(c.req.param("eventId"));
+ const placements = await publishedPlacements(options.db, eventId);
+ if (!placements) return c.json({ message: "Not found" }, 404);
+ if (!placements.published) return c.json({ message: "Forbidden" }, 403);
+ return c.json(placements);
+ });
+
+ app.get("/v1/feedback/external/:eventId/:externalId", async (c) => {
+ const ctx = await contextFor(c);
+ if (!ctx.actor || !roleAtLeast(ctx.actor.role, "organizer")) {
+ return c.json({ message: "Unauthorized" }, 401);
+ }
+ const eventId = z.string().uuid().parse(c.req.param("eventId"));
+ const token = await feedbackTokenForExternal(options.db, eventId, c.req.param("externalId"));
+ if (!token) return c.json({ message: "Forbidden" }, 403);
+ return c.json({ token });
+ });
+
+ app.get("/v1/feedback/:token", async (c) => {
+ const card = await feedbackCard(options.db, c.req.param("token"));
+ if (card.status === "forbidden") return c.json({ message: "Forbidden" }, 403);
+ return c.json(card);
+ });
+
+ app.all("/trpc/*", (c) =>
+ fetchRequestHandler({
+ endpoint: "/trpc",
+ req: c.req.raw,
+ router: appRouter,
+ createContext: () => contextFor(c),
+ }),
+ );
+
+ return app;
+}
diff --git a/packages/judging-server/src/auth.test.ts b/packages/judging-server/src/auth.test.ts
new file mode 100644
index 00000000..33babd47
--- /dev/null
+++ b/packages/judging-server/src/auth.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+import { hashApiKey, roleForScopes } from "./auth";
+
+describe("api keys", () => {
+ it("hashes a token the same way every time", () => {
+ expect(hashApiKey("panel_live_key")).toBe(hashApiKey("panel_live_key"));
+ expect(hashApiKey("panel_live_key")).not.toBe(hashApiKey("panel_live_other"));
+ });
+
+ it("uses the strongest scope as the role", () => {
+ expect(roleForScopes(["volunteer", "organizer"])).toBe("organizer");
+ expect(roleForScopes(["admin"])).toBe("admin");
+ expect(roleForScopes(["import"])).toBe("organizer");
+ expect(roleForScopes(["read"])).toBe(null);
+ });
+});
diff --git a/packages/judging-server/src/auth.ts b/packages/judging-server/src/auth.ts
new file mode 100644
index 00000000..8a51e635
--- /dev/null
+++ b/packages/judging-server/src/auth.ts
@@ -0,0 +1,100 @@
+import { createHash, randomInt } from "node:crypto";
+import { SignJWT, createRemoteJWKSet, jwtVerify } from "jose";
+
+export const ROLES = ["volunteer", "organizer", "admin", "owner"] as const;
+export type Role = (typeof ROLES)[number];
+
+export type Actor = {
+ sub: string;
+ email: string;
+ name: string;
+ org: string | null;
+ role: Role | null;
+ judgeExternalId: string | null;
+};
+
+const ROLE_RANK: Record = {
+ volunteer: 0,
+ organizer: 1,
+ admin: 2,
+ owner: 3,
+};
+
+export function roleAtLeast(actual: Role | null, minimum: Role): boolean {
+ if (!actual) return false;
+ return ROLE_RANK[actual] >= ROLE_RANK[minimum];
+}
+
+export function hashCode(code: string): string {
+ return createHash("sha256").update(code).digest("hex");
+}
+
+export function newLoginCode(): string {
+ return String(randomInt(0, 1_000_000)).padStart(6, "0");
+}
+
+export async function signActor(
+ actor: Actor,
+ secret: string,
+ ttlSeconds: number,
+): Promise {
+ return new SignJWT({
+ email: actor.email,
+ name: actor.name,
+ org: actor.org,
+ role: actor.role,
+ judge_external_id: actor.judgeExternalId,
+ })
+ .setProtectedHeader({ alg: "HS256" })
+ .setSubject(actor.sub)
+ .setIssuedAt()
+ .setExpirationTime(`${ttlSeconds}s`)
+ .sign(new TextEncoder().encode(secret));
+}
+
+export async function verifyActor(
+ token: string,
+ options: { secret?: string; jwksUrl?: string },
+): Promise {
+ const verified = options.jwksUrl
+ ? await jwtVerify(token, createRemoteJWKSet(new URL(options.jwksUrl)))
+ : await jwtVerify(token, new TextEncoder().encode(options.secret ?? ""));
+ const payload = verified.payload;
+ const email = payload.email;
+ const name = payload.name;
+ if (typeof email !== "string" || typeof payload.sub !== "string") {
+ throw new Error("Token is missing sub or email");
+ }
+ const role = ROLES.find((item) => item === payload.role) ?? null;
+ return {
+ sub: payload.sub,
+ email,
+ name: typeof name === "string" ? name : email,
+ org: typeof payload.org === "string" ? payload.org : null,
+ role,
+ judgeExternalId:
+ typeof payload.judge_external_id === "string"
+ ? payload.judge_external_id
+ : null,
+ };
+}
+
+export function hashApiKey(token: string): string {
+ return createHash("sha256").update(token).digest("hex");
+}
+
+/** The strongest role named in the key's scopes. Import and export are organizer work. */
+export function roleForScopes(scopes: readonly string[]): Role | null {
+ if (scopes.includes("owner")) return "owner";
+ if (scopes.includes("admin")) return "admin";
+ if (
+ scopes.includes("organizer") ||
+ scopes.includes("import") ||
+ scopes.includes("export") ||
+ scopes.includes("webhooks")
+ ) {
+ return "organizer";
+ }
+ if (scopes.includes("volunteer")) return "volunteer";
+ return null;
+}
diff --git a/packages/judging-server/src/bus.test.ts b/packages/judging-server/src/bus.test.ts
new file mode 100644
index 00000000..b35b71f4
--- /dev/null
+++ b/packages/judging-server/src/bus.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+import { FallbackBus, InMemoryBus } from "./bus";
+import type { Bus } from "./bus";
+
+describe("InMemoryBus", () => {
+ it("delivers a message only to subscribers of that channel", () => {
+ const bus = new InMemoryBus();
+ const seen: unknown[] = [];
+ const stop = bus.subscribe("event:1:board", (message) => seen.push(message));
+ bus.publish("event:1:board", { kind: "vote.cast" });
+ bus.publish("event:2:board", { kind: "other" });
+ stop();
+ bus.publish("event:1:board", { kind: "again" });
+ expect(seen).toEqual([{ kind: "vote.cast" }]);
+ });
+});
+
+class DownableBus implements Bus {
+ readonly seen: unknown[] = [];
+ private readonly down: (() => void)[] = [];
+
+ publish(_channel: string, message: unknown) {
+ this.seen.push(message);
+ }
+
+ subscribe() {
+ return () => undefined;
+ }
+
+ onDown(listener: () => void) {
+ this.down.push(listener);
+ }
+
+ stop() {
+ for (const listener of this.down) listener();
+ }
+}
+
+describe("FallbackBus", () => {
+ it("keeps delivering on this process after Redis reports down", () => {
+ const redis = new DownableBus();
+ const bus = new FallbackBus(redis);
+ const seen: unknown[] = [];
+ bus.subscribe("event:1:board", (message) => seen.push(message));
+ bus.publish("event:1:board", { kind: "before" });
+ redis.stop();
+ bus.publish("event:1:board", { kind: "after" });
+ expect(redis.seen).toEqual([{ kind: "before" }]);
+ expect(seen).toEqual([{ kind: "after" }]);
+ });
+});
diff --git a/packages/judging-server/src/bus.ts b/packages/judging-server/src/bus.ts
new file mode 100644
index 00000000..1cc6fe2b
--- /dev/null
+++ b/packages/judging-server/src/bus.ts
@@ -0,0 +1,131 @@
+import { Redis } from "ioredis";
+import { log } from "./log";
+
+type Listener = (message: unknown) => void;
+
+export type Bus = {
+ publish(channel: string, message: unknown): void;
+ subscribe(channel: string, listener: Listener): () => void;
+ onDown(listener: () => void): void;
+};
+
+/** In-process fan-out. Used when REDIS_URL is unset. */
+export class InMemoryBus implements Bus {
+ private readonly channels = new Map>();
+
+ publish(channel: string, message: unknown) {
+ for (const listener of this.channels.get(channel) ?? []) listener(message);
+ }
+
+ subscribe(channel: string, listener: Listener) {
+ const listeners = this.channels.get(channel) ?? new Set();
+ listeners.add(listener);
+ this.channels.set(channel, listeners);
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0) this.channels.delete(channel);
+ };
+ }
+
+ onDown() {
+ // This process is the bus.
+ }
+}
+
+export function createRedisBus(url: string): Bus {
+ const options = { maxRetriesPerRequest: null };
+ const pub = new Redis(url, options);
+ const sub = new Redis(url, options);
+ const listeners = new Map>();
+ const downListeners = new Set<() => void>();
+ let down = false;
+ const fail = () => {
+ if (down) return;
+ down = true;
+ for (const listener of downListeners) listener();
+ };
+ pub.on("error", fail);
+ sub.on("error", fail);
+ sub.on("message", (channel: string, raw: string) => {
+ const message = JSON.parse(raw) as unknown;
+ for (const listener of listeners.get(channel) ?? []) listener(message);
+ });
+ return {
+ publish(channel, message) {
+ void pub.publish(channel, JSON.stringify(message));
+ },
+ subscribe(channel, listener) {
+ const set = listeners.get(channel) ?? new Set();
+ const first = set.size === 0;
+ set.add(listener);
+ listeners.set(channel, set);
+ if (first) void sub.subscribe(channel);
+ return () => {
+ set.delete(listener);
+ if (set.size === 0) void sub.unsubscribe(channel);
+ };
+ },
+ onDown(listener) {
+ downListeners.add(listener);
+ },
+ };
+}
+
+/** Redis when REDIS_URL is set, otherwise the in-process bus. */
+export function createBus(redisUrl: string | undefined): Bus {
+ const url = redisUrl?.trim();
+ if (!url) return new InMemoryBus();
+ return new FallbackBus(createRedisBus(url));
+}
+
+/**
+ * Uses Redis until the connection errors, then this process fans out in memory.
+ * Clients still reload state from the live snapshot, so a second server does
+ * not need Redis for correctness.
+ */
+export class FallbackBus implements Bus {
+ private readonly memory = new InMemoryBus();
+ private redis: Bus | null;
+ private failed = false;
+ private readonly subs: { channel: string; listener: Listener; unsubscribe: () => void }[] = [];
+
+ constructor(redis: Bus) {
+ this.redis = redis;
+ redis.onDown(() => this.fail());
+ }
+
+ private fail() {
+ if (this.failed) return;
+ this.failed = true;
+ for (const sub of this.subs) {
+ sub.unsubscribe();
+ sub.unsubscribe = this.memory.subscribe(sub.channel, sub.listener);
+ }
+ this.redis = null;
+ log({
+ level: "warn",
+ message: "Redis is unavailable. This process is using the in-process bus.",
+ });
+ }
+
+ publish(channel: string, message: unknown) {
+ const target = this.failed ? this.memory : this.redis;
+ target?.publish(channel, message);
+ }
+
+ subscribe(channel: string, listener: Listener) {
+ const target = this.failed ? this.memory : this.redis;
+ const unsubscribe = target?.subscribe(channel, listener) ?? (() => undefined);
+ const sub = { channel, listener, unsubscribe };
+ this.subs.push(sub);
+ return () => {
+ sub.unsubscribe();
+ const index = this.subs.indexOf(sub);
+ if (index >= 0) this.subs.splice(index, 1);
+ };
+ }
+
+ onDown() {
+ // Callers keep their sockets. This process still delivers messages.
+ }
+}
diff --git a/packages/judging-server/src/index.ts b/packages/judging-server/src/index.ts
new file mode 100644
index 00000000..669d6c96
--- /dev/null
+++ b/packages/judging-server/src/index.ts
@@ -0,0 +1,9 @@
+export { createApp } from "./app";
+export { appRouter } from "./router";
+export { drainOutbox } from "./services/outbox";
+export type { Context } from "./router";
+export type { Actor, Role } from "./auth";
+export { InMemoryBus } from "./bus";
+export { createMetrics } from "./metrics";
+export { publishedPlacements } from "./services/results";
+export { ensureEvent } from "./services/event";
diff --git a/packages/judging-server/src/log.ts b/packages/judging-server/src/log.ts
new file mode 100644
index 00000000..7f80e178
--- /dev/null
+++ b/packages/judging-server/src/log.ts
@@ -0,0 +1,3 @@
+export function log(fields: Record) {
+ console.log(JSON.stringify({ time: new Date().toISOString(), ...fields }));
+}
diff --git a/packages/judging-server/src/mail.ts b/packages/judging-server/src/mail.ts
new file mode 100644
index 00000000..89d84954
--- /dev/null
+++ b/packages/judging-server/src/mail.ts
@@ -0,0 +1,12 @@
+import nodemailer from "nodemailer";
+
+/** Sends the sign-in code when PANEL_SMTP_URL is set. */
+export async function sendLoginCode(smtpUrl: string, to: string, code: string) {
+ const transport = nodemailer.createTransport(smtpUrl);
+ await transport.sendMail({
+ from: process.env.PANEL_SMTP_FROM ?? "panel@localhost",
+ to,
+ subject: "Your panel sign-in code",
+ text: `Your sign-in code is ${code}. It expires in 10 minutes.`,
+ });
+}
diff --git a/packages/judging-server/src/metrics.ts b/packages/judging-server/src/metrics.ts
new file mode 100644
index 00000000..5089489a
--- /dev/null
+++ b/packages/judging-server/src/metrics.ts
@@ -0,0 +1,40 @@
+import { Counter, Gauge, Histogram, Registry } from "prom-client";
+
+export function createMetrics() {
+ const register = new Registry();
+ const dispatchSeconds = new Histogram({
+ name: "panel_dispatch_seconds",
+ help: "Time to choose and record the next table",
+ registers: [register],
+ });
+ const votes = new Counter({
+ name: "panel_votes_total",
+ help: "Votes stored",
+ registers: [register],
+ });
+ const outboxPending = new Gauge({
+ name: "panel_outbox_pending",
+ help: "Outbox rows not yet delivered",
+ registers: [register],
+ });
+ const wsConnections = new Gauge({
+ name: "panel_ws_connections",
+ help: "Open websocket clients",
+ registers: [register],
+ });
+ const liveJudges = new Gauge({
+ name: "panel_live_judges",
+ help: "Judges with a visit that is still open",
+ registers: [register],
+ });
+ const coverage = new Histogram({
+ name: "panel_project_looks",
+ help: "How many scores a project had when the latest one landed",
+ buckets: [1, 2, 3, 4, 5, 8, 13],
+ labelNames: ["event_id"],
+ registers: [register],
+ });
+ return { register, dispatchSeconds, votes, outboxPending, wsConnections, liveJudges, coverage };
+}
+
+export type Metrics = ReturnType;
diff --git a/packages/judging-server/src/router.ts b/packages/judging-server/src/router.ts
new file mode 100644
index 00000000..90e44911
--- /dev/null
+++ b/packages/judging-server/src/router.ts
@@ -0,0 +1,674 @@
+import { initTRPC, TRPCError } from "@trpc/server";
+import { and, eq, sql } from "drizzle-orm";
+import { z } from "zod";
+import { assertTransition, boardChannel, judgeChannel, leaderboardChannel } from "@query/judging-core";
+import type { PanelDb } from "@query/judging-db";
+import { event, eventLog, judge, criterion, rubric, visit, vote } from "@query/judging-db";
+import type { Actor, Role } from "./auth";
+import { roleAtLeast } from "./auth";
+import type { Bus } from "./bus";
+import type { Metrics } from "./metrics";
+import { log } from "./log";
+import { arrive } from "./services/arrive";
+import { dispatchNext, judgeProgress, skipVisit, visitStatus } from "./services/dispatch";
+import {
+ assignJudge,
+ applyAsJudge,
+ recallJudge,
+ setJudgeStatus,
+ upsertJudge,
+ upsertProject,
+ voidVisit,
+ withdrawProject,
+ rotateQr,
+ assignEventTables,
+} from "./services/records";
+import {
+ computeResults,
+ diffPlacements,
+ inspectRun,
+ placementsForRun,
+ publishedPlacements,
+ publishResults,
+ unpublishResults,
+} from "./services/results";
+import { castComparison } from "./services/compare";
+import {
+ issueApiKey,
+ listProjects,
+ listLogs,
+ listJudges,
+ listTables,
+ listTracks,
+ placeTable,
+ savePrize,
+ saveRubric,
+ saveTrack,
+ saveWebhook,
+ saveZone,
+ saveConfig,
+} from "./services/catalog";
+import { castVote } from "./services/vote";
+import { drainOutbox } from "./services/outbox";
+
+export type Context = {
+ db: PanelDb;
+ actor: Actor | null;
+ now: Date;
+ bus: Bus;
+ metrics: Metrics;
+};
+
+function tell(
+ ctx: Context,
+ eventId: string,
+ judgeId: string | null,
+ kind: string,
+ visitId?: string,
+) {
+ log({
+ level: "info",
+ message: kind,
+ event_id: eventId,
+ judge_id: judgeId,
+ visit_id: visitId ?? null,
+ });
+ const message = { kind, eventId };
+ ctx.bus.publish(boardChannel(eventId), message);
+ ctx.bus.publish(leaderboardChannel(eventId), message);
+ if (judgeId) ctx.bus.publish(judgeChannel(judgeId), message);
+}
+
+const t = initTRPC.context().create();
+
+function asTrpc(error: unknown): never {
+ const message = error instanceof Error ? error.message : "Request failed";
+ const code = message.includes("not allowed")
+ ? "PRECONDITION_FAILED"
+ : message.includes("not found") || message.includes("not approved")
+ ? "NOT_FOUND"
+ : message.includes("hard limit")
+ ? "CONFLICT"
+ : "BAD_REQUEST";
+ throw new TRPCError({ code, message });
+}
+
+const authed = t.procedure.use(({ ctx, next }) => {
+ if (!ctx.actor) throw new TRPCError({ code: "UNAUTHORIZED" });
+ return next({ ctx: { ...ctx, actor: ctx.actor } });
+});
+
+function atLeast(role: Role) {
+ return authed.use(({ ctx, next }) => {
+ if (!roleAtLeast(ctx.actor.role, role)) {
+ throw new TRPCError({ code: "FORBIDDEN" });
+ }
+ return next();
+ });
+}
+
+async function requireJudge(ctx: Context, eventId: string) {
+ if (!ctx.actor) throw new TRPCError({ code: "UNAUTHORIZED" });
+ const rows = await ctx.db
+ .select()
+ .from(judge)
+ .where(eq(judge.eventId, eventId));
+ const match = rows.find(
+ (row) =>
+ (ctx.actor?.judgeExternalId &&
+ row.externalId === ctx.actor.judgeExternalId) ||
+ row.email.toLowerCase() === ctx.actor?.email.toLowerCase(),
+ );
+ if (!match || match.status !== "approved") {
+ throw new TRPCError({ code: "FORBIDDEN", message: "This judge is not approved" });
+ }
+ return match;
+}
+
+const uuid = z.string().uuid();
+
+export const appRouter = t.router({
+ session: t.router({
+ next: authed
+ .input(z.object({ eventId: uuid }))
+ .mutation(async ({ ctx, input }) => {
+ const row = await requireJudge(ctx, input.eventId);
+ const stop = ctx.metrics.dispatchSeconds.startTimer();
+ try {
+ const outcome = await dispatchNext(ctx.db, {
+ eventId: input.eventId,
+ judgeId: row.id,
+ now: ctx.now,
+ });
+ tell(
+ ctx,
+ input.eventId,
+ row.id,
+ "visit.handed_out",
+ outcome.done ? undefined : outcome.visitId,
+ );
+ return outcome;
+ } catch (error) {
+ asTrpc(error);
+ } finally {
+ stop();
+ }
+ }),
+ arrive: authed
+ .input(
+ z.object({
+ eventId: uuid,
+ qrToken: uuid.optional(),
+ tableNumber: z.number().int().optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const row = await requireJudge(ctx, input.eventId);
+ try {
+ const outcome = await arrive(ctx.db, {
+ eventId: input.eventId,
+ judgeId: row.id,
+ qrToken: input.qrToken,
+ tableNumber: input.tableNumber,
+ now: ctx.now,
+ });
+ tell(ctx, input.eventId, row.id, "visit.arrived", outcome.visitId);
+ return outcome;
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ vote: authed
+ .input(
+ z.object({
+ eventId: uuid,
+ visitId: uuid,
+ comment: z.string().nullable().default(null),
+ scores: z.array(
+ z.object({ criterionId: uuid, value: z.number() }),
+ ),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const row = await requireJudge(ctx, input.eventId);
+ try {
+ const outcome = await castVote(ctx.db, {
+ eventId: input.eventId,
+ judgeId: row.id,
+ visitId: input.visitId,
+ scores: input.scores,
+ comment: input.comment,
+ now: ctx.now,
+ });
+ tell(ctx, input.eventId, row.id, "vote.cast", input.visitId);
+ ctx.metrics.votes.inc();
+ const [current] = await ctx.db
+ .select({ projectId: visit.projectId })
+ .from(visit)
+ .where(eq(visit.id, input.visitId));
+ if (current) {
+ const [looks] = await ctx.db
+ .select({ n: sql`count(*)::int` })
+ .from(vote)
+ .where(
+ and(eq(vote.eventId, input.eventId), eq(vote.projectId, current.projectId)),
+ );
+ ctx.metrics.coverage.observe({ event_id: input.eventId }, looks?.n ?? 1);
+ }
+ return outcome;
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ rubric: authed
+ .input(z.object({ eventId: uuid }))
+ .query(async ({ ctx, input }) => {
+ await requireJudge(ctx, input.eventId);
+ const [rub] = await ctx.db
+ .select({ id: rubric.id })
+ .from(rubric)
+ .where(and(eq(rubric.eventId, input.eventId), eq(rubric.isDefault, true)));
+ if (!rub) return [];
+ return ctx.db
+ .select({
+ id: criterion.id,
+ label: criterion.label,
+ min: criterion.min,
+ max: criterion.max,
+ anchors: criterion.anchors,
+ })
+ .from(criterion)
+ .where(eq(criterion.rubricId, rub.id));
+ }),
+ compare: authed
+ .input(z.object({ eventId: uuid, outcome: z.enum(["a", "b", "tie"]) }))
+ .mutation(async ({ ctx, input }) => {
+ const row = await requireJudge(ctx, input.eventId);
+ try {
+ const outcome = await castComparison(ctx.db, {
+ eventId: input.eventId,
+ judgeId: row.id,
+ outcome: input.outcome,
+ now: ctx.now,
+ });
+ tell(ctx, input.eventId, row.id, "comparison.cast");
+ return outcome;
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ skip: authed
+ .input(z.object({ eventId: uuid, visitId: uuid }))
+ .mutation(async ({ ctx, input }) => {
+ const row = await requireJudge(ctx, input.eventId);
+ try {
+ return await skipVisit(ctx.db, {
+ eventId: input.eventId,
+ judgeId: row.id,
+ visitId: input.visitId,
+ now: ctx.now,
+ });
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ progress: authed
+ .input(z.object({ eventId: uuid }))
+ .query(async ({ ctx, input }) => {
+ const row = await requireJudge(ctx, input.eventId);
+ try {
+ return await judgeProgress(ctx.db, { eventId: input.eventId, judgeId: row.id });
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ status: authed
+ .input(z.object({ eventId: uuid, visitId: uuid }))
+ .query(async ({ ctx, input }) => {
+ const row = await requireJudge(ctx, input.eventId);
+ try {
+ return await visitStatus(ctx.db, {
+ eventId: input.eventId,
+ judgeId: row.id,
+ visitId: input.visitId,
+ });
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ }),
+ event: t.router({
+ setPhase: atLeast("organizer")
+ .input(z.object({ eventId: uuid, phase: z.enum([
+ "setup",
+ "submissions_open",
+ "submissions_closed",
+ "judging_live",
+ "judging_closed",
+ "published",
+ "archived",
+ ]) }))
+ .mutation(async ({ ctx, input }) => {
+ const [current] = await ctx.db
+ .select({ phase: event.phase, id: event.id })
+ .from(event)
+ .where(eq(event.id, input.eventId));
+ if (!current) throw new TRPCError({ code: "NOT_FOUND" });
+ try {
+ assertTransition(current.phase, input.phase);
+ } catch (error) {
+ asTrpc(error);
+ }
+ await ctx.db.transaction(async (tx) => {
+ await tx
+ .update(event)
+ .set({ phase: input.phase })
+ .where(eq(event.id, input.eventId));
+ await tx.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "phase.changed",
+ actor: { email: ctx.actor.email },
+ subject: { eventId: input.eventId },
+ payload: { from: current.phase, to: input.phase },
+ });
+ });
+ tell(ctx, input.eventId, null, "phase.changed");
+ return { phase: input.phase };
+ }),
+ }),
+ project: t.router({
+ upsert: atLeast("organizer")
+ .input(
+ z.object({
+ eventId: uuid,
+ externalId: z.string().min(1),
+ name: z.string().min(1),
+ teamName: z.string().nullable().default(null),
+ tableNumber: z.number().int().nullable().default(null),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ try {
+ return await upsertProject(ctx.db, { ...input, actorEmail: ctx.actor.email });
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ withdraw: atLeast("organizer")
+ .input(z.object({ eventId: uuid, projectId: uuid }))
+ .mutation(async ({ ctx, input }) => {
+ try {
+ await withdrawProject(ctx.db, { ...input, now: ctx.now, actorEmail: ctx.actor.email });
+ return { ok: true };
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ rotateQr: atLeast("organizer")
+ .input(z.object({ eventId: uuid, projectId: uuid }))
+ .mutation(async ({ ctx, input }) => {
+ try {
+ return await rotateQr(ctx.db, { ...input, actorEmail: ctx.actor.email });
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ assignTables: atLeast("organizer")
+ .input(z.object({ eventId: uuid }))
+ .mutation(async ({ ctx, input }) => {
+ return assignEventTables(ctx.db, {
+ eventId: input.eventId,
+ actorEmail: ctx.actor.email,
+ });
+ }),
+ }),
+ judge: t.router({
+ applyToEvent: authed
+ .input(z.object({ eventId: uuid, name: z.string().min(1) }))
+ .mutation(async ({ ctx, input }) => {
+ try {
+ return await applyAsJudge(ctx.db, {
+ eventId: input.eventId,
+ email: ctx.actor.email,
+ name: input.name,
+ });
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ upsert: atLeast("organizer")
+ .input(
+ z.object({
+ eventId: uuid,
+ email: z.string().email(),
+ name: z.string().min(1),
+ externalId: z.string().nullable().default(null),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ try {
+ return await upsertJudge(ctx.db, { ...input, actorEmail: ctx.actor.email });
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ assign: atLeast("organizer")
+ .input(
+ z.object({
+ eventId: uuid,
+ judgeId: uuid,
+ trackId: uuid.nullable(),
+ zoneId: uuid.nullable(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ try {
+ return await assignJudge(ctx.db, { ...input, actorEmail: ctx.actor.email });
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ setStatus: atLeast("organizer")
+ .input(
+ z.object({
+ eventId: uuid,
+ judgeId: uuid,
+ status: z.enum(["approved", "suspended"]),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ await setJudgeStatus(ctx.db, { ...input, actorEmail: ctx.actor.email });
+ return { ok: true };
+ }),
+ recall: atLeast("organizer")
+ .input(z.object({ eventId: uuid, judgeId: uuid }))
+ .mutation(async ({ ctx, input }) => {
+ await recallJudge(ctx.db, { ...input, actorEmail: ctx.actor.email });
+ tell(ctx, input.eventId, input.judgeId, "judge.recalled");
+ return { ok: true };
+ }),
+ voidVisit: atLeast("organizer")
+ .input(z.object({ eventId: uuid, visitId: uuid, reason: z.string().min(1) }))
+ .mutation(async ({ ctx, input }) => {
+ try {
+ const voided = await voidVisit(ctx.db, { ...input, now: ctx.now, actorEmail: ctx.actor.email });
+ tell(ctx, input.eventId, voided.judgeId, "visit.voided", input.visitId);
+ return { ok: true };
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ }),
+ results: t.router({
+ compute: atLeast("organizer")
+ .input(z.object({ eventId: uuid }))
+ .mutation(async ({ ctx, input }) => {
+ try {
+ return await computeResults(ctx.db, {
+ eventId: input.eventId,
+ actorEmail: ctx.actor.email,
+ now: ctx.now,
+ });
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ publish: atLeast("organizer")
+ .input(z.object({ eventId: uuid, runId: uuid }))
+ .mutation(async ({ ctx, input }) => {
+ try {
+ const outcome = await publishResults(ctx.db, {
+ ...input,
+ actorEmail: ctx.actor.email,
+ now: ctx.now,
+ });
+ tell(ctx, input.eventId, null, "results.published");
+ // Nothing drains the queue on a timer, and publish is the one step
+ // every event takes. Webhooks go out here; rows nobody listens for
+ // are cleared. A failed delivery stays queued for `panel outbox drain`.
+ for (let batch = 0; batch < 20; batch += 1) {
+ const delivered = await drainOutbox(ctx.db).catch(() => 0);
+ if (delivered === 0) break;
+ }
+ return outcome;
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ inspect: atLeast("organizer")
+ .input(z.object({ runId: uuid }))
+ .query(({ ctx, input }) => inspectRun(ctx.db, input.runId)),
+ diff: atLeast("organizer")
+ .input(z.object({ runA: uuid, runB: uuid }))
+ .query(async ({ ctx, input }) => {
+ const [left, right] = await Promise.all([
+ placementsForRun(ctx.db, input.runA),
+ placementsForRun(ctx.db, input.runB),
+ ]);
+ return diffPlacements(left, right);
+ }),
+ unpublish: atLeast("organizer")
+ .input(z.object({ eventId: uuid }))
+ .mutation(async ({ ctx, input }) => {
+ try {
+ const outcome = await unpublishResults(ctx.db, {
+ eventId: input.eventId,
+ actorEmail: ctx.actor.email,
+ });
+ tell(ctx, input.eventId, null, "results.unpublished");
+ return outcome;
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ export: atLeast("organizer")
+ .input(z.object({ eventId: uuid }))
+ .query(({ ctx, input }) => publishedPlacements(ctx.db, input.eventId)),
+ }),
+ catalog: t.router({
+ tracks: atLeast("organizer")
+ .input(z.object({ eventId: uuid }))
+ .query(({ ctx, input }) => listTracks(ctx.db, input.eventId)),
+ projects: atLeast("organizer")
+ .input(z.object({ eventId: uuid }))
+ .query(({ ctx, input }) => listProjects(ctx.db, input.eventId)),
+ logs: atLeast("organizer")
+ .input(z.object({ eventId: uuid }))
+ .query(({ ctx, input }) => listLogs(ctx.db, input.eventId)),
+ tables: atLeast("organizer")
+ .input(z.object({ eventId: uuid }))
+ .query(({ ctx, input }) => listTables(ctx.db, input.eventId)),
+ judges: atLeast("organizer")
+ .input(z.object({ eventId: uuid }))
+ .query(({ ctx, input }) => listJudges(ctx.db, input.eventId)),
+ saveTrack: atLeast("organizer")
+ .input(
+ z.object({
+ eventId: uuid,
+ slug: z.string().min(1),
+ name: z.string().min(1),
+ kind: z.enum(["main", "sponsor", "special"]),
+ judgeGroup: z.string().min(1),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ try {
+ return await saveTrack(ctx.db, { ...input, actorEmail: ctx.actor.email });
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ savePrize: atLeast("organizer")
+ .input(
+ z.object({
+ trackId: uuid,
+ place: z.number().int(),
+ title: z.string().min(1),
+ amount: z.number().nullable().default(null),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ try {
+ return await savePrize(ctx.db, { ...input, actorEmail: ctx.actor.email });
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ saveZone: atLeast("organizer")
+ .input(
+ z.object({
+ eventId: uuid,
+ name: z.string().min(1),
+ position: z.number().int().default(0),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ try {
+ return await saveZone(ctx.db, { ...input, actorEmail: ctx.actor.email });
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ placeTable: atLeast("organizer")
+ .input(
+ z.object({
+ eventId: uuid,
+ number: z.number().int(),
+ zoneId: uuid.nullable().default(null),
+ x: z.number().nullable().default(null),
+ y: z.number().nullable().default(null),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ await placeTable(ctx.db, { ...input, actorEmail: ctx.actor.email });
+ return { ok: true };
+ }),
+ saveRubric: atLeast("organizer")
+ .input(
+ z.object({
+ eventId: uuid,
+ name: z.string().min(1),
+ isDefault: z.boolean().default(false),
+ criteria: z.array(
+ z.object({
+ key: z.string().min(1),
+ label: z.string().min(1),
+ min: z.number(),
+ max: z.number(),
+ weight: z.number(),
+ }),
+ ),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ try {
+ return await saveRubric(ctx.db, { ...input, actorEmail: ctx.actor.email });
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ saveWebhook: atLeast("organizer")
+ .input(
+ z.object({
+ eventId: uuid,
+ url: z.string().url(),
+ secret: z.string().min(8),
+ topics: z.array(z.string()).min(1),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ try {
+ return await saveWebhook(ctx.db, { ...input, actorEmail: ctx.actor.email });
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ issueApiKey: atLeast("admin")
+ .input(z.object({ eventId: uuid, scopes: z.array(z.string()).min(1) }))
+ .mutation(async ({ ctx, input }) => {
+ try {
+ return await issueApiKey(ctx.db, { ...input, actorEmail: ctx.actor.email });
+ } catch (error) {
+ asTrpc(error);
+ }
+ }),
+ saveConfig: atLeast("organizer")
+ .input(
+ z.object({
+ eventId: uuid,
+ hardLimitSeconds: z.number().int().positive(),
+ dispatchStrategy: z.enum(["coverage", "uncertainty"]),
+ pairwiseEnabled: z.boolean(),
+ pairwiseWeight: z.number().min(0).max(1),
+ leaderboardPublic: z.boolean(),
+ boardPollSeconds: z.number().int().positive(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ await saveConfig(ctx.db, { ...input, actorEmail: ctx.actor.email });
+ return { ok: true };
+ }),
+ }),
+});
+
+export type AppRouter = typeof appRouter;
diff --git a/packages/judging-server/src/services/arrive.ts b/packages/judging-server/src/services/arrive.ts
new file mode 100644
index 00000000..616e98f7
--- /dev/null
+++ b/packages/judging-server/src/services/arrive.ts
@@ -0,0 +1,76 @@
+import { and, eq, isNull } from "drizzle-orm";
+import type { PanelDb } from "@query/judging-db";
+import { event, eventLog, outbox, project, visit } from "@query/judging-db";
+import { phaseAllows } from "@query/judging-core";
+
+export async function arrive(
+ db: PanelDb,
+ input: {
+ eventId: string;
+ judgeId: string;
+ qrToken?: string;
+ tableNumber?: number;
+ now: Date;
+ },
+): Promise<{ visitId: string; projectId: string }> {
+ return db.transaction(async (tx) => {
+ const [evt] = await tx
+ .select({ phase: event.phase })
+ .from(event)
+ .where(eq(event.id, input.eventId));
+ if (!evt) throw new Error("Event not found");
+ if (!phaseAllows(evt.phase, "dispatch")) {
+ throw new Error(`dispatch is not allowed while the event is ${evt.phase}`);
+ }
+
+ const [row] = await tx
+ .select()
+ .from(visit)
+ .where(
+ and(
+ eq(visit.judgeId, input.judgeId),
+ eq(visit.eventId, input.eventId),
+ isNull(visit.completedAt),
+ isNull(visit.voidedAt),
+ ),
+ );
+ if (!row) throw new Error("No table is in hand");
+
+ const [current] = await tx
+ .select()
+ .from(project)
+ .where(eq(project.id, row.projectId));
+ if (!current) throw new Error("Project not found");
+
+ const qrOk = input.qrToken !== undefined && input.qrToken === current.qrToken;
+ const tableOk =
+ input.tableNumber !== undefined && input.tableNumber === current.tableNumber;
+ if (!qrOk && !tableOk) throw new Error("That is not the table you were sent to");
+
+ if (row.arrivedAt) return { visitId: row.id, projectId: row.projectId };
+
+ await tx
+ .update(visit)
+ .set({ arrivedAt: input.now })
+ .where(eq(visit.id, row.id));
+ if (!current.arrivedFirstAt) {
+ await tx
+ .update(project)
+ .set({ arrivedFirstAt: input.now })
+ .where(eq(project.id, current.id));
+ }
+ await tx.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "visit.arrived",
+ actor: { judgeId: input.judgeId },
+ subject: { visitId: row.id, projectId: row.projectId },
+ payload: {},
+ });
+ await tx.insert(outbox).values({
+ eventId: input.eventId,
+ topic: "visit.arrived",
+ payload: { visitId: row.id, judgeId: input.judgeId },
+ });
+ return { visitId: row.id, projectId: row.projectId };
+ });
+}
diff --git a/packages/judging-server/src/services/catalog.ts b/packages/judging-server/src/services/catalog.ts
new file mode 100644
index 00000000..8f1e3996
--- /dev/null
+++ b/packages/judging-server/src/services/catalog.ts
@@ -0,0 +1,454 @@
+import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
+import type { PanelDb } from "@query/judging-db";
+import {
+ apiKey,
+ criterion,
+ event,
+ eventConfig,
+ eventLog,
+ floorTable,
+ judge,
+ organization,
+ prize,
+ project,
+ projectTrack,
+ result,
+ resultRun,
+ rubric,
+ track,
+ vote,
+ voteScore,
+ webhook,
+ zone,
+} from "@query/judging-db";
+import { randomBytes } from "node:crypto";
+import type { Actor } from "../auth";
+import { hashApiKey, roleForScopes } from "../auth";
+import { feedbackStatus, median } from "./feedback";
+
+async function placementsForProject(db: PanelDb, projectId: string) {
+ const links = await db
+ .select({ trackId: track.id, track: track.name })
+ .from(projectTrack)
+ .innerJoin(track, eq(track.id, projectTrack.trackId))
+ .where(eq(projectTrack.projectId, projectId));
+ const placed = await db
+ .select({ trackId: result.trackId, placement: result.placement })
+ .from(result)
+ .innerJoin(resultRun, eq(resultRun.id, result.runId))
+ .where(and(eq(result.projectId, projectId), isNotNull(resultRun.publishedAt)));
+ const placeOf = new Map(placed.map((row) => [row.trackId, row.placement]));
+ return links.map((link) => ({
+ track: link.track,
+ placement: placeOf.get(link.trackId) ?? null,
+ }));
+}
+
+/** The published card token for a project the club already knows by id. */
+export async function feedbackTokenForExternal(db: PanelDb, eventId: string, externalId: string) {
+ const [row] = await db
+ .select({ token: project.feedbackToken, phase: event.phase })
+ .from(project)
+ .innerJoin(event, eq(event.id, project.eventId))
+ .where(and(eq(project.eventId, eventId), eq(project.externalId, externalId)));
+ if (!row?.token || row.phase !== "published") return null;
+ return row.token;
+}
+
+export async function feedbackCard(db: PanelDb, token: string) {
+ const [row] = await db
+ .select({
+ projectId: project.id,
+ name: project.name,
+ eventId: project.eventId,
+ phase: event.phase,
+ stored: project.feedbackToken,
+ })
+ .from(project)
+ .innerJoin(event, eq(event.id, project.eventId))
+ .where(eq(project.feedbackToken, token));
+ if (
+ feedbackStatus({
+ published: row?.phase === "published",
+ tokenMatches: row?.stored === token,
+ }) === "forbidden" ||
+ !row
+ ) {
+ return { status: "forbidden" as const };
+ }
+
+ const scores = await db
+ .select({
+ projectId: vote.projectId,
+ criterionId: voteScore.criterionId,
+ label: criterion.label,
+ value: voteScore.value,
+ })
+ .from(voteScore)
+ .innerJoin(vote, eq(vote.id, voteScore.voteId))
+ .innerJoin(criterion, eq(criterion.id, voteScore.criterionId))
+ .where(eq(vote.eventId, row.eventId));
+
+ const byCriterion = new Map();
+ for (const score of scores) {
+ const bucket = byCriterion.get(score.criterionId) ?? {
+ label: score.label,
+ mine: [],
+ all: [],
+ };
+ bucket.all.push(score.value);
+ if (score.projectId === row.projectId) bucket.mine.push(score.value);
+ byCriterion.set(score.criterionId, bucket);
+ }
+
+ return {
+ status: "ok" as const,
+ name: row.name,
+ criteria: [...byCriterion.values()].map((bucket) => ({
+ label: bucket.label,
+ mean:
+ bucket.mine.length === 0
+ ? 0
+ : bucket.mine.reduce((sum, value) => sum + value, 0) / bucket.mine.length,
+ median: median(bucket.all),
+ })),
+ places: await placementsForProject(db, row.projectId),
+ comments: (
+ await db
+ .select({ comment: vote.comment })
+ .from(vote)
+ .where(and(eq(vote.projectId, row.projectId), isNotNull(vote.comment)))
+ )
+ .map((item) => item.comment)
+ .filter((comment): comment is string => Boolean(comment)),
+ };
+}
+
+export async function saveTrack(
+ db: PanelDb,
+ input: {
+ eventId: string;
+ slug: string;
+ name: string;
+ kind: "main" | "sponsor" | "special";
+ judgeGroup: string;
+ actorEmail: string;
+ },
+) {
+ const [created] = await db
+ .insert(track)
+ .values(input)
+ .returning({ id: track.id });
+ if (!created) throw new Error("Track was not stored");
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "track.saved",
+ actor: { email: input.actorEmail },
+ subject: { trackId: created.id },
+ payload: { slug: input.slug },
+ });
+ return created;
+}
+
+export async function listTracks(db: PanelDb, eventId: string) {
+ return db.select().from(track).where(eq(track.eventId, eventId));
+}
+
+export async function savePrize(
+ db: PanelDb,
+ input: {
+ trackId: string;
+ place: number;
+ title: string;
+ amount: number | null;
+ actorEmail: string;
+ },
+) {
+ const [created] = await db
+ .insert(prize)
+ .values({
+ trackId: input.trackId,
+ place: input.place,
+ title: input.title,
+ amount: input.amount,
+ })
+ .returning({ id: prize.id });
+ if (!created) throw new Error("Prize was not stored");
+ const [owner] = await db
+ .select({ eventId: track.eventId })
+ .from(track)
+ .where(eq(track.id, input.trackId));
+ if (owner) {
+ await db.insert(eventLog).values({
+ eventId: owner.eventId,
+ kind: "prize.saved",
+ actor: { email: input.actorEmail },
+ subject: { prizeId: created.id, trackId: input.trackId },
+ payload: { title: input.title, place: input.place },
+ });
+ }
+ return created;
+}
+
+export async function saveZone(
+ db: PanelDb,
+ input: { eventId: string; name: string; position: number; actorEmail: string },
+) {
+ const [created] = await db
+ .insert(zone)
+ .values({ eventId: input.eventId, name: input.name, position: input.position })
+ .returning({ id: zone.id });
+ if (!created) throw new Error("Zone was not stored");
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "zone.saved",
+ actor: { email: input.actorEmail },
+ subject: { zoneId: created.id },
+ payload: { name: input.name },
+ });
+ return created;
+}
+
+export async function placeTable(
+ db: PanelDb,
+ input: {
+ eventId: string;
+ number: number;
+ zoneId: string | null;
+ x: number | null;
+ y: number | null;
+ actorEmail: string;
+ },
+) {
+ await db
+ .insert(floorTable)
+ .values(input)
+ .onConflictDoUpdate({
+ target: [floorTable.eventId, floorTable.number],
+ set: { zoneId: input.zoneId, x: input.x, y: input.y },
+ });
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "table.placed",
+ actor: { email: input.actorEmail },
+ subject: { number: input.number },
+ payload: { x: input.x, y: input.y },
+ });
+}
+
+export async function saveRubric(
+ db: PanelDb,
+ input: {
+ eventId: string;
+ name: string;
+ isDefault: boolean;
+ criteria: { key: string; label: string; min: number; max: number; weight: number }[];
+ actorEmail: string;
+ },
+) {
+ const [created] = await db
+ .insert(rubric)
+ .values({
+ eventId: input.eventId,
+ name: input.name,
+ isDefault: input.isDefault,
+ })
+ .returning({ id: rubric.id });
+ if (!created) throw new Error("Rubric was not stored");
+ if (input.criteria.length > 0) {
+ await db.insert(criterion).values(
+ input.criteria.map((item, position) => ({
+ rubricId: created.id,
+ position,
+ key: item.key,
+ label: item.label,
+ min: item.min,
+ max: item.max,
+ weight: item.weight,
+ })),
+ );
+ }
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "rubric.saved",
+ actor: { email: input.actorEmail },
+ subject: { rubricId: created.id },
+ payload: {},
+ });
+ return created;
+}
+
+export async function saveWebhook(
+ db: PanelDb,
+ input: {
+ eventId: string;
+ url: string;
+ secret: string;
+ topics: string[];
+ actorEmail: string;
+ },
+) {
+ const [evt] = await db
+ .select({ orgId: event.orgId })
+ .from(event)
+ .where(eq(event.id, input.eventId));
+ if (!evt) throw new Error("Event not found");
+ const [created] = await db
+ .insert(webhook)
+ .values({
+ orgId: evt.orgId,
+ url: input.url,
+ secret: input.secret,
+ topics: input.topics,
+ })
+ .returning({ id: webhook.id });
+ if (!created) throw new Error("Webhook was not stored");
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "webhook.saved",
+ actor: { email: input.actorEmail },
+ subject: { webhookId: created.id },
+ payload: { url: input.url },
+ });
+ return created;
+}
+
+export async function issueApiKey(
+ db: PanelDb,
+ input: { eventId: string; scopes: string[]; actorEmail: string },
+) {
+ const [evt] = await db
+ .select({ orgId: organization.id })
+ .from(event)
+ .innerJoin(organization, eq(organization.id, event.orgId))
+ .where(eq(event.id, input.eventId));
+ if (!evt) throw new Error("Event not found");
+ const token = randomBytes(24).toString("hex");
+ const [created] = await db
+ .insert(apiKey)
+ .values({
+ orgId: evt.orgId,
+ hashedKey: hashApiKey(token),
+ scopes: input.scopes,
+ })
+ .returning({ id: apiKey.id });
+ if (!created) throw new Error("API key was not stored");
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "api_key.created",
+ actor: { email: input.actorEmail },
+ subject: { apiKeyId: created.id },
+ payload: {},
+ });
+ return { id: created.id, token };
+}
+
+export async function actorForApiKey(db: PanelDb, token: string): Promise {
+ const [row] = await db
+ .select()
+ .from(apiKey)
+ .where(and(eq(apiKey.hashedKey, hashApiKey(token)), isNull(apiKey.revokedAt)));
+ if (!row) return null;
+ const granted = roleForScopes(row.scopes);
+ if (!granted) return null;
+ const [org] = await db
+ .select({ slug: organization.slug })
+ .from(organization)
+ .where(eq(organization.id, row.orgId));
+ if (!org) return null;
+ return {
+ sub: row.id,
+ email: `key:${row.id}`,
+ name: "API key",
+ org: org.slug,
+ role: granted,
+ judgeExternalId: null,
+ };
+}
+
+export async function listTables(db: PanelDb, eventId: string) {
+ return db
+ .select({
+ number: floorTable.number,
+ zoneId: floorTable.zoneId,
+ x: floorTable.x,
+ y: floorTable.y,
+ })
+ .from(floorTable)
+ .where(eq(floorTable.eventId, eventId));
+}
+
+export async function listJudges(db: PanelDb, eventId: string) {
+ return db
+ .select({
+ id: judge.id,
+ name: judge.name,
+ email: judge.email,
+ status: judge.status,
+ })
+ .from(judge)
+ .where(eq(judge.eventId, eventId));
+}
+
+export async function listProjects(db: PanelDb, eventId: string) {
+ return db
+ .select({
+ id: project.id,
+ name: project.name,
+ tableNumber: project.tableNumber,
+ zoneId: project.zoneId,
+ zoneName: zone.name,
+ })
+ .from(project)
+ .leftJoin(zone, eq(zone.id, project.zoneId))
+ .where(eq(project.eventId, eventId));
+}
+
+export async function listLogs(db: PanelDb, eventId: string) {
+ return db
+ .select({
+ id: eventLog.id,
+ kind: eventLog.kind,
+ createdAt: eventLog.createdAt,
+ })
+ .from(eventLog)
+ .where(eq(eventLog.eventId, eventId))
+ .orderBy(desc(eventLog.createdAt))
+ .limit(40);
+}
+
+export async function saveConfig(
+ db: PanelDb,
+ input: {
+ eventId: string;
+ hardLimitSeconds: number;
+ dispatchStrategy: "coverage" | "uncertainty";
+ pairwiseEnabled: boolean;
+ pairwiseWeight: number;
+ leaderboardPublic: boolean;
+ boardPollSeconds: number;
+ actorEmail: string;
+ },
+) {
+ await db
+ .update(eventConfig)
+ .set({
+ hardLimitSeconds: input.hardLimitSeconds,
+ dispatchStrategy: input.dispatchStrategy,
+ pairwiseEnabled: input.pairwiseEnabled,
+ pairwiseWeight: input.pairwiseWeight,
+ leaderboardPublic: input.leaderboardPublic,
+ boardPollSeconds: input.boardPollSeconds,
+ })
+ .where(eq(eventConfig.eventId, input.eventId));
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "config.saved",
+ actor: { email: input.actorEmail },
+ subject: { eventId: input.eventId },
+ payload: {
+ dispatchStrategy: input.dispatchStrategy,
+ hardLimitSeconds: input.hardLimitSeconds,
+ },
+ });
+}
diff --git a/packages/judging-server/src/services/compare.ts b/packages/judging-server/src/services/compare.ts
new file mode 100644
index 00000000..f1220ce7
--- /dev/null
+++ b/packages/judging-server/src/services/compare.ts
@@ -0,0 +1,63 @@
+import { and, desc, eq, isNull } from "drizzle-orm";
+import { phaseAllows } from "@query/judging-core";
+import type { PanelDb } from "@query/judging-db";
+import { comparison, event, eventLog, visit } from "@query/judging-db";
+
+/** The judge's previous table against the one they are at. */
+export async function castComparison(
+ db: PanelDb,
+ input: {
+ eventId: string;
+ judgeId: string;
+ outcome: "a" | "b" | "tie";
+ now: Date;
+ },
+) {
+ const [evt] = await db
+ .select({ phase: event.phase })
+ .from(event)
+ .where(eq(event.id, input.eventId));
+ if (!evt) throw new Error("Event not found");
+ if (!phaseAllows(evt.phase, "vote")) {
+ throw new Error(`vote is not allowed while the event is ${evt.phase}`);
+ }
+
+ const visits = await db
+ .select()
+ .from(visit)
+ .where(
+ and(
+ eq(visit.eventId, input.eventId),
+ eq(visit.judgeId, input.judgeId),
+ isNull(visit.voidedAt),
+ ),
+ )
+ .orderBy(desc(visit.handedOutAt));
+ const current = visits.find((row) => !row.completedAt);
+ const earlier = visits.find((row) => row.completedAt);
+ if (!current) throw new Error("No table is in hand");
+ if (!earlier) throw new Error("Compare after a finished table");
+
+ const [row] = await db
+ .insert(comparison)
+ .values({
+ eventId: input.eventId,
+ judgeId: input.judgeId,
+ judgeGroup: current.judgeGroup,
+ aProjectId: earlier.projectId,
+ bProjectId: current.projectId,
+ outcome: input.outcome,
+ createdAt: input.now,
+ })
+ .returning({ id: comparison.id });
+ if (!row) throw new Error("Comparison was not stored");
+
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "comparison.cast",
+ actor: { judgeId: input.judgeId },
+ subject: { comparisonId: row.id },
+ payload: { outcome: input.outcome },
+ });
+ return { id: row.id };
+}
diff --git a/packages/judging-server/src/services/dispatch.concurrent.test.ts b/packages/judging-server/src/services/dispatch.concurrent.test.ts
new file mode 100644
index 00000000..e39c0584
--- /dev/null
+++ b/packages/judging-server/src/services/dispatch.concurrent.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import { dispatchNext } from "./dispatch";
+import { createDb, databaseUrl, migrate } from "@query/judging-db";
+
+const url = databaseUrl();
+
+describe.skipIf(!url)("concurrent dispatch", () => {
+ it("does not hand two judges the same project", async () => {
+ const { db, pool } = createDb(url as string);
+ try {
+ await migrate(pool);
+ const { seedDemo, judge, project } = await import("@query/judging-db");
+ const { eq } = await import("drizzle-orm");
+ const { eventId } = await seedDemo(db);
+ const judges = await db.select().from(judge).where(eq(judge.eventId, eventId));
+ const first = judges[0];
+ const second = judges[1];
+ if (!first || !second) throw new Error("demo judges missing");
+ const now = new Date();
+ const [a, b] = await Promise.all([
+ dispatchNext(db, { eventId, judgeId: first.id, now }),
+ dispatchNext(db, { eventId, judgeId: second.id, now }),
+ ]);
+ expect(a.done).toBe(false);
+ expect(b.done).toBe(false);
+ if (!a.done && !b.done) expect(a.projectId).not.toBe(b.projectId);
+ const rows = await db.select().from(project).where(eq(project.eventId, eventId));
+ expect(rows.length).toBeGreaterThan(1);
+ } finally {
+ await pool.end();
+ }
+ });
+});
diff --git a/packages/judging-server/src/services/dispatch.ts b/packages/judging-server/src/services/dispatch.ts
new file mode 100644
index 00000000..97d21031
--- /dev/null
+++ b/packages/judging-server/src/services/dispatch.ts
@@ -0,0 +1,455 @@
+import { randomInt } from "node:crypto";
+import { and, desc, eq, gte, isNull, sql } from "drizzle-orm";
+import { isLive, pickNext } from "@query/judging-core";
+import type { TimerConfig } from "@query/judging-core";
+import type { PanelDb, PanelTx } from "@query/judging-db";
+import {
+ event,
+ eventConfig,
+ eventLog,
+ judge,
+ outbox,
+ project,
+ projectTrack,
+ track,
+ visit,
+ vote,
+} from "@query/judging-db";
+import { isDeliveredTopic, logsForHandout } from "./logs";
+
+export type DispatchOutcome =
+ | { done: true }
+ | {
+ done: false;
+ reused: boolean;
+ visitId: string;
+ projectId: string;
+ projectName: string;
+ tableNumber: number | null;
+ zoneId: string | null;
+ handedOutAt: Date;
+ arrivedAt: Date | null;
+ };
+
+type VisitRow = typeof visit.$inferSelect;
+type ProjectRow = typeof project.$inferSelect;
+
+export async function dispatchNext(
+ db: PanelDb,
+ input: { eventId: string; judgeId: string; now: Date },
+): Promise {
+ return db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${`dispatch:${input.eventId}`}))`,
+ );
+
+ const phase = await loadPhase(tx, input.eventId);
+ if (phase !== "judging_live") {
+ throw new Error(`dispatch is not allowed while the event is ${phase}`);
+ }
+ const config = await loadConfig(tx, input.eventId);
+ const judgeRow = await loadJudge(tx, input.judgeId, input.eventId);
+ if (judgeRow.status !== "approved") {
+ throw new Error("This judge is not approved");
+ }
+
+ const projects = await loadPool(tx, input.eventId, judgeRow.trackId);
+ const poolIds = new Set(projects.map((item) => item.id));
+ const mine = await tx
+ .select()
+ .from(visit)
+ .where(
+ and(eq(visit.judgeId, input.judgeId), eq(visit.eventId, input.eventId)),
+ );
+
+ const lapsed = mine.filter(
+ (row) =>
+ !row.completedAt &&
+ !row.voidedAt &&
+ (row.handedOutAt || row.arrivedAt) &&
+ (!isLive(clock(row), input.now, config) || !poolIds.has(row.projectId)),
+ );
+ if (lapsed.length > 0) {
+ for (const row of lapsed) {
+ await tx
+ .update(visit)
+ .set({
+ voidedAt: input.now,
+ voidReason: poolIds.has(row.projectId) ? "lapsed" : "withdrawn",
+ })
+ .where(eq(visit.id, row.id));
+ }
+ }
+
+ const open = mine.find(
+ (row) =>
+ !lapsed.includes(row) &&
+ !row.completedAt &&
+ !row.voidedAt &&
+ isLive(clock(row), input.now, config),
+ );
+ if (open) {
+ const current = projects.find((item) => item.id === open.projectId);
+ return {
+ done: false as const,
+ reused: true,
+ visitId: open.id,
+ projectId: open.projectId,
+ projectName: current?.name ?? "",
+ tableNumber: current?.tableNumber ?? null,
+ zoneId: current?.zoneId ?? null,
+ handedOutAt: open.handedOutAt,
+ arrivedAt: open.arrivedAt,
+ };
+ }
+
+ const seen = new Set(
+ mine.filter((row) => !row.voidedAt).map((row) => row.projectId),
+ );
+ for (const row of lapsed) seen.add(row.projectId);
+ const unseen = projects.filter((item) => !seen.has(item.id));
+ const myGroup = await groupOf(tx, judgeRow.trackId);
+ if (unseen.length === 0) {
+ await record(
+ tx,
+ input,
+ lapsed.map((row) => row.id),
+ null,
+ );
+ return { done: true as const };
+ }
+
+ const picked = await choose(
+ tx,
+ input,
+ unseen,
+ mine,
+ projects,
+ myGroup,
+ config,
+ );
+ const choice = picked ? unseen.find((item) => item.id === picked.id) : undefined;
+ if (!choice) {
+ await record(
+ tx,
+ input,
+ lapsed.map((row) => row.id),
+ null,
+ );
+ return { done: true as const };
+ }
+
+ const [created] = await tx
+ .insert(visit)
+ .values({
+ eventId: input.eventId,
+ judgeId: input.judgeId,
+ projectId: choice.id,
+ judgeGroup: myGroup,
+ handedOutAt: input.now,
+ })
+ .returning();
+ if (!created) throw new Error("visit was not created");
+
+ await record(tx, input, lapsed.map((row) => row.id), {
+ visitId: created.id,
+ projectId: choice.id,
+ });
+
+ return {
+ done: false as const,
+ reused: false,
+ visitId: created.id,
+ projectId: choice.id,
+ projectName: choice.name,
+ tableNumber: choice.tableNumber,
+ zoneId: choice.zoneId,
+ handedOutAt: created.handedOutAt,
+ arrivedAt: null,
+ };
+ });
+}
+
+async function record(
+ tx: PanelTx,
+ input: { eventId: string; judgeId: string },
+ voidedVisitIds: readonly string[],
+ handout: { visitId: string; projectId: string } | null,
+) {
+ const drafts = handout
+ ? logsForHandout(voidedVisitIds)
+ : voidedVisitIds.map((visitId) => ({
+ kind: "visit.voided" as const,
+ subject: { visitId } as Record,
+ payload: {} as Record,
+ }));
+ if (handout) {
+ const handed = drafts[drafts.length - 1];
+ if (handed) handed.subject = handout;
+ }
+ if (drafts.length === 0) return;
+
+ await tx.insert(eventLog).values(
+ drafts.map((draft) => ({
+ eventId: input.eventId,
+ kind: draft.kind,
+ actor: { judgeId: input.judgeId },
+ subject: draft.subject,
+ payload: draft.payload,
+ })),
+ );
+ const topics = drafts.filter((draft) => isDeliveredTopic(draft.kind));
+ if (topics.length === 0) return;
+ await tx.insert(outbox).values(
+ topics.map((draft) => ({
+ eventId: input.eventId,
+ topic: draft.kind,
+ payload: { ...draft.subject, judgeId: input.judgeId },
+ })),
+ );
+}
+
+function clock(row: VisitRow) {
+ return {
+ handedOutAt: row.handedOutAt,
+ arrivedAt: row.arrivedAt,
+ completedAt: row.completedAt ?? row.voidedAt,
+ };
+}
+
+async function loadPhase(tx: PanelDb | PanelTx, eventId: string) {
+ const [row] = await tx
+ .select({ phase: event.phase })
+ .from(event)
+ .where(eq(event.id, eventId));
+ if (!row) throw new Error("Event not found");
+ return row.phase;
+}
+
+async function loadConfig(tx: PanelTx, eventId: string): Promise {
+ const [row] = await tx
+ .select()
+ .from(eventConfig)
+ .where(eq(eventConfig.eventId, eventId));
+ if (!row) throw new Error("Event config not found");
+ return {
+ targetSeconds: row.targetSeconds,
+ hardLimitSeconds: row.hardLimitSeconds,
+ walkLimitSeconds: row.walkLimitSeconds,
+ submitGraceSeconds: row.submitGraceSeconds,
+ strategy: row.dispatchStrategy,
+ minLooksPerProject: row.minLooksPerProject,
+ };
+}
+
+async function loadJudge(tx: PanelDb | PanelTx, judgeId: string, eventId: string) {
+ const [row] = await tx
+ .select()
+ .from(judge)
+ .where(and(eq(judge.id, judgeId), eq(judge.eventId, eventId)));
+ if (!row) throw new Error("Judge not found");
+ return row;
+}
+
+async function loadPool(tx: PanelDb | PanelTx, eventId: string, trackId: string | null) {
+ const projects = await tx
+ .select()
+ .from(project)
+ .where(and(eq(project.eventId, eventId), isNull(project.withdrawnAt)));
+ if (!trackId) return projects;
+ const links = await tx
+ .select({ projectId: projectTrack.projectId })
+ .from(projectTrack)
+ .where(eq(projectTrack.trackId, trackId));
+ const allowed = new Set(links.map((link) => link.projectId));
+ return projects.filter((item) => allowed.has(item.id));
+}
+
+async function groupOf(tx: PanelTx, trackId: string | null) {
+ if (!trackId) return "main";
+ const [row] = await tx
+ .select({ judgeGroup: track.judgeGroup })
+ .from(track)
+ .where(eq(track.id, trackId));
+ return row?.judgeGroup ?? "main";
+}
+
+async function choose(
+ tx: PanelTx,
+ input: { eventId: string; judgeId: string; now: Date },
+ unseen: ProjectRow[],
+ mine: VisitRow[],
+ projects: ProjectRow[],
+ myGroup: string,
+ config: TimerConfig & {
+ strategy: "coverage" | "uncertainty";
+ minLooksPerProject: number;
+ },
+) {
+ const judges = await tx
+ .select({ id: judge.id, trackId: judge.trackId })
+ .from(judge)
+ .where(eq(judge.eventId, input.eventId));
+ const tracks = await tx
+ .select({ id: track.id, judgeGroup: track.judgeGroup })
+ .from(track)
+ .where(eq(track.eventId, input.eventId));
+ const groupByJudge = new Map(
+ judges.map((item) => [
+ item.id,
+ tracks.find((entry) => entry.id === item.trackId)?.judgeGroup ?? "main",
+ ]),
+ );
+
+ const coverage = new Map();
+ const bump = (projectId: string) =>
+ coverage.set(projectId, (coverage.get(projectId) ?? 0) + 1);
+
+ const votes = await tx
+ .select({ judgeId: vote.judgeId, projectId: vote.projectId })
+ .from(vote)
+ .where(eq(vote.eventId, input.eventId));
+ for (const row of votes) {
+ if (groupByJudge.get(row.judgeId) === myGroup) bump(row.projectId);
+ }
+
+ const open = await tx
+ .select()
+ .from(visit)
+ .where(and(eq(visit.eventId, input.eventId), isNull(visit.completedAt), isNull(visit.voidedAt)));
+ const claimedAt = new Map();
+ for (const row of open) {
+ if (row.judgeId === input.judgeId) continue;
+ if (!isLive(clock(row), input.now, config)) continue;
+ if (groupByJudge.get(row.judgeId) !== myGroup) continue;
+ bump(row.projectId);
+ const at = (row.arrivedAt ?? row.handedOutAt).getTime();
+ claimedAt.set(row.projectId, Math.max(claimedAt.get(row.projectId) ?? 0, at));
+ }
+
+ const byId = new Map(projects.map((item) => [item.id, item]));
+ const last = [...mine].sort(
+ (a, b) => b.handedOutAt.getTime() - a.handedOutAt.getTime(),
+ )[0];
+ const lastProject = last ? byId.get(last.projectId) : undefined;
+
+ return pickNext(
+ unseen.map((item) => ({
+ id: item.id,
+ tableNumber: item.tableNumber ?? 0,
+ zoneId: item.zoneId,
+ coverage: coverage.get(item.id) ?? 0,
+ claimedAt: claimedAt.get(item.id) ?? null,
+ })),
+ {
+ lastTable: lastProject?.tableNumber ?? null,
+ zoneId: lastProject?.zoneId ?? null,
+ },
+ {
+ strategy: config.strategy,
+ minLooksPerProject: config.minLooksPerProject,
+ },
+ (n) => randomInt(0, n),
+ );
+}
+
+/** Pass on the open table without a score. It stays seen, so this judge is not sent back. */
+export async function skipVisit(
+ db: PanelDb,
+ input: { eventId: string; judgeId: string; visitId: string; now: Date },
+) {
+ return db.transaction(async (tx) => {
+ const phase = await loadPhase(tx, input.eventId);
+ if (phase !== "judging_live") {
+ throw new Error(`skip is not allowed while the event is ${phase}`);
+ }
+ const [row] = await tx
+ .select()
+ .from(visit)
+ .where(
+ and(
+ eq(visit.id, input.visitId),
+ eq(visit.judgeId, input.judgeId),
+ eq(visit.eventId, input.eventId),
+ ),
+ );
+ if (!row || row.voidedAt || row.completedAt) {
+ throw new Error("This visit cannot be skipped");
+ }
+ await tx.update(visit).set({ completedAt: input.now }).where(eq(visit.id, row.id));
+ await tx.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "visit.skipped",
+ actor: { judgeId: input.judgeId },
+ subject: { visitId: row.id, projectId: row.projectId },
+ payload: {},
+ });
+ return { projectId: row.projectId };
+ });
+}
+
+/**
+ * Whether the judge's open visit still stands: an organizer may have voided
+ * it, or called the judge back to the desk since it was handed out. The
+ * recall lookup reads only log rows written since the hand-out
+ * (event_log_event_idx), so a phone can ask while it walks.
+ */
+export async function visitStatus(
+ db: PanelDb,
+ input: { eventId: string; judgeId: string; visitId: string },
+) {
+ const [row] = await db
+ .select({ handedOutAt: visit.handedOutAt, voidedAt: visit.voidedAt })
+ .from(visit)
+ .where(
+ and(
+ eq(visit.id, input.visitId),
+ eq(visit.eventId, input.eventId),
+ eq(visit.judgeId, input.judgeId),
+ ),
+ );
+ if (!row) throw new Error("Visit not found");
+ const [recall] = await db
+ .select({ at: eventLog.createdAt })
+ .from(eventLog)
+ .where(
+ and(
+ eq(eventLog.eventId, input.eventId),
+ gte(eventLog.createdAt, row.handedOutAt),
+ eq(eventLog.kind, "judge.recalled"),
+ sql`${eventLog.subject}->>'judgeId' = ${input.judgeId}`,
+ ),
+ )
+ .orderBy(desc(eventLog.createdAt))
+ .limit(1);
+ // A recall does not void the visit, so the same visit can come back from
+ // dispatch. The time lets the phone act once per recall, not once per poll.
+ return {
+ voided: row.voidedAt !== null,
+ recalledAt: recall ? recall.at.toISOString() : null,
+ };
+}
+
+export async function judgeProgress(
+ db: PanelDb,
+ input: { eventId: string; judgeId: string },
+) {
+ const person = await loadJudge(db, input.judgeId, input.eventId);
+ if (person.status !== "approved") throw new Error("This judge is not approved");
+ const pool = await loadPool(db, input.eventId, person.trackId);
+ const [scored] = await db
+ .select({ n: sql`count(*)::int` })
+ .from(vote)
+ .where(and(eq(vote.judgeId, input.judgeId), eq(vote.eventId, input.eventId)));
+ const total = pool.length;
+ const completed = scored?.n ?? 0;
+ return {
+ judgeId: input.judgeId,
+ total,
+ completed,
+ percentage: total > 0 ? Math.round((completed / total) * 100) : 0,
+ };
+}
diff --git a/packages/judging-server/src/services/event.integration.test.ts b/packages/judging-server/src/services/event.integration.test.ts
new file mode 100644
index 00000000..46c2aea4
--- /dev/null
+++ b/packages/judging-server/src/services/event.integration.test.ts
@@ -0,0 +1,38 @@
+import { randomUUID } from "node:crypto";
+import { and, eq } from "drizzle-orm";
+import { describe, expect, it } from "vitest";
+import { createDb, criterion, databaseUrl, event, migrate, rubric } from "@query/judging-db";
+import { ensureEvent } from "./event";
+
+const url = databaseUrl();
+
+describe.skipIf(!url)("ensureEvent", () => {
+ it("creates an edition's event once, with the default rubric", async () => {
+ const { db, pool } = createDb(url as string);
+ try {
+ await migrate(pool);
+ const input = {
+ orgSlug: "test-org",
+ orgName: "Test",
+ eventSlug: randomUUID(),
+ name: "Test edition",
+ };
+ const first = await ensureEvent(db, input);
+ const again = await ensureEvent(db, input);
+ expect(again.eventId).toBe(first.eventId);
+
+ const [row] = await db.select().from(event).where(eq(event.id, first.eventId));
+ expect(row?.phase).toBe("setup");
+ const criteria = await db
+ .select({ key: criterion.key })
+ .from(criterion)
+ .innerJoin(rubric, eq(rubric.id, criterion.rubricId))
+ .where(and(eq(rubric.eventId, first.eventId), eq(rubric.isDefault, true)));
+ expect(criteria.map((item) => item.key).sort()).toEqual(
+ ["clarity", "creativity", "impact", "scope", "soundness"],
+ );
+ } finally {
+ await pool.end();
+ }
+ });
+});
diff --git a/packages/judging-server/src/services/event.ts b/packages/judging-server/src/services/event.ts
new file mode 100644
index 00000000..30fae295
--- /dev/null
+++ b/packages/judging-server/src/services/event.ts
@@ -0,0 +1,78 @@
+import { and, eq, sql } from "drizzle-orm";
+import { DEFAULT_TIMERS } from "@query/judging-core";
+import type { PanelDb } from "@query/judging-db";
+import { criterion, event, eventConfig, organization, rubric } from "@query/judging-db";
+
+/** The five scores the club's judges have always given, each out of ten. */
+const DEFAULT_CRITERIA = [
+ ["creativity", "Creativity & Originality"],
+ ["impact", "Impact & Relevance"],
+ ["scope", "Scope & Technical Depth"],
+ ["clarity", "Clarity & Engagement"],
+ ["soundness", "Soundness & Accuracy"],
+] as const;
+
+/**
+ * The event for one hackathon edition, created the first time it is asked
+ * for: organization, event in setup, default config, and the default rubric.
+ * Safe to call on every sync; an existing event is returned untouched.
+ */
+export async function ensureEvent(
+ db: PanelDb,
+ input: { orgSlug: string; orgName: string; eventSlug: string; name: string },
+): Promise<{ eventId: string }> {
+ return db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${`panel:event:${input.orgSlug}:${input.eventSlug}`}))`,
+ );
+ await tx
+ .insert(organization)
+ .values({ slug: input.orgSlug, name: input.orgName })
+ .onConflictDoNothing({ target: organization.slug });
+ const [org] = await tx
+ .select({ id: organization.id })
+ .from(organization)
+ .where(eq(organization.slug, input.orgSlug));
+ if (!org) throw new Error("Organization was not stored");
+
+ const [existing] = await tx
+ .select({ id: event.id })
+ .from(event)
+ .where(and(eq(event.orgId, org.id), eq(event.slug, input.eventSlug)));
+ if (existing) return { eventId: existing.id };
+
+ const [created] = await tx
+ .insert(event)
+ .values({ orgId: org.id, slug: input.eventSlug, name: input.name, phase: "setup" })
+ .returning({ id: event.id });
+ if (!created) throw new Error("Event was not stored");
+
+ await tx.insert(eventConfig).values({
+ eventId: created.id,
+ targetSeconds: DEFAULT_TIMERS.targetSeconds,
+ hardLimitSeconds: DEFAULT_TIMERS.hardLimitSeconds,
+ walkLimitSeconds: DEFAULT_TIMERS.walkLimitSeconds,
+ submitGraceSeconds: DEFAULT_TIMERS.submitGraceSeconds,
+ minLooksPerProject: 1,
+ dispatchStrategy: "coverage",
+ });
+
+ const [main] = await tx
+ .insert(rubric)
+ .values({ eventId: created.id, name: "Main", isDefault: true })
+ .returning({ id: rubric.id });
+ if (!main) throw new Error("Rubric was not stored");
+ await tx.insert(criterion).values(
+ DEFAULT_CRITERIA.map(([key, label], position) => ({
+ rubricId: main.id,
+ position,
+ key,
+ label,
+ min: 1,
+ max: 10,
+ weight: 1,
+ })),
+ );
+ return { eventId: created.id };
+ });
+}
diff --git a/packages/judging-server/src/services/feedback.test.ts b/packages/judging-server/src/services/feedback.test.ts
new file mode 100644
index 00000000..6f897277
--- /dev/null
+++ b/packages/judging-server/src/services/feedback.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+import { feedbackStatus, median } from "./feedback";
+
+describe("feedbackStatus", () => {
+ it("refuses before publish and for the wrong token", () => {
+ expect(feedbackStatus({ published: false, tokenMatches: true })).toBe("forbidden");
+ expect(feedbackStatus({ published: true, tokenMatches: false })).toBe("forbidden");
+ });
+
+ it("allows the project's token after publish", () => {
+ expect(feedbackStatus({ published: true, tokenMatches: true })).toBe("ok");
+ });
+
+ it("puts the event median between the middle scores", () => {
+ expect(median([1, 3, 9])).toBe(3);
+ expect(median([1, 2, 8, 9])).toBe(5);
+ });
+});
diff --git a/packages/judging-server/src/services/feedback.ts b/packages/judging-server/src/services/feedback.ts
new file mode 100644
index 00000000..d12107fc
--- /dev/null
+++ b/packages/judging-server/src/services/feedback.ts
@@ -0,0 +1,17 @@
+export function feedbackStatus(input: {
+ published: boolean;
+ tokenMatches: boolean;
+}): "ok" | "forbidden" {
+ if (!input.published || !input.tokenMatches) return "forbidden";
+ return "ok";
+}
+
+export function median(values: readonly number[]): number {
+ if (values.length === 0) return 0;
+ const sorted = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ const upper = sorted[mid] ?? 0;
+ if (sorted.length % 2 === 1) return upper;
+ const lower = sorted[mid - 1] ?? upper;
+ return (lower + upper) / 2;
+}
diff --git a/packages/judging-server/src/services/floor.test.ts b/packages/judging-server/src/services/floor.test.ts
new file mode 100644
index 00000000..aabba96b
--- /dev/null
+++ b/packages/judging-server/src/services/floor.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_TIMERS } from "@query/judging-core";
+import { judgeFloorState } from "./floor";
+
+const arrived = new Date("2027-02-28T13:00:00Z");
+
+describe("judgeFloorState", () => {
+ it("marks a judge with no table as idle", () => {
+ expect(judgeFloorState(null, arrived, DEFAULT_TIMERS)).toEqual({
+ state: "idle",
+ overtime: false,
+ });
+ });
+
+ it("marks a judge who has not arrived as walking", () => {
+ expect(judgeFloorState({ arrivedAt: null }, arrived, DEFAULT_TIMERS).state).toBe("walking");
+ });
+
+ it("marks overtime only after the target time at the table", () => {
+ const early = judgeFloorState({ arrivedAt: arrived }, new Date(arrived.getTime() + 1000), DEFAULT_TIMERS);
+ const late = judgeFloorState(
+ { arrivedAt: arrived },
+ new Date(arrived.getTime() + DEFAULT_TIMERS.targetSeconds * 1000 + 1000),
+ DEFAULT_TIMERS,
+ );
+ expect(early).toEqual({ state: "at table", overtime: false });
+ expect(late).toEqual({ state: "at table", overtime: true });
+ });
+});
diff --git a/packages/judging-server/src/services/floor.ts b/packages/judging-server/src/services/floor.ts
new file mode 100644
index 00000000..5dfe8c0a
--- /dev/null
+++ b/packages/judging-server/src/services/floor.ts
@@ -0,0 +1,79 @@
+import { and, eq, isNull, sql } from "drizzle-orm";
+import { isOverTarget } from "@query/judging-core";
+import type { TimerConfig } from "@query/judging-core";
+import type { PanelDb } from "@query/judging-db";
+import { eventConfig, judge, project, visit, vote } from "@query/judging-db";
+
+export type FloorState = "idle" | "walking" | "at table";
+
+export function judgeFloorState(
+ open: { arrivedAt: Date | null } | null,
+ now: Date,
+ config: TimerConfig,
+): { state: FloorState; overtime: boolean } {
+ if (!open) return { state: "idle", overtime: false };
+ if (!open.arrivedAt) return { state: "walking", overtime: false };
+ return {
+ state: "at table",
+ overtime: isOverTarget(open.arrivedAt, now, config),
+ };
+}
+
+export async function organizerFloor(db: PanelDb, eventId: string, now: Date) {
+ const [config] = await db
+ .select({
+ targetSeconds: eventConfig.targetSeconds,
+ hardLimitSeconds: eventConfig.hardLimitSeconds,
+ walkLimitSeconds: eventConfig.walkLimitSeconds,
+ submitGraceSeconds: eventConfig.submitGraceSeconds,
+ })
+ .from(eventConfig)
+ .where(eq(eventConfig.eventId, eventId));
+ if (!config) return null;
+
+ const projects = await db
+ .select({ id: project.id, tableNumber: project.tableNumber })
+ .from(project)
+ .where(and(eq(project.eventId, eventId), isNull(project.withdrawnAt)));
+ const looks = await db
+ .select({ projectId: vote.projectId, count: sql`count(*)::int` })
+ .from(vote)
+ .where(eq(vote.eventId, eventId))
+ .groupBy(vote.projectId);
+ const lookOf = new Map(looks.map((item) => [item.projectId, item.count]));
+
+ const open = await db
+ .select({
+ judgeId: visit.judgeId,
+ arrivedAt: visit.arrivedAt,
+ tableNumber: project.tableNumber,
+ })
+ .from(visit)
+ .innerJoin(project, eq(project.id, visit.projectId))
+ .where(and(eq(visit.eventId, eventId), isNull(visit.completedAt), isNull(visit.voidedAt)));
+ const openOf = new Map(open.map((item) => [item.judgeId, item]));
+
+ const people = await db
+ .select({ id: judge.id, name: judge.name })
+ .from(judge)
+ .where(and(eq(judge.eventId, eventId), eq(judge.status, "approved")));
+
+ return {
+ tables: projects
+ .map((item) => ({
+ tableNumber: item.tableNumber,
+ looks: lookOf.get(item.id) ?? 0,
+ }))
+ .sort((a, b) => (a.tableNumber ?? 0) - (b.tableNumber ?? 0)),
+ judges: people.map((person) => {
+ const current = openOf.get(person.id) ?? null;
+ const state = judgeFloorState(current, now, config);
+ return {
+ name: person.name,
+ state: state.state,
+ overtime: state.overtime,
+ tableNumber: current?.tableNumber ?? null,
+ };
+ }),
+ };
+}
diff --git a/packages/judging-server/src/services/flow.integration.test.ts b/packages/judging-server/src/services/flow.integration.test.ts
new file mode 100644
index 00000000..58156201
--- /dev/null
+++ b/packages/judging-server/src/services/flow.integration.test.ts
@@ -0,0 +1,110 @@
+import { and, eq } from "drizzle-orm";
+import { describe, expect, it } from "vitest";
+import {
+ createDb,
+ criterion,
+ databaseUrl,
+ event,
+ eventLog,
+ migrate,
+ project,
+ rubric,
+ seedDemo,
+} from "@query/judging-db";
+import { arrive } from "./arrive";
+import { dispatchNext } from "./dispatch";
+import { feedbackCard } from "./catalog";
+import { computeResults, publishResults } from "./results";
+import { castVote } from "./vote";
+
+const url = databaseUrl();
+
+describe.skipIf(!url)("a full visit", () => {
+ it("scores a table, publishes, and opens that project's feedback", async () => {
+ const { db, pool } = createDb(url as string);
+ try {
+ await migrate(pool);
+ const { eventId } = await seedDemo(db);
+ const { judge } = await import("@query/judging-db");
+ const [person] = await db.select().from(judge).where(eq(judge.eventId, eventId));
+ if (!person) throw new Error("demo judge missing");
+ const now = new Date("2027-02-28T13:00:00Z");
+
+ const handed = await dispatchNext(db, { eventId, judgeId: person.id, now });
+ expect(handed.done).toBe(false);
+ if (handed.done) return;
+
+ await arrive(db, {
+ eventId,
+ judgeId: person.id,
+ tableNumber: handed.tableNumber ?? undefined,
+ now: new Date(now.getTime() + 30_000),
+ });
+
+ const [rub] = await db
+ .select({ id: rubric.id })
+ .from(rubric)
+ .where(and(eq(rubric.eventId, eventId), eq(rubric.isDefault, true)));
+ if (!rub) throw new Error("demo rubric missing");
+ const criteria = await db
+ .select()
+ .from(criterion)
+ .where(eq(criterion.rubricId, rub.id));
+
+ await castVote(db, {
+ eventId,
+ judgeId: person.id,
+ visitId: handed.visitId,
+ scores: criteria.map((item) => ({ criterionId: item.id, value: 8 })),
+ comment: null,
+ now: new Date(now.getTime() + 60_000),
+ });
+
+ await db.update(event).set({ phase: "judging_closed" }).where(eq(event.id, eventId));
+ const run = await computeResults(db, {
+ eventId,
+ actorEmail: "organizer@demo.panel",
+ now: new Date(now.getTime() + 120_000),
+ });
+ await publishResults(db, {
+ eventId,
+ runId: run.runId,
+ actorEmail: "organizer@demo.panel",
+ now: new Date(now.getTime() + 130_000),
+ });
+
+ const [scored] = await db
+ .select({ token: project.feedbackToken })
+ .from(project)
+ .where(eq(project.id, handed.projectId));
+ if (!scored?.token) throw new Error("feedback token was not minted");
+
+ const card = await feedbackCard(db, scored.token);
+ expect(card.status).toBe("ok");
+ if (card.status === "ok") {
+ expect(card.criteria.length).toBeGreaterThan(0);
+ expect(card.criteria[0]?.mean).toBe(8);
+ }
+ expect((await feedbackCard(db, "00000000-0000-0000-0000-000000000000")).status).toBe(
+ "forbidden",
+ );
+
+ const kinds = [
+ "visit.handed_out",
+ "visit.arrived",
+ "vote.cast",
+ "results.computed",
+ "results.published",
+ ] as const;
+ for (const kind of kinds) {
+ const rows = await db
+ .select({ id: eventLog.id })
+ .from(eventLog)
+ .where(and(eq(eventLog.eventId, eventId), eq(eventLog.kind, kind)));
+ expect(rows, kind).toHaveLength(1);
+ }
+ } finally {
+ await pool.end();
+ }
+ });
+});
diff --git a/packages/judging-server/src/services/live.ts b/packages/judging-server/src/services/live.ts
new file mode 100644
index 00000000..3e75fbe2
--- /dev/null
+++ b/packages/judging-server/src/services/live.ts
@@ -0,0 +1,122 @@
+import { and, eq, isNotNull, isNull, sql } from "drizzle-orm";
+import type { PanelDb } from "@query/judging-db";
+import { event, eventConfig, judge, organization, prize, project, result, resultRun, track, visit, vote } from "@query/judging-db";
+
+export async function liveSnapshot(db: PanelDb, orgSlug: string, eventSlug: string) {
+ const [row] = await db
+ .select({
+ eventId: event.id,
+ phase: event.phase,
+ name: event.name,
+ leaderboardPublic: eventConfig.leaderboardPublic,
+ pollSeconds: eventConfig.boardPollSeconds,
+ })
+ .from(event)
+ .innerJoin(organization, eq(organization.id, event.orgId))
+ .innerJoin(eventConfig, eq(eventConfig.eventId, event.id))
+ .where(and(eq(organization.slug, orgSlug), eq(event.slug, eventSlug)));
+ if (!row) return null;
+
+ const prizes = await db
+ .select({
+ title: prize.title,
+ place: prize.place,
+ amount: prize.amount,
+ track: track.name,
+ })
+ .from(prize)
+ .innerJoin(track, eq(track.id, prize.trackId))
+ .where(eq(track.eventId, row.eventId))
+ .orderBy(track.position, prize.place);
+
+ const published = row.phase === "published";
+ const showFloor = published || row.leaderboardPublic;
+ if (!showFloor) {
+ return {
+ eventId: row.eventId,
+ name: row.name,
+ phase: row.phase,
+ pollSeconds: row.pollSeconds,
+ projects: [],
+ placements: [],
+ prizes,
+ };
+ }
+
+ const projects = await db
+ .select({
+ id: project.id,
+ name: project.name,
+ tableNumber: project.tableNumber,
+ })
+ .from(project)
+ .where(and(eq(project.eventId, row.eventId), isNull(project.withdrawnAt)));
+
+ const looks = await db
+ .select({
+ projectId: vote.projectId,
+ count: sql`count(*)::int`,
+ })
+ .from(vote)
+ .where(eq(vote.eventId, row.eventId))
+ .groupBy(vote.projectId);
+ const lookOf = new Map(looks.map((item) => [item.projectId, item.count]));
+
+ const open = await db
+ .select({ judgeId: visit.judgeId, projectId: visit.projectId, arrivedAt: visit.arrivedAt })
+ .from(visit)
+ .where(
+ and(eq(visit.eventId, row.eventId), isNull(visit.completedAt), isNull(visit.voidedAt)),
+ );
+ const judges = await db
+ .select({ id: judge.id, name: judge.name })
+ .from(judge)
+ .where(eq(judge.eventId, row.eventId));
+ const nameOf = new Map(judges.map((item) => [item.id, item.name]));
+
+ let placements: {
+ projectId: string;
+ projectName: string;
+ trackId: string;
+ placement: number | null;
+ }[] = [];
+ if (published) {
+ const [run] = await db
+ .select({ id: resultRun.id })
+ .from(resultRun)
+ .where(and(eq(resultRun.eventId, row.eventId), isNotNull(resultRun.publishedAt)));
+ if (run) {
+ placements = await db
+ .select({
+ projectId: result.projectId,
+ projectName: project.name,
+ trackId: result.trackId,
+ placement: result.placement,
+ })
+ .from(result)
+ .innerJoin(project, eq(project.id, result.projectId))
+ .where(eq(result.runId, run.id));
+ }
+ }
+
+ return {
+ eventId: row.eventId,
+ name: row.name,
+ phase: row.phase,
+ pollSeconds: row.pollSeconds,
+ projects: projects.map((item) => ({
+ id: item.id,
+ name: published ? item.name : null,
+ tableNumber: item.tableNumber,
+ looks: lookOf.get(item.id) ?? 0,
+ })),
+ floor: open.map((item) => ({
+ judgeId: item.judgeId,
+ judgeName: nameOf.get(item.judgeId) ?? "",
+ projectId: item.projectId,
+ arrived: item.arrivedAt !== null,
+ })),
+ placements,
+ prizes,
+ };
+}
diff --git a/packages/judging-server/src/services/logs.test.ts b/packages/judging-server/src/services/logs.test.ts
new file mode 100644
index 00000000..2be074f6
--- /dev/null
+++ b/packages/judging-server/src/services/logs.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+import { logsForHandout } from "./logs";
+
+describe("logsForHandout", () => {
+ it("writes exactly one row when nothing has lapsed", () => {
+ expect(logsForHandout([]).map((row) => row.kind)).toEqual(["visit.handed_out"]);
+ });
+
+ it("writes one void row per lapsed visit, then the hand-out", () => {
+ expect(logsForHandout(["a", "b"]).map((row) => row.kind)).toEqual([
+ "visit.voided",
+ "visit.voided",
+ "visit.handed_out",
+ ]);
+ });
+});
diff --git a/packages/judging-server/src/services/logs.ts b/packages/judging-server/src/services/logs.ts
new file mode 100644
index 00000000..b7d54c73
--- /dev/null
+++ b/packages/judging-server/src/services/logs.ts
@@ -0,0 +1,28 @@
+import { isOutboxTopic } from "@query/judging-core";
+import type { LogKind } from "@query/judging-core";
+
+export type LogDraft = {
+ kind: LogKind;
+ subject: Record;
+ payload: Record;
+};
+
+/** One log row per void, then one for the hand-out. A reload writes none. */
+export function logsForHandout(voidedVisitIds: readonly string[]): LogDraft[] {
+ return [
+ ...voidedVisitIds.map((visitId) => ({
+ kind: "visit.voided" as const,
+ subject: { visitId },
+ payload: {},
+ })),
+ {
+ kind: "visit.handed_out" as const,
+ subject: {},
+ payload: {},
+ },
+ ];
+}
+
+export function isDeliveredTopic(kind: LogKind): boolean {
+ return isOutboxTopic(kind);
+}
diff --git a/packages/judging-server/src/services/outbox.ts b/packages/judging-server/src/services/outbox.ts
new file mode 100644
index 00000000..1c43acc6
--- /dev/null
+++ b/packages/judging-server/src/services/outbox.ts
@@ -0,0 +1,53 @@
+import { createHmac } from "node:crypto";
+import { eq, isNull } from "drizzle-orm";
+import type { PanelDb } from "@query/judging-db";
+import { outbox, webhook } from "@query/judging-db";
+
+/**
+ * Delivers pending outbox rows. A topic with no active webhook is delivered.
+ * A delivered row is deleted: event_log already keeps the history, and on a
+ * 0.5 GB database a second copy of every vote is not worth keeping.
+ */
+export async function drainOutbox(db: PanelDb): Promise {
+ const pending = await db
+ .select()
+ .from(outbox)
+ .where(isNull(outbox.deliveredAt))
+ .limit(50);
+ if (pending.length === 0) return 0;
+
+ const hooks = await db.select().from(webhook).where(eq(webhook.isActive, true));
+ let delivered = 0;
+
+ for (const row of pending) {
+ const targets = hooks.filter((hook) => hook.topics?.includes(row.topic));
+ let ok = true;
+ for (const hook of targets) {
+ const body = JSON.stringify(row.payload);
+ const signature = createHmac("sha256", hook.secret).update(body).digest("hex");
+ try {
+ const response = await fetch(hook.url, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-panel-signature": signature,
+ },
+ body,
+ });
+ if (!response.ok) ok = false;
+ } catch {
+ ok = false;
+ }
+ }
+ if (!ok) {
+ await db
+ .update(outbox)
+ .set({ attempts: row.attempts + 1 })
+ .where(eq(outbox.id, row.id));
+ continue;
+ }
+ await db.delete(outbox).where(eq(outbox.id, row.id));
+ delivered += 1;
+ }
+ return delivered;
+}
diff --git a/packages/judging-server/src/services/overtime.test.ts b/packages/judging-server/src/services/overtime.test.ts
new file mode 100644
index 00000000..35fdb143
--- /dev/null
+++ b/packages/judging-server/src/services/overtime.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_TIMERS } from "@query/judging-core";
+import { visitsOverTarget } from "./overtime";
+
+const arrived = new Date("2027-02-28T13:00:00Z");
+
+describe("visitsOverTarget", () => {
+ it("includes a judge who has stayed past the target", () => {
+ const rows = visitsOverTarget(
+ [
+ {
+ visitId: "late",
+ eventId: "event",
+ judgeId: "judge",
+ arrivedAt: arrived,
+ config: DEFAULT_TIMERS,
+ },
+ {
+ visitId: "early",
+ eventId: "event",
+ judgeId: "other",
+ arrivedAt: new Date(arrived.getTime() + 10_000),
+ config: DEFAULT_TIMERS,
+ },
+ ],
+ new Date(arrived.getTime() + DEFAULT_TIMERS.targetSeconds * 1000 + 1000),
+ );
+ expect(rows.map((row) => row.visitId)).toEqual(["late"]);
+ });
+});
diff --git a/packages/judging-server/src/services/overtime.ts b/packages/judging-server/src/services/overtime.ts
new file mode 100644
index 00000000..db7fc2a0
--- /dev/null
+++ b/packages/judging-server/src/services/overtime.ts
@@ -0,0 +1,49 @@
+import { and, eq, isNotNull, isNull } from "drizzle-orm";
+import { isOverTarget } from "@query/judging-core";
+import type { TimerConfig } from "@query/judging-core";
+import type { PanelDb } from "@query/judging-db";
+import { eventConfig, visit } from "@query/judging-db";
+
+export type OpenVisit = {
+ visitId: string;
+ eventId: string;
+ judgeId: string;
+ arrivedAt: Date | null;
+ config: TimerConfig;
+};
+
+/** Visits where the judge has arrived and is past the target time. */
+export function visitsOverTarget(rows: readonly OpenVisit[], now: Date): OpenVisit[] {
+ return rows.filter(
+ (row) => row.arrivedAt !== null && isOverTarget(row.arrivedAt, now, row.config),
+ );
+}
+
+export async function loadOpenVisits(db: PanelDb): Promise {
+ const rows = await db
+ .select({
+ visitId: visit.id,
+ eventId: visit.eventId,
+ judgeId: visit.judgeId,
+ arrivedAt: visit.arrivedAt,
+ targetSeconds: eventConfig.targetSeconds,
+ hardLimitSeconds: eventConfig.hardLimitSeconds,
+ walkLimitSeconds: eventConfig.walkLimitSeconds,
+ submitGraceSeconds: eventConfig.submitGraceSeconds,
+ })
+ .from(visit)
+ .innerJoin(eventConfig, eq(eventConfig.eventId, visit.eventId))
+ .where(and(isNull(visit.completedAt), isNull(visit.voidedAt), isNotNull(visit.arrivedAt)));
+ return rows.map((row) => ({
+ visitId: row.visitId,
+ eventId: row.eventId,
+ judgeId: row.judgeId,
+ arrivedAt: row.arrivedAt,
+ config: {
+ targetSeconds: row.targetSeconds,
+ hardLimitSeconds: row.hardLimitSeconds,
+ walkLimitSeconds: row.walkLimitSeconds,
+ submitGraceSeconds: row.submitGraceSeconds,
+ },
+ }));
+}
diff --git a/packages/judging-server/src/services/records.ts b/packages/judging-server/src/services/records.ts
new file mode 100644
index 00000000..7000de5b
--- /dev/null
+++ b/packages/judging-server/src/services/records.ts
@@ -0,0 +1,358 @@
+import { randomUUID } from "node:crypto";
+import { and, eq, isNull } from "drizzle-orm";
+import { assignTables, phaseAllows } from "@query/judging-core";
+import type { PanelDb } from "@query/judging-db";
+import { event, eventLog, floorTable, judge, outbox, project, projectTrack, visit } from "@query/judging-db";
+
+async function phaseOf(db: PanelDb, eventId: string) {
+ const [row] = await db
+ .select({ phase: event.phase })
+ .from(event)
+ .where(eq(event.id, eventId));
+ if (!row) throw new Error("Event not found");
+ return row.phase;
+}
+
+export async function upsertProject(
+ db: PanelDb,
+ input: {
+ eventId: string;
+ externalId: string;
+ name: string;
+ teamName: string | null;
+ tableNumber: number | null;
+ actorEmail: string;
+ },
+) {
+ const phase = await phaseOf(db, input.eventId);
+ if (phase === "archived" || phase === "published") {
+ throw new Error(`submit is not allowed while the event is ${phase}`);
+ }
+ const [existing] = await db
+ .select({ id: project.id })
+ .from(project)
+ .where(and(eq(project.eventId, input.eventId), eq(project.externalId, input.externalId)));
+
+ if (existing) {
+ await db
+ .update(project)
+ .set({
+ name: input.name,
+ teamName: input.teamName,
+ tableNumber: input.tableNumber,
+ })
+ .where(eq(project.id, existing.id));
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "project.upserted",
+ actor: { email: input.actorEmail },
+ subject: { projectId: existing.id },
+ payload: { externalId: input.externalId },
+ });
+ return { id: existing.id };
+ }
+
+ const [created] = await db
+ .insert(project)
+ .values({
+ eventId: input.eventId,
+ externalId: input.externalId,
+ name: input.name,
+ teamName: input.teamName,
+ tableNumber: input.tableNumber,
+ })
+ .returning({ id: project.id });
+ if (!created) throw new Error("Project was not stored");
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "project.upserted",
+ actor: { email: input.actorEmail },
+ subject: { projectId: created.id },
+ payload: { externalId: input.externalId },
+ });
+ return { id: created.id };
+}
+
+export async function withdrawProject(
+ db: PanelDb,
+ input: { eventId: string; projectId: string; now: Date; actorEmail: string },
+) {
+ const phase = await phaseOf(db, input.eventId);
+ if (!phaseAllows(phase, "submit") && phase !== "judging_live" && phase !== "submissions_closed") {
+ throw new Error(`submit is not allowed while the event is ${phase}`);
+ }
+ await db
+ .update(project)
+ .set({ withdrawnAt: input.now })
+ .where(and(eq(project.id, input.projectId), eq(project.eventId, input.eventId)));
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "project.withdrawn",
+ actor: { email: input.actorEmail },
+ subject: { projectId: input.projectId },
+ payload: {},
+ });
+}
+
+export async function upsertJudge(
+ db: PanelDb,
+ input: {
+ eventId: string;
+ email: string;
+ name: string;
+ externalId: string | null;
+ actorEmail: string;
+ },
+) {
+ const phase = await phaseOf(db, input.eventId);
+ if (!phaseAllows(phase, "apply") && phase !== "judging_live") {
+ throw new Error(`apply is not allowed while the event is ${phase}`);
+ }
+ const email = input.email.toLowerCase();
+ const [existing] = await db
+ .select({ id: judge.id })
+ .from(judge)
+ .where(and(eq(judge.eventId, input.eventId), eq(judge.email, email)));
+ if (existing) {
+ await db
+ .update(judge)
+ .set({ name: input.name, externalId: input.externalId })
+ .where(eq(judge.id, existing.id));
+ return { id: existing.id };
+ }
+ const [created] = await db
+ .insert(judge)
+ .values({
+ eventId: input.eventId,
+ email,
+ name: input.name,
+ externalId: input.externalId,
+ status: "invited",
+ })
+ .returning({ id: judge.id });
+ if (!created) throw new Error("Judge was not stored");
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "judge.upserted",
+ actor: { email: input.actorEmail },
+ subject: { judgeId: created.id },
+ payload: {},
+ });
+ return { id: created.id };
+}
+
+export async function applyAsJudge(
+ db: PanelDb,
+ input: { eventId: string; email: string; name: string },
+) {
+ const phase = await phaseOf(db, input.eventId);
+ if (!phaseAllows(phase, "apply")) {
+ throw new Error(`apply is not allowed while the event is ${phase}`);
+ }
+ const email = input.email.toLowerCase();
+ const [existing] = await db
+ .select({ id: judge.id, status: judge.status })
+ .from(judge)
+ .where(and(eq(judge.eventId, input.eventId), eq(judge.email, email)));
+ if (existing) {
+ if (existing.status === "invited") {
+ await db.update(judge).set({ status: "applied", name: input.name }).where(eq(judge.id, existing.id));
+ }
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "judge.upserted",
+ actor: { email },
+ subject: { judgeId: existing.id },
+ payload: { status: existing.status === "invited" ? "applied" : existing.status },
+ });
+ return { id: existing.id, status: existing.status === "invited" ? "applied" : existing.status };
+ }
+ const [created] = await db
+ .insert(judge)
+ .values({
+ eventId: input.eventId,
+ email,
+ name: input.name,
+ status: "applied",
+ })
+ .returning({ id: judge.id });
+ if (!created) throw new Error("Judge was not stored");
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "judge.upserted",
+ actor: { email },
+ subject: { judgeId: created.id },
+ payload: { status: "applied" },
+ });
+ return { id: created.id, status: "applied" as const };
+}
+
+export async function setJudgeStatus(
+ db: PanelDb,
+ input: {
+ eventId: string;
+ judgeId: string;
+ status: "approved" | "suspended";
+ actorEmail: string;
+ },
+) {
+ await db
+ .update(judge)
+ .set({ status: input.status })
+ .where(and(eq(judge.id, input.judgeId), eq(judge.eventId, input.eventId)));
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: input.status === "approved" ? "judge.approved" : "judge.suspended",
+ actor: { email: input.actorEmail },
+ subject: { judgeId: input.judgeId },
+ payload: { status: input.status },
+ });
+}
+
+export async function voidVisit(
+ db: PanelDb,
+ input: { eventId: string; visitId: string; now: Date; actorEmail: string; reason: string },
+) {
+ return db.transaction(async (tx) => {
+ const [row] = await tx
+ .select({ id: visit.id, judgeId: visit.judgeId })
+ .from(visit)
+ .where(and(eq(visit.id, input.visitId), eq(visit.eventId, input.eventId), isNull(visit.voidedAt)));
+ if (!row) throw new Error("Visit not found");
+ await tx
+ .update(visit)
+ .set({ voidedAt: input.now, voidReason: input.reason })
+ .where(eq(visit.id, row.id));
+ await tx.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "visit.voided",
+ actor: { email: input.actorEmail },
+ subject: { visitId: row.id },
+ payload: { reason: input.reason },
+ });
+ await tx.insert(outbox).values({
+ eventId: input.eventId,
+ topic: "visit.voided",
+ payload: { visitId: row.id, judgeId: row.judgeId },
+ });
+ return { judgeId: row.judgeId };
+ });
+}
+
+export async function recallJudge(
+ db: PanelDb,
+ input: { eventId: string; judgeId: string; actorEmail: string },
+) {
+ await db.transaction(async (tx) => {
+ await tx.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "judge.recalled",
+ actor: { email: input.actorEmail },
+ subject: { judgeId: input.judgeId },
+ payload: {},
+ });
+ await tx.insert(outbox).values({
+ eventId: input.eventId,
+ topic: "judge.recalled",
+ payload: { judgeId: input.judgeId },
+ });
+ });
+}
+
+export async function assignJudge(
+ db: PanelDb,
+ input: {
+ eventId: string;
+ judgeId: string;
+ trackId: string | null;
+ zoneId: string | null;
+ actorEmail: string;
+ },
+) {
+ const [row] = await db
+ .update(judge)
+ .set({ trackId: input.trackId, zoneId: input.zoneId })
+ .where(and(eq(judge.id, input.judgeId), eq(judge.eventId, input.eventId)))
+ .returning({ id: judge.id });
+ if (!row) throw new Error("Judge not found");
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "judge.upserted",
+ actor: { email: input.actorEmail },
+ subject: { judgeId: input.judgeId },
+ payload: { trackId: input.trackId, zoneId: input.zoneId },
+ });
+ return { id: row.id };
+}
+
+export async function assignProjectTrack(
+ db: PanelDb,
+ input: { projectId: string; trackId: string },
+) {
+ await db
+ .insert(projectTrack)
+ .values(input)
+ .onConflictDoNothing();
+}
+
+export async function rotateQr(
+ db: PanelDb,
+ input: { eventId: string; projectId: string; actorEmail: string },
+) {
+ const token = randomUUID();
+ const [row] = await db
+ .update(project)
+ .set({ qrToken: token })
+ .where(and(eq(project.id, input.projectId), eq(project.eventId, input.eventId)))
+ .returning({ qrToken: project.qrToken });
+ if (!row) throw new Error("Project not found");
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "project.qr_rotated",
+ actor: { email: input.actorEmail },
+ subject: { projectId: input.projectId },
+ payload: {},
+ });
+ return { qrToken: row.qrToken };
+}
+
+export async function assignEventTables(
+ db: PanelDb,
+ input: { eventId: string; actorEmail: string },
+) {
+ const projects = await db
+ .select({ id: project.id })
+ .from(project)
+ .where(and(eq(project.eventId, input.eventId), isNull(project.withdrawnAt)));
+ const links = await db
+ .select({ projectId: projectTrack.projectId, trackId: projectTrack.trackId })
+ .from(projectTrack)
+ .innerJoin(project, eq(project.id, projectTrack.projectId))
+ .where(eq(project.eventId, input.eventId));
+ const trackOf = new Map();
+ for (const link of links) {
+ if (!trackOf.has(link.projectId)) trackOf.set(link.projectId, link.trackId);
+ }
+ const tables = await db
+ .select({ number: floorTable.number, zoneId: floorTable.zoneId })
+ .from(floorTable)
+ .where(eq(floorTable.eventId, input.eventId));
+ const seated = assignTables(
+ projects.map((row) => ({ id: row.id, trackId: trackOf.get(row.id) ?? null })),
+ tables.map((row) => ({ number: row.number, zoneId: row.zoneId })),
+ );
+ for (const seat of seated) {
+ await db
+ .update(project)
+ .set({ tableNumber: seat.tableNumber, zoneId: seat.zoneId })
+ .where(eq(project.id, seat.projectId));
+ }
+ await db.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "project.table_assigned",
+ actor: { email: input.actorEmail },
+ subject: { eventId: input.eventId },
+ payload: { assigned: seated.length },
+ });
+ return { assigned: seated.length };
+}
diff --git a/packages/judging-server/src/services/redis.integration.test.ts b/packages/judging-server/src/services/redis.integration.test.ts
new file mode 100644
index 00000000..561545b7
--- /dev/null
+++ b/packages/judging-server/src/services/redis.integration.test.ts
@@ -0,0 +1,41 @@
+import { Redis } from "ioredis";
+import { describe, expect, it } from "vitest";
+import { createRedisBus } from "../bus";
+
+const url = process.env.REDIS_URL;
+
+async function redisAnswers(address: string): Promise {
+ const client = new Redis(address, {
+ maxRetriesPerRequest: 0,
+ connectTimeout: 500,
+ lazyConnect: true,
+ retryStrategy: () => null,
+ });
+ client.on("error", () => undefined);
+ try {
+ await client.connect();
+ return (await client.ping()) === "PONG";
+ } catch {
+ return false;
+ } finally {
+ client.disconnect();
+ }
+}
+
+describe.skipIf(!url)("redis bus", () => {
+ it("delivers a message from one connection to another", async (ctx) => {
+ if (!(await redisAnswers(url as string))) ctx.skip();
+ const left = createRedisBus(url as string);
+ const right = createRedisBus(url as string);
+ const seen = new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error("no redis message")), 4000);
+ right.subscribe("event:test:board", (message) => {
+ clearTimeout(timer);
+ resolve(message);
+ });
+ });
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ left.publish("event:test:board", { kind: "vote.cast" });
+ await expect(seen).resolves.toEqual({ kind: "vote.cast" });
+ });
+});
diff --git a/packages/judging-server/src/services/results.test.ts b/packages/judging-server/src/services/results.test.ts
new file mode 100644
index 00000000..552c88d4
--- /dev/null
+++ b/packages/judging-server/src/services/results.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+import { diffPlacements } from "./results";
+
+const quiet = { rubricComponent: 8, pairwiseComponent: 0 };
+
+describe("diffPlacements", () => {
+ it("reports a place move with both blend components", () => {
+ const left = [
+ { projectId: "a", trackId: "t", placement: 1, score: 9, ...quiet },
+ { projectId: "b", trackId: "t", placement: 2, score: 8, ...quiet },
+ ];
+ const right = [
+ { projectId: "a", trackId: "t", placement: 2, score: 7, rubricComponent: 7, pairwiseComponent: 1 },
+ { projectId: "b", trackId: "t", placement: 1, score: 9, ...quiet },
+ ];
+ expect(diffPlacements(left, right)).toEqual([
+ {
+ projectId: "a",
+ trackId: "t",
+ from: 1,
+ to: 2,
+ score: 7,
+ rubricFrom: 8,
+ rubricTo: 7,
+ pairwiseFrom: 0,
+ pairwiseTo: 1,
+ },
+ {
+ projectId: "b",
+ trackId: "t",
+ from: 2,
+ to: 1,
+ score: 9,
+ rubricFrom: 8,
+ rubricTo: 8,
+ pairwiseFrom: 0,
+ pairwiseTo: 0,
+ },
+ ]);
+ });
+
+ it("reports a pairwise change when the place stays put", () => {
+ const left = [{ projectId: "a", trackId: "t", placement: 1, score: 8, ...quiet }];
+ const right = [
+ { projectId: "a", trackId: "t", placement: 1, score: 8.4, rubricComponent: 8, pairwiseComponent: 2 },
+ ];
+ expect(diffPlacements(left, right)).toEqual([
+ {
+ projectId: "a",
+ trackId: "t",
+ from: 1,
+ to: 1,
+ score: 8.4,
+ rubricFrom: 8,
+ rubricTo: 8,
+ pairwiseFrom: 0,
+ pairwiseTo: 2,
+ },
+ ]);
+ });
+});
diff --git a/packages/judging-server/src/services/results.ts b/packages/judging-server/src/services/results.ts
new file mode 100644
index 00000000..addcd6f1
--- /dev/null
+++ b/packages/judging-server/src/services/results.ts
@@ -0,0 +1,328 @@
+import { and, desc, eq, isNotNull, isNull, sql } from "drizzle-orm";
+import { phaseAllows, rank } from "@query/judging-core";
+import type { PanelDb } from "@query/judging-db";
+import {
+ comparison,
+ event,
+ eventConfig,
+ eventLog,
+ judge,
+ outbox,
+ project,
+ projectTrack,
+ result,
+ resultRun,
+ track,
+ vote,
+} from "@query/judging-db";
+
+export type Placement = {
+ projectId: string;
+ trackId: string;
+ placement: number | null;
+ score: number;
+ rubricComponent: number;
+ pairwiseComponent: number;
+};
+
+/** Projects whose place or blend components changed between two runs. */
+export function diffPlacements(left: readonly Placement[], right: readonly Placement[]) {
+ const later = new Map(right.map((row) => [`${row.trackId}:${row.projectId}`, row]));
+ return left.flatMap((row) => {
+ const other = later.get(`${row.trackId}:${row.projectId}`);
+ const to = other?.placement ?? null;
+ const score = other?.score ?? row.score;
+ const rubricTo = other?.rubricComponent ?? row.rubricComponent;
+ const pairwiseTo = other?.pairwiseComponent ?? row.pairwiseComponent;
+ if (
+ row.placement === to &&
+ row.score === score &&
+ row.rubricComponent === rubricTo &&
+ row.pairwiseComponent === pairwiseTo
+ ) {
+ return [];
+ }
+ return [
+ {
+ projectId: row.projectId,
+ trackId: row.trackId,
+ from: row.placement,
+ to,
+ score,
+ rubricFrom: row.rubricComponent,
+ rubricTo,
+ pairwiseFrom: row.pairwiseComponent,
+ pairwiseTo,
+ },
+ ];
+ });
+}
+
+export async function computeResults(
+ db: PanelDb,
+ input: { eventId: string; actorEmail: string; now: Date },
+) {
+ return db.transaction(async (tx) => {
+ const [evt] = await tx
+ .select({ phase: event.phase })
+ .from(event)
+ .where(eq(event.id, input.eventId));
+ const [config] = await tx
+ .select()
+ .from(eventConfig)
+ .where(eq(eventConfig.eventId, input.eventId));
+ if (!evt || !config) throw new Error("Event not found");
+ if (!phaseAllows(evt.phase, "compute")) {
+ throw new Error(`compute is not allowed while the event is ${evt.phase}`);
+ }
+
+ const projects = await tx
+ .select()
+ .from(project)
+ .where(and(eq(project.eventId, input.eventId), isNull(project.withdrawnAt)));
+ const links = await tx
+ .select({ projectId: projectTrack.projectId, trackId: projectTrack.trackId })
+ .from(projectTrack)
+ .innerJoin(project, eq(project.id, projectTrack.projectId))
+ .where(eq(project.eventId, input.eventId));
+ const tracks = await tx
+ .select({ id: track.id, judgeGroup: track.judgeGroup })
+ .from(track)
+ .where(eq(track.eventId, input.eventId));
+ const judges = await tx
+ .select({ id: judge.id, trackId: judge.trackId })
+ .from(judge)
+ .where(eq(judge.eventId, input.eventId));
+ const groupOf = new Map(
+ judges.map((row) => [
+ row.id,
+ tracks.find((item) => item.id === row.trackId)?.judgeGroup ?? "main",
+ ]),
+ );
+ const votes = await tx
+ .select()
+ .from(vote)
+ .where(eq(vote.eventId, input.eventId));
+ const comparisons = await tx
+ .select()
+ .from(comparison)
+ .where(eq(comparison.eventId, input.eventId));
+
+ const byProject = new Map();
+ for (const link of links) {
+ const list = byProject.get(link.projectId) ?? [];
+ list.push(link.trackId);
+ byProject.set(link.projectId, list);
+ }
+
+ const ranked = rank(
+ projects.map((row) => ({
+ id: row.id,
+ trackIds: byProject.get(row.id) ?? [],
+ })),
+ votes.map((row) => ({
+ judgeId: row.judgeId,
+ projectId: row.projectId,
+ judgeGroup: groupOf.get(row.judgeId) ?? "main",
+ total: row.total,
+ isCalibration: row.isCalibration,
+ })),
+ comparisons.map((row) => ({
+ judgeId: row.judgeId,
+ judgeGroup: row.judgeGroup,
+ a: row.aProjectId,
+ b: row.bProjectId,
+ outcome: row.outcome,
+ })),
+ {
+ bayesianC: config.bayesianC,
+ pairwiseWeight: config.pairwiseEnabled ? config.pairwiseWeight : 0,
+ calibrationWeight: config.calibrationWeight,
+ },
+ );
+
+ const [run] = await tx
+ .insert(resultRun)
+ .values({
+ eventId: input.eventId,
+ computedAt: input.now,
+ configSnapshot: {
+ bayesianC: config.bayesianC,
+ pairwiseWeight: config.pairwiseWeight,
+ pairwiseEnabled: config.pairwiseEnabled,
+ calibrationWeight: config.calibrationWeight,
+ },
+ })
+ .returning({ id: resultRun.id });
+ if (!run) throw new Error("Result run was not stored");
+
+ const placed = ranked.filter((row) => row.placement !== null);
+ if (placed.length > 0) {
+ await tx.insert(result).values(
+ placed.map((row) => ({
+ runId: run.id,
+ projectId: row.projectId,
+ trackId: row.trackId,
+ placement: row.placement,
+ score: row.score,
+ rubricComponent: row.rubricComponent,
+ pairwiseComponent: row.pairwiseComponent,
+ voteCount: row.voteCount,
+ comparisonCount: row.comparisonCount,
+ flags: [],
+ })),
+ );
+ }
+
+ await tx.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "results.computed",
+ actor: { email: input.actorEmail },
+ subject: { runId: run.id },
+ payload: { rows: placed.length },
+ });
+
+ return { runId: run.id, rows: placed.length };
+ });
+}
+
+export async function publishResults(
+ db: PanelDb,
+ input: { eventId: string; runId: string; actorEmail: string; now: Date },
+) {
+ return db.transaction(async (tx) => {
+ const [evt] = await tx
+ .select({ phase: event.phase })
+ .from(event)
+ .where(eq(event.id, input.eventId));
+ if (!evt) throw new Error("Event not found");
+ if (!phaseAllows(evt.phase, "publish")) {
+ throw new Error(`publish is not allowed while the event is ${evt.phase}`);
+ }
+ const [run] = await tx
+ .select({ id: resultRun.id })
+ .from(resultRun)
+ .where(and(eq(resultRun.id, input.runId), eq(resultRun.eventId, input.eventId)));
+ if (!run) throw new Error("Result run not found");
+
+ await tx
+ .update(resultRun)
+ .set({ publishedAt: input.now })
+ .where(eq(resultRun.id, run.id));
+ await tx.update(event).set({ phase: "published" }).where(eq(event.id, input.eventId));
+ await tx.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "results.published",
+ actor: { email: input.actorEmail },
+ subject: { runId: run.id },
+ payload: { from: evt.phase, to: "published" },
+ });
+ await tx.insert(outbox).values({
+ eventId: input.eventId,
+ topic: "results.published",
+ payload: { runId: run.id },
+ });
+ await tx.execute(sql`
+ update project
+ set feedback_token = gen_random_uuid()
+ where event_id = ${input.eventId} and feedback_token is null
+ `);
+ return { runId: run.id };
+ });
+}
+
+export async function unpublishResults(
+ db: PanelDb,
+ input: { eventId: string; actorEmail: string },
+) {
+ return db.transaction(async (tx) => {
+ const [evt] = await tx
+ .select({ phase: event.phase })
+ .from(event)
+ .where(eq(event.id, input.eventId));
+ if (!evt) throw new Error("Event not found");
+ if (evt.phase !== "published") {
+ throw new Error(`unpublish is not allowed while the event is ${evt.phase}`);
+ }
+ const [run] = await tx
+ .select({ id: resultRun.id })
+ .from(resultRun)
+ .where(and(eq(resultRun.eventId, input.eventId), isNotNull(resultRun.publishedAt)))
+ .orderBy(desc(resultRun.publishedAt))
+ .limit(1);
+ if (!run) throw new Error("Nothing is published");
+ await tx.update(resultRun).set({ publishedAt: null }).where(eq(resultRun.id, run.id));
+ await tx.update(event).set({ phase: "judging_closed" }).where(eq(event.id, input.eventId));
+ await tx.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "results.unpublished",
+ actor: { email: input.actorEmail },
+ subject: { runId: run.id },
+ payload: { from: "published", to: "judging_closed" },
+ });
+ await tx.insert(outbox).values({
+ eventId: input.eventId,
+ topic: "results.unpublished",
+ payload: { runId: run.id },
+ });
+ return { runId: run.id };
+ });
+}
+
+export async function placementsForRun(db: PanelDb, runId: string): Promise {
+ const rows = await db
+ .select({
+ projectId: result.projectId,
+ trackId: result.trackId,
+ placement: result.placement,
+ score: result.score,
+ rubricComponent: result.rubricComponent,
+ pairwiseComponent: result.pairwiseComponent,
+ })
+ .from(result)
+ .where(eq(result.runId, runId))
+ .orderBy(desc(result.score));
+ return rows;
+}
+
+export async function inspectRun(db: PanelDb, runId: string) {
+ return db
+ .select({
+ projectId: result.projectId,
+ projectName: project.name,
+ trackId: result.trackId,
+ placement: result.placement,
+ score: result.score,
+ rubricComponent: result.rubricComponent,
+ pairwiseComponent: result.pairwiseComponent,
+ voteCount: result.voteCount,
+ comparisonCount: result.comparisonCount,
+ })
+ .from(result)
+ .innerJoin(project, eq(project.id, result.projectId))
+ .where(eq(result.runId, runId))
+ .orderBy(desc(result.score));
+}
+
+export async function publishedPlacements(db: PanelDb, eventId: string) {
+ const [evt] = await db
+ .select({ phase: event.phase })
+ .from(event)
+ .where(eq(event.id, eventId));
+ if (!evt) return null;
+ if (evt.phase !== "published") return { published: false as const, rows: [] };
+ const rows = await db
+ .select({
+ externalId: project.externalId,
+ placement: result.placement,
+ score: result.score,
+ track: track.slug,
+ voteCount: result.voteCount,
+ })
+ .from(result)
+ .innerJoin(resultRun, eq(resultRun.id, result.runId))
+ .innerJoin(project, eq(project.id, result.projectId))
+ .innerJoin(track, eq(track.id, result.trackId))
+ .where(and(eq(resultRun.eventId, eventId), isNotNull(resultRun.publishedAt)));
+ return { published: true as const, rows };
+}
diff --git a/packages/judging-server/src/services/visit-status.integration.test.ts b/packages/judging-server/src/services/visit-status.integration.test.ts
new file mode 100644
index 00000000..6aaf3ea7
--- /dev/null
+++ b/packages/judging-server/src/services/visit-status.integration.test.ts
@@ -0,0 +1,41 @@
+import { eq } from "drizzle-orm";
+import { describe, expect, it } from "vitest";
+import { createDb, databaseUrl, judge, migrate, seedDemo } from "@query/judging-db";
+import { dispatchNext, visitStatus } from "./dispatch";
+import { recallJudge, voidVisit } from "./records";
+
+const url = databaseUrl();
+
+describe.skipIf(!url)("visit status", () => {
+ it("reports a recall since the hand-out, then a void", async () => {
+ const { db, pool } = createDb(url as string);
+ try {
+ await migrate(pool);
+ const { eventId } = await seedDemo(db);
+ const [person] = await db.select().from(judge).where(eq(judge.eventId, eventId));
+ if (!person) throw new Error("demo judge missing");
+
+ const handed = await dispatchNext(db, { eventId, judgeId: person.id, now: new Date() });
+ if (handed.done) throw new Error("demo floor is empty");
+ const ask = () => visitStatus(db, { eventId, judgeId: person.id, visitId: handed.visitId });
+
+ expect(await ask()).toEqual({ voided: false, recalledAt: null });
+
+ await recallJudge(db, { eventId, judgeId: person.id, actorEmail: "organizer@example.com" });
+ const recalled = await ask();
+ expect(recalled.voided).toBe(false);
+ expect(recalled.recalledAt).not.toBeNull();
+
+ await voidVisit(db, {
+ eventId,
+ visitId: handed.visitId,
+ now: new Date(),
+ actorEmail: "organizer@example.com",
+ reason: "test",
+ });
+ expect((await ask()).voided).toBe(true);
+ } finally {
+ await pool.end();
+ }
+ });
+});
diff --git a/packages/judging-server/src/services/vote.test.ts b/packages/judging-server/src/services/vote.test.ts
new file mode 100644
index 00000000..43dd458b
--- /dev/null
+++ b/packages/judging-server/src/services/vote.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_TIMERS } from "@query/judging-core";
+import { assessVote } from "./vote";
+
+const criteria = [{ id: "creativity", min: 0, max: 10, weight: 1 }];
+const arrived = new Date("2027-02-28T13:00:00Z");
+const at = (seconds: number) => new Date(arrived.getTime() + seconds * 1000);
+
+describe("assessVote", () => {
+ it("refuses a vote outside judging_live", () => {
+ const decision = assessVote({
+ phase: "setup",
+ arrivedAt: arrived,
+ now: at(10),
+ config: DEFAULT_TIMERS,
+ criteria,
+ scores: [{ criterionId: "creativity", value: 5 }],
+ });
+ expect(decision.ok).toBe(false);
+ if (!decision.ok) expect(decision.message).toMatch(/setup/);
+ });
+
+ it("names the configured hard limit when the score is late", () => {
+ const config = { ...DEFAULT_TIMERS, hardLimitSeconds: 90, submitGraceSeconds: 0 };
+ const decision = assessVote({
+ phase: "judging_live",
+ arrivedAt: arrived,
+ now: at(91),
+ config,
+ criteria,
+ scores: [{ criterionId: "creativity", value: 5 }],
+ });
+ expect(decision.ok).toBe(false);
+ if (!decision.ok) expect(decision.message).toContain("90s");
+ });
+
+ it("accepts a score inside the window and derives the total", () => {
+ const decision = assessVote({
+ phase: "judging_live",
+ arrivedAt: arrived,
+ now: at(10),
+ config: DEFAULT_TIMERS,
+ criteria,
+ scores: [{ criterionId: "creativity", value: 8 }],
+ });
+ expect(decision).toEqual({ ok: true, total: 8 });
+ });
+});
diff --git a/packages/judging-server/src/services/vote.ts b/packages/judging-server/src/services/vote.ts
new file mode 100644
index 00000000..bb1d8f0d
--- /dev/null
+++ b/packages/judging-server/src/services/vote.ts
@@ -0,0 +1,175 @@
+import { and, eq } from "drizzle-orm";
+import { isPastCutoff, phaseAllows, validateScores, weightedTotal } from "@query/judging-core";
+import type { Criterion, Phase, ScoreInput, TimerConfig } from "@query/judging-core";
+import type { PanelDb } from "@query/judging-db";
+import {
+ criterion,
+ event,
+ eventConfig,
+ eventLog,
+ outbox,
+ rubric,
+ track,
+ visit,
+ vote,
+ voteScore,
+} from "@query/judging-db";
+
+export type VoteDecision =
+ | { ok: true; total: number }
+ | { ok: false; message: string };
+
+/**
+ * Whether this score can be stored. The cutoff message names the configured
+ * hard limit so the phone can show the number the event actually used.
+ */
+export function assessVote(input: {
+ phase: Phase;
+ arrivedAt: Date | null;
+ now: Date;
+ config: TimerConfig;
+ criteria: readonly Criterion[];
+ scores: readonly ScoreInput[];
+}): VoteDecision {
+ if (!phaseAllows(input.phase, "vote")) {
+ return {
+ ok: false,
+ message: `vote is not allowed while the event is ${input.phase}`,
+ };
+ }
+ if (!input.arrivedAt) {
+ return { ok: false, message: "Arrive at the table before scoring" };
+ }
+ if (isPastCutoff(input.arrivedAt, input.now, input.config)) {
+ return {
+ ok: false,
+ message: `Vote is past the ${input.config.hardLimitSeconds}s hard limit`,
+ };
+ }
+ const check = validateScores(input.criteria, input.scores);
+ if (!check.ok) return { ok: false, message: check.reason };
+ return { ok: true, total: weightedTotal(input.criteria, input.scores) };
+}
+
+export async function castVote(
+ db: PanelDb,
+ input: {
+ eventId: string;
+ judgeId: string;
+ visitId: string;
+ scores: readonly ScoreInput[];
+ comment: string | null;
+ now: Date;
+ },
+): Promise<{ total: number }> {
+ return db.transaction(async (tx) => {
+ const [evt] = await tx
+ .select({ phase: event.phase })
+ .from(event)
+ .where(eq(event.id, input.eventId));
+ const [config] = await tx
+ .select()
+ .from(eventConfig)
+ .where(eq(eventConfig.eventId, input.eventId));
+ const [row] = await tx
+ .select()
+ .from(visit)
+ .where(and(eq(visit.id, input.visitId), eq(visit.judgeId, input.judgeId)));
+ if (!evt || !config || !row) throw new Error("Visit not found");
+ if (row.eventId !== input.eventId) throw new Error("Visit not found");
+ if (row.voidedAt || row.completedAt) throw new Error("This visit is closed");
+
+ const criteria = await loadCriteria(tx, input.eventId, row.judgeGroup);
+ const decision = assessVote({
+ phase: evt.phase,
+ arrivedAt: row.arrivedAt,
+ now: input.now,
+ config: {
+ targetSeconds: config.targetSeconds,
+ hardLimitSeconds: config.hardLimitSeconds,
+ walkLimitSeconds: config.walkLimitSeconds,
+ submitGraceSeconds: config.submitGraceSeconds,
+ },
+ criteria,
+ scores: input.scores,
+ });
+ if (!decision.ok) throw new Error(decision.message);
+
+ const duration =
+ row.arrivedAt === null
+ ? null
+ : Math.round((input.now.getTime() - row.arrivedAt.getTime()) / 1000);
+
+ const [stored] = await tx
+ .insert(vote)
+ .values({
+ visitId: row.id,
+ judgeId: input.judgeId,
+ projectId: row.projectId,
+ eventId: input.eventId,
+ total: decision.total,
+ comment: input.comment,
+ durationSeconds: duration,
+ isCalibration: false,
+ })
+ .returning({ id: vote.id });
+ if (!stored) throw new Error("Vote was not stored");
+
+ if (input.scores.length > 0) {
+ await tx.insert(voteScore).values(
+ input.scores.map((score) => ({
+ voteId: stored.id,
+ criterionId: score.criterionId,
+ value: score.value,
+ })),
+ );
+ }
+ await tx
+ .update(visit)
+ .set({ completedAt: input.now })
+ .where(eq(visit.id, row.id));
+ await tx.insert(eventLog).values({
+ eventId: input.eventId,
+ kind: "vote.cast",
+ actor: { judgeId: input.judgeId },
+ subject: { visitId: row.id, projectId: row.projectId },
+ payload: { total: decision.total },
+ });
+ await tx.insert(outbox).values({
+ eventId: input.eventId,
+ topic: "vote.cast",
+ payload: { visitId: row.id, judgeId: input.judgeId, total: decision.total },
+ });
+ return { total: decision.total };
+ });
+}
+
+async function loadCriteria(
+ tx: Parameters[0]>[0],
+ eventId: string,
+ judgeGroup: string,
+): Promise {
+ const [groupTrack] = await tx
+ .select({ rubricId: track.rubricId })
+ .from(track)
+ .where(and(eq(track.eventId, eventId), eq(track.judgeGroup, judgeGroup)));
+ const rubricId = groupTrack?.rubricId
+ ? groupTrack.rubricId
+ : (
+ await tx
+ .select({ id: rubric.id })
+ .from(rubric)
+ .where(and(eq(rubric.eventId, eventId), eq(rubric.isDefault, true)))
+ )[0]?.id;
+ if (!rubricId) return [];
+ const rows = await tx
+ .select()
+ .from(criterion)
+ .where(eq(criterion.rubricId, rubricId));
+ return rows.map((row) => ({
+ id: row.id,
+ min: row.min,
+ max: row.max,
+ weight: row.weight,
+ }));
+}
diff --git a/packages/judging-server/tsconfig.json b/packages/judging-server/tsconfig.json
new file mode 100644
index 00000000..f54bde2c
--- /dev/null
+++ b/packages/judging-server/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tooling/typescript/base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/judging-server/vitest.config.ts b/packages/judging-server/vitest.config.ts
new file mode 100644
index 00000000..f62398d7
--- /dev/null
+++ b/packages/judging-server/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["src/**/*.test.ts"],
+ fileParallelism: false,
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8907bf4d..ecbd1840 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -49,6 +49,12 @@ importers:
'@query/db':
specifier: workspace:*
version: link:../db
+ '@query/judging-db':
+ specifier: workspace:*
+ version: link:../judging-db
+ '@query/judging-server':
+ specifier: workspace:*
+ version: link:../judging-server
'@tanstack/react-query':
specifier: 5.103.2
version: 5.103.2(react@19.3.0)
@@ -86,6 +92,9 @@ importers:
specifier: 4.6.5
version: 4.6.5
devDependencies:
+ '@query/judging-core':
+ specifier: workspace:*
+ version: link:../judging-core
'@query/tsconfig':
specifier: workspace:*
version: link:../../tooling/typescript
@@ -188,6 +197,129 @@ importers:
specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz
version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz
+ packages/judging-cli:
+ dependencies:
+ '@query/judging-db':
+ specifier: workspace:*
+ version: link:../judging-db
+ '@query/judging-server':
+ specifier: workspace:*
+ version: link:../judging-server
+ drizzle-orm:
+ specifier: 0.45.3
+ version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9)
+ tsx:
+ specifier: ^4.23.15
+ version: 4.23.15
+ devDependencies:
+ '@query/eslint-config':
+ specifier: workspace:*
+ version: link:../../tooling/eslint
+ '@query/tsconfig':
+ specifier: workspace:*
+ version: link:../../tooling/typescript
+ '@types/node':
+ specifier: 22.20.4
+ version: 22.20.4
+ typescript:
+ specifier: 7.0.2
+ version: 7.0.2
+
+ packages/judging-core:
+ devDependencies:
+ '@query/eslint-config':
+ specifier: workspace:*
+ version: link:../../tooling/eslint
+ '@query/tsconfig':
+ specifier: workspace:*
+ version: link:../../tooling/typescript
+ '@types/node':
+ specifier: 22.20.4
+ version: 22.20.4
+ typescript:
+ specifier: 7.0.2
+ version: 7.0.2
+ vitest:
+ specifier: ^5.0.1
+ version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15))
+
+ packages/judging-db:
+ dependencies:
+ '@query/judging-core':
+ specifier: workspace:*
+ version: link:../judging-core
+ drizzle-orm:
+ specifier: 0.45.3
+ version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9)
+ pg:
+ specifier: 8.23.0
+ version: 8.23.0
+ devDependencies:
+ '@query/eslint-config':
+ specifier: workspace:*
+ version: link:../../tooling/eslint
+ '@query/tsconfig':
+ specifier: workspace:*
+ version: link:../../tooling/typescript
+ '@types/node':
+ specifier: 22.20.4
+ version: 22.20.4
+ '@types/pg':
+ specifier: ^8.23.1
+ version: 8.23.1
+ typescript:
+ specifier: 7.0.2
+ version: 7.0.2
+
+ packages/judging-server:
+ dependencies:
+ '@query/judging-core':
+ specifier: workspace:*
+ version: link:../judging-core
+ '@query/judging-db':
+ specifier: workspace:*
+ version: link:../judging-db
+ '@trpc/server':
+ specifier: 11.19.0
+ version: 11.19.0(typescript@7.0.2)
+ drizzle-orm:
+ specifier: 0.45.3
+ version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9)
+ hono:
+ specifier: ^4.9.0
+ version: 4.13.13
+ ioredis:
+ specifier: ^5.8.2
+ version: 5.11.1
+ jose:
+ specifier: ^6.1.0
+ version: 6.2.3
+ nodemailer:
+ specifier: ^10.0.10
+ version: 10.0.10
+ prom-client:
+ specifier: 15.1.3
+ version: 15.1.3
+ zod:
+ specifier: 4.6.5
+ version: 4.6.5
+ devDependencies:
+ '@query/eslint-config':
+ specifier: workspace:*
+ version: link:../../tooling/eslint
+ '@query/tsconfig':
+ specifier: workspace:*
+ version: link:../../tooling/typescript
+ '@types/node':
+ specifier: 22.20.4
+ version: 22.20.4
+ typescript:
+ specifier: 7.0.2
+ version: 7.0.2
+ vitest:
+ specifier: ^5.0.1
+ version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15))
+
packages/ui:
dependencies:
minimatch:
@@ -1162,6 +1294,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@ioredis/commands@1.10.0':
+ resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==}
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -2651,6 +2786,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
+ cluster-key-slot@1.1.1:
+ resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
+ engines: {node: '>=0.10.0'}
+
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -2758,6 +2897,10 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
+ denque@2.1.0:
+ resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
+ engines: {node: '>=0.10'}
+
detect-libc@1.0.3:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
engines: {node: '>=0.10'}
@@ -3357,6 +3500,10 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+ hono@4.13.13:
+ resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==}
+ engines: {node: '>=16.9.0'}
+
hookified@1.15.1:
resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==}
@@ -3411,6 +3558,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
+ ioredis@5.11.1:
+ resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==}
+ engines: {node: '>=12.22.0'}
+
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -4204,6 +4355,14 @@ packages:
resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==}
engines: {node: '>=18'}
+ redis-errors@1.2.0:
+ resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
+ engines: {node: '>=4'}
+
+ redis-parser@3.0.0:
+ resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
+ engines: {node: '>=4'}
+
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -4362,6 +4521,9 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+ standard-as-callback@2.1.0:
+ resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
+
std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
@@ -5326,6 +5488,8 @@ snapshots:
'@img/sharp-win32-x64@0.35.4':
optional: true
+ '@ioredis/commands@1.10.0': {}
+
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
@@ -6748,6 +6912,8 @@ snapshots:
clsx@2.1.1: {}
+ cluster-key-slot@1.1.1: {}
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -6842,6 +7008,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
+ denque@2.1.0: {}
+
detect-libc@1.0.3: {}
detect-libc@2.1.2: {}
@@ -7526,6 +7694,8 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
+ hono@4.13.13: {}
+
hookified@1.15.1: {}
hookified@2.2.0: {}
@@ -7580,6 +7750,18 @@ snapshots:
hasown: 2.0.3
side-channel: 1.1.1
+ ioredis@5.11.1:
+ dependencies:
+ '@ioredis/commands': 1.10.0
+ cluster-key-slot: 1.1.1
+ debug: 4.4.3
+ denque: 2.1.0
+ redis-errors: 1.2.0
+ redis-parser: 3.0.0
+ standard-as-callback: 2.1.0
+ transitivePeerDependencies:
+ - supports-color
+
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.9
@@ -8257,6 +8439,12 @@ snapshots:
dependencies:
minimatch: 10.2.6
+ redis-errors@1.2.0: {}
+
+ redis-parser@3.0.0:
+ dependencies:
+ redis-errors: 1.2.0
+
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.9
@@ -8495,6 +8683,8 @@ snapshots:
stackback@0.0.2: {}
+ standard-as-callback@2.1.0: {}
+
std-env@4.2.0: {}
stop-iteration-iterator@1.1.0:
diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx
index 805ed3c0..14e9d414 100644
--- a/sites/mainweb/app/(portal)/admin/judging/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useMemo, useRef } from "react";
+import Link from "next/link";
import { loginHref } from "@/lib/safe-callback";
import { useSession } from "next-auth/react";
import { trpc } from "@/lib/trpc";
@@ -57,6 +58,11 @@ export default function AdminResultsPage() {
);
// Get rankings for selected hackathon
+ const { data: panelConsole } = trpc.judge.panelConsole.useQuery(
+ { hackathonId: selectedHackathon as string },
+ { enabled: !!selectedHackathon },
+ );
+
const { data: rankings } = trpc.judge.getRankings.useQuery(
{ hackathonId: selectedHackathon as string },
{ enabled: !!selectedHackathon },
@@ -82,6 +88,9 @@ export default function AdminResultsPage() {
// enforced: sync submissions, then build the queues. Doing them in the wrong
// order leaves late projects in nobody's queue.
const utils = trpc.useUtils();
+ const setJudgingBackend = trpc.judge.setJudgingBackend.useMutation({
+ onSuccess: () => utils.judge.panelConsole.invalidate(),
+ });
const [prepState, setPrepState] = useState<{
busy: boolean;
message: string | null;
@@ -251,6 +260,36 @@ export default function AdminResultsPage() {
? "Pick a hackathon to see its results."
: "Open and close judging, watch the floor, and compare scores."}
+ {selectedHackathon && panelConsole ? (
+
+
+ {panelConsole.backend === "panel"
+ ? "Judged on panel: judges score from their phones."
+ : "Classic judging."}
+
+ {panelConsole.url ? (
+
+ Open the panel console
+
+ ) : null}
+
+
+ ) : null}
{/* Judging Control Panel */}
diff --git a/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/console.tsx b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/console.tsx
new file mode 100644
index 00000000..197c95e1
--- /dev/null
+++ b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/console.tsx
@@ -0,0 +1,464 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { apiBase } from "@/lib/panel";
+import type { PublicEvent } from "@/lib/panel";
+import { Editors } from "./editors";
+
+const phases = [
+ "setup",
+ "submissions_open",
+ "submissions_closed",
+ "judging_live",
+ "judging_closed",
+ "published",
+ "archived",
+] as const;
+
+export function Console({ event }: { event: PublicEvent }) {
+ const [message, setMessage] = useState(event.phase);
+ const [phase, setPhase] = useState(event.phase);
+ const [floor, setFloor] = useState<{
+ tables: { tableNumber: number | null; looks: number }[];
+ judges: {
+ name: string;
+ state: string;
+ overtime: boolean;
+ tableNumber: number | null;
+ }[];
+ } | null>(null);
+ const [cards, setCards] = useState<
+ {
+ id: string;
+ name: string;
+ tableNumber: number | null;
+ zoneName: string | null;
+ }[]
+ >([]);
+ const [logs, setLogs] = useState<{ id: string; kind: string }[]>([]);
+ const [runId, setRunId] = useState("");
+ const [inspected, setInspected] = useState<
+ {
+ projectId: string;
+ projectName: string;
+ trackId: string;
+ placement: number | null;
+ score: number;
+ rubricComponent: number;
+ pairwiseComponent: number;
+ }[]
+ >([]);
+ const [trackName, setTrackName] = useState("");
+ const [judgeId, setJudgeId] = useState("");
+
+ async function post(path: string, body: unknown) {
+ const response = await fetch(`${apiBase}/trpc/${path}`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const payload = (await response.json()) as {
+ error?: { message?: string };
+ result?: { data?: { runId?: string; assigned?: number } };
+ };
+ if (!response.ok || payload.error) {
+ throw new Error(payload.error?.message ?? "Request failed");
+ }
+ return payload.result?.data;
+ }
+
+ useEffect(() => {
+ // The floor only moves while judging is live, and only matters on screen.
+ // Anything else would keep the database awake (see the public board).
+ if (phase !== "judging_live") return;
+ let stop = false;
+ let timer: ReturnType | undefined;
+ const pull = async () => {
+ const response = await fetch(`${apiBase}/v1/floor/${event.eventId}`);
+ if (!response.ok || stop) return;
+ setFloor(
+ (await response.json()) as {
+ tables: { tableNumber: number | null; looks: number }[];
+ judges: {
+ name: string;
+ state: string;
+ overtime: boolean;
+ tableNumber: number | null;
+ }[];
+ },
+ );
+ };
+ const sync = () => {
+ clearInterval(timer);
+ if (document.visibilityState !== "visible") return;
+ void pull();
+ timer = setInterval(() => void pull(), 5000);
+ };
+ sync();
+ document.addEventListener("visibilitychange", sync);
+ return () => {
+ stop = true;
+ clearInterval(timer);
+ document.removeEventListener("visibilitychange", sync);
+ };
+ }, [phase, event.eventId]);
+
+ return (
+
+ {event.name}
+ Current phase: {message}
+ {floor ? (
+
+ Floor
+
+ {floor.tables.map((table) => (
+ -
+ {table.tableNumber}: {table.looks}
+
+ ))}
+
+
+ {floor.judges.map((person) => (
+ -
+ {person.name}: {person.state}
+ {person.tableNumber !== null
+ ? ` · table ${person.tableNumber}`
+ : ""}
+ {person.overtime ? " · overtime" : ""}
+
+ ))}
+
+
+ ) : null}
+ {phases.map((phase) => (
+
+ ))}
+
+
+
+ {inspected.length > 0 ? (
+
+
+
+ | Place |
+ Project |
+ Score |
+ Rubric |
+ Pairwise |
+
+
+
+ {inspected.map((row) => (
+
+ | {row.placement} |
+ {row.projectName} |
+ {row.score} |
+ {row.rubricComponent} |
+ {row.pairwiseComponent} |
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+ {logs.map((row) => (
+ - {row.kind}
+ ))}
+
+
+
+ {cards
+ .slice()
+ .sort(
+ (a, b) =>
+ (a.zoneName ?? "").localeCompare(b.zoneName ?? "") ||
+ (a.tableNumber ?? 0) - (b.tableNumber ?? 0),
+ )
+ .map((card, index, all) => {
+ const zoneLabel = card.zoneName ?? "Unzoned";
+ const previous = all[index - 1];
+ const showZone =
+ !previous || (previous.zoneName ?? "Unzoned") !== zoneLabel;
+ return (
+
+ {showZone ? {zoneLabel}
: null}
+
+ {card.tableNumber}
+
+ {card.name}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/editors.tsx b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/editors.tsx
new file mode 100644
index 00000000..49de1196
--- /dev/null
+++ b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/editors.tsx
@@ -0,0 +1,426 @@
+"use client";
+
+import { useState } from "react";
+import type { CSSProperties, PointerEvent } from "react";
+import { apiBase } from "@/lib/panel";
+
+type Spot = {
+ number: number;
+ zoneId: string | null;
+ x: number | null;
+ y: number | null;
+};
+type JudgeRow = { id: string; name: string; email: string; status: string };
+
+function readValue(event: { target: EventTarget | null }) {
+ const target = event.target;
+ if (target && "value" in target) return String(target.value);
+ return "";
+}
+
+function placeOf(spot: Spot) {
+ return {
+ x: spot.x ?? (spot.number % 8) * 76,
+ y: spot.y ?? Math.floor((spot.number - 1) / 8) * 68,
+ };
+}
+
+export function Editors({
+ eventId,
+ onMessage,
+}: {
+ eventId: string;
+ onMessage: (message: string) => void;
+}) {
+ const [rubricName, setRubricName] = useState("");
+ const [criterionLabel, setCriterionLabel] = useState("");
+ const [zoneName, setZoneName] = useState("");
+ const [trackId, setTrackId] = useState("");
+ const [prizeTitle, setPrizeTitle] = useState("");
+ const [judgeEmail, setJudgeEmail] = useState("");
+ const [judgeName, setJudgeName] = useState("");
+ const [projectId, setProjectId] = useState("");
+ const [tables, setTables] = useState([]);
+ const [judges, setJudges] = useState([]);
+ const [drag, setDrag] = useState<{
+ number: number;
+ dx: number;
+ dy: number;
+ } | null>(null);
+
+ async function post(path: string, body: unknown) {
+ const response = await fetch(`${apiBase}/trpc/${path}`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const payload = (await response.json()) as { error?: { message?: string } };
+ if (!response.ok || payload.error) {
+ throw new Error(payload.error?.message ?? "Request failed");
+ }
+ }
+
+ function moveTable(pointer: PointerEvent, spot: Spot) {
+ if (!drag || drag.number !== spot.number) return;
+ const parent = pointer.currentTarget.parentElement;
+ if (!parent) return;
+ const rect = parent.getBoundingClientRect();
+ const x = Math.max(0, Math.round(pointer.clientX - rect.left - drag.dx));
+ const y = Math.max(0, Math.round(pointer.clientY - rect.top - drag.dy));
+ setTables((current) =>
+ current.map((item) =>
+ item.number === spot.number ? { ...item, x, y } : item,
+ ),
+ );
+ }
+
+ return (
+
+ Rubric
+
+
+
+
+ Zone and floor
+
+
+
+
+ {tables.map((spot) => {
+ const at = placeOf(spot);
+ return (
+
+ );
+ })}
+
+
+ Prize
+
+
+
+
+ Judges
+
+
+
+
+
+ {judges.map((person) => (
+ -
+ {person.name} · {person.status}{" "}
+
+
+ ))}
+
+
+ Withdraw a project
+
+
+
+ );
+}
+
+const field: CSSProperties = {
+ display: "block",
+ width: "100%",
+ margin: "0.25rem 0 0.75rem",
+};
+
+const floor: CSSProperties = {
+ position: "relative",
+ height: 420,
+ margin: "1rem 0",
+ background: "#f5f5f4",
+ border: "1px solid #d6d3d1",
+};
+
+const chip: CSSProperties = {
+ position: "absolute",
+ width: 64,
+ height: 48,
+ background: "white",
+ border: "1px solid #1c1917",
+};
diff --git a/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/page.tsx b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/page.tsx
new file mode 100644
index 00000000..92ca5b08
--- /dev/null
+++ b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/page.tsx
@@ -0,0 +1,16 @@
+import { Console } from "./console";
+import { loadEvent } from "@/lib/panel.server";
+
+export default async function OrganizerPage({
+ params,
+}: {
+ params: Promise<{ hackathonId: string }>;
+}) {
+ const { hackathonId } = await params;
+ const event = await loadEvent(hackathonId);
+ if (!event)
+ return (
+ This event is not available.
+ );
+ return ;
+}
diff --git a/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts b/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts
new file mode 100644
index 00000000..982485c6
--- /dev/null
+++ b/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts
@@ -0,0 +1,27 @@
+import { panel } from "@query/api/panel";
+
+/**
+ * The judging API, served by this app. Routes are written against `/`, so the
+ * prefix comes off before the request reaches them. Callers are identified by
+ * their portal session.
+ */
+const PREFIX = "/api/panel";
+
+async function handler(req: Request) {
+ const url = new URL(req.url);
+ url.pathname = url.pathname.slice(PREFIX.length) || "/";
+ // Not public: process metrics, and a readiness probe that queries Postgres.
+ // An uptime checker on /readyz would keep Neon awake around the clock.
+ if (url.pathname === "/metrics" || url.pathname === "/readyz") {
+ return new Response(null, { status: 404 });
+ }
+ const body =
+ req.method === "GET" || req.method === "HEAD"
+ ? undefined
+ : await req.arrayBuffer();
+ return panel().app.fetch(
+ new Request(url, { method: req.method, headers: req.headers, body }),
+ );
+}
+
+export { handler as GET, handler as POST };
diff --git a/sites/mainweb/app/(portal)/judge/page.tsx b/sites/mainweb/app/(portal)/judge/page.tsx
index 8a61fae2..43bf60c0 100644
--- a/sites/mainweb/app/(portal)/judge/page.tsx
+++ b/sites/mainweb/app/(portal)/judge/page.tsx
@@ -55,6 +55,9 @@ export default function JudgePage() {
// Gated on any approved application, not isJudge: isJudge answers for the
// current edition only, while assignments span every edition, so a judge
// approved for another one saw "Awaiting approval" forever.
+ const { data: panelDesk } = trpc.judge.panelDesk.useQuery(undefined, {
+ enabled: !!session,
+ });
const { data: assignments } = trpc.judge.getMyAssignments.useQuery(
undefined,
{
@@ -112,6 +115,11 @@ export default function JudgePage() {
Apply to judge an event. An organiser approves you before you can
score.
+ {panelDesk?.url ? (
+
+ Open the judging desk
+
+ ) : null}
{!hackathons?.length ? (
diff --git a/sites/mainweb/app/(portal)/judge/panel/[hackathonId]/desk.tsx b/sites/mainweb/app/(portal)/judge/panel/[hackathonId]/desk.tsx
new file mode 100644
index 00000000..b748cefc
--- /dev/null
+++ b/sites/mainweb/app/(portal)/judge/panel/[hackathonId]/desk.tsx
@@ -0,0 +1,506 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import type { CSSProperties } from "react";
+import { apiBase } from "@/lib/panel";
+import type { PublicEvent } from "@/lib/panel";
+import { flushHeld, holdVote } from "@/lib/panel-held";
+
+type Visit = {
+ done: boolean;
+ reused?: boolean;
+ visitId?: string;
+ projectName?: string;
+ tableNumber?: number | null;
+ arrivedAt?: string | null;
+ handedOutAt?: string;
+};
+
+type Anchor = { label?: string; value?: number };
+type CriterionRow = {
+ id: string;
+ label: string;
+ min: number;
+ max: number;
+ anchors?: Anchor[];
+};
+
+export function JudgeDesk({
+ event,
+ name,
+}: {
+ event: PublicEvent;
+ name: string;
+}) {
+ const [visit, setVisit] = useState(null);
+ const [criteria, setCriteria] = useState([]);
+ const [scores, setScores] = useState>({});
+ const [tableNumber, setTableNumber] = useState("");
+ const [message, setMessage] = useState("");
+ const [progress, setProgress] = useState("");
+ const [now, setNow] = useState(() => Date.now());
+ const [scanning, setScanning] = useState(false);
+ const videoRef = useRef(null);
+ const accent = event.branding.colors?.accent ?? "#1c1917";
+
+ async function loadProgress() {
+ const response = await fetch(
+ `${apiBase}/v1/session/progress?eventId=${event.eventId}`,
+ );
+ if (!response.ok) return;
+ const row = (await response.json()) as { completed: number; total: number };
+ setProgress(`${row.completed} of ${row.total} scored`);
+ }
+
+ useEffect(() => {
+ void fetch(`${apiBase}/v1/session/rubric?eventId=${event.eventId}`)
+ .then((response) => response.json())
+ .then(async (rows: CriterionRow[]) => {
+ if (!Array.isArray(rows)) {
+ setMessage("You are not an approved judge for this event yet.");
+ return;
+ }
+ setCriteria(rows);
+ await loadProgress();
+ })
+ .catch(() => setMessage("Could not load the rubric."));
+ // loadProgress only reads event.eventId.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [event.eventId]);
+
+ async function call(path: string, body?: unknown) {
+ const response = await fetch(`${apiBase}${path}`, {
+ method: body ? "POST" : "GET",
+ headers: { "content-type": "application/json" },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const payload = (await response.json().catch(() => ({}))) as {
+ message?: string;
+ };
+ if (!response.ok) {
+ throw Object.assign(new Error(payload.message ?? "Request failed"), {
+ status: response.status,
+ });
+ }
+ return payload;
+ }
+
+ async function compare(outcome: "a" | "b" | "tie") {
+ try {
+ await call("/v1/session/compare", { eventId: event.eventId, outcome });
+ setMessage(
+ outcome === "b"
+ ? "This table is better."
+ : outcome === "a"
+ ? "The previous table was better."
+ : "Recorded as about the same.",
+ );
+ } catch (error) {
+ setMessage(error instanceof Error ? error.message : "Could not compare");
+ }
+ }
+
+ useEffect(() => {
+ const send = () => {
+ void flushHeld((path, payload) =>
+ fetch(`${apiBase}${path}`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(payload),
+ }).then((response) => {
+ // A refusal (past the hard limit, a voided visit) is final; only an
+ // outage keeps the score on the phone for another try.
+ if (response.status >= 500) throw new Error("still offline");
+ }),
+ )
+ .then((sent) => {
+ if (sent > 0) {
+ setMessage(
+ `Sent ${sent} score${sent === 1 ? "" : "s"} held on this phone.`,
+ );
+ }
+ })
+ .catch(() => undefined);
+ };
+ send();
+ window.addEventListener("online", send);
+ return () => window.removeEventListener("online", send);
+ }, []);
+
+ const openVisitId = visit && !visit.done ? visit.visitId : undefined;
+ const acknowledgedRecall = useRef(null);
+ useEffect(() => {
+ // Recall and void are the only things an organizer sends a judge. Ask only
+ // while a visit is open and the screen is on: judging is live then, so the
+ // database is awake anyway, and a phone in a pocket costs nothing.
+ if (!openVisitId) return;
+ let stop = false;
+ let timer: ReturnType | undefined;
+ const check = async () => {
+ const response = await fetch(
+ `${apiBase}/v1/session/status?eventId=${event.eventId}&visitId=${openVisitId}`,
+ );
+ if (!response.ok || stop) return;
+ const status = (await response.json()) as {
+ voided: boolean;
+ recalledAt: string | null;
+ };
+ if (status.voided) {
+ setVisit(null);
+ setMessage("This visit was voided.");
+ return;
+ }
+ if (
+ status.recalledAt &&
+ status.recalledAt !== acknowledgedRecall.current
+ ) {
+ acknowledgedRecall.current = status.recalledAt;
+ setVisit(null);
+ setMessage("Come back to the desk.");
+ }
+ };
+ const sync = () => {
+ clearInterval(timer);
+ if (document.visibilityState !== "visible") return;
+ void check();
+ timer = setInterval(() => void check(), 15_000);
+ };
+ sync();
+ document.addEventListener("visibilitychange", sync);
+ return () => {
+ stop = true;
+ clearInterval(timer);
+ document.removeEventListener("visibilitychange", sync);
+ };
+ }, [openVisitId, event.eventId]);
+
+ const walkingSince =
+ visit?.handedOutAt && !visit.arrivedAt && !visit.done
+ ? new Date(visit.handedOutAt).getTime()
+ : null;
+ useEffect(() => {
+ if (walkingSince === null) return;
+ const timer = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(timer);
+ }, [walkingSince]);
+ const walkedSeconds =
+ walkingSince === null
+ ? 0
+ : Math.max(0, Math.floor((now - walkingSince) / 1000));
+ const elapsed =
+ walkingSince === null
+ ? ""
+ : `${Math.floor(walkedSeconds / 60)}:${String(walkedSeconds % 60).padStart(2, "0")} walking`;
+
+ return (
+
+
+ {event.orgName}
+
+ {event.name}
+ {event.branding.tagline}
+ Phase: {event.phase}
+
+
+
+
+ {progress ? {progress}
: null}
+ {visit && !visit.done ? (
+ <>
+
+ {visit.projectName} · table {visit.tableNumber}
+
+ {elapsed ? {elapsed}
: null}
+
+
+
+
+ {visit.arrivedAt ? (
+ <>
+ Which table is stronger?
+
+
+
+ >
+ ) : null}
+ {criteria.map((item) => (
+
+ ))}
+
+
+ >
+ ) : null}
+
+ {message ? {message}
: null}
+
+ );
+}
+
+function readValue(event: { target: EventTarget | null }) {
+ const target = event.target;
+ if (target && "value" in target) return String(target.value);
+ return "";
+}
+
+const field: CSSProperties = {
+ display: "block",
+ width: "100%",
+ margin: "0.25rem 0 0.75rem",
+ padding: "0.75rem",
+ fontSize: "1.1rem",
+};
+
+const button: CSSProperties = {
+ display: "block",
+ width: "100%",
+ margin: "0.5rem 0",
+ padding: "0.9rem",
+ color: "white",
+ background: "#1c1917",
+ border: 0,
+ fontSize: "1rem",
+};
diff --git a/sites/mainweb/app/(portal)/judge/panel/[hackathonId]/page.tsx b/sites/mainweb/app/(portal)/judge/panel/[hackathonId]/page.tsx
new file mode 100644
index 00000000..7ccd0db1
--- /dev/null
+++ b/sites/mainweb/app/(portal)/judge/panel/[hackathonId]/page.tsx
@@ -0,0 +1,24 @@
+import { redirect } from "next/navigation";
+import { portalActor } from "@query/api/panel";
+import { JudgeDesk } from "./desk";
+import { loadEvent } from "@/lib/panel.server";
+
+export default async function JudgePage({
+ params,
+}: {
+ params: Promise<{ hackathonId: string }>;
+}) {
+ const { hackathonId } = await params;
+ const actor = await portalActor();
+ if (!actor) {
+ redirect(
+ `/login?callbackUrl=${encodeURIComponent(`/judge/panel/${hackathonId}`)}`,
+ );
+ }
+ const event = await loadEvent(hackathonId);
+ if (!event)
+ return (
+ This event is not available.
+ );
+ return ;
+}
diff --git a/sites/mainweb/app/judging/[hackathonId]/board.tsx b/sites/mainweb/app/judging/[hackathonId]/board.tsx
new file mode 100644
index 00000000..c94909b5
--- /dev/null
+++ b/sites/mainweb/app/judging/[hackathonId]/board.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { apiBase } from "@/lib/panel";
+
+type Snapshot = {
+ eventId: string;
+ phase: string;
+ pollSeconds: number;
+ projects: { id: string; tableNumber: number | null; looks: number }[];
+ placements: {
+ projectId: string;
+ projectName?: string;
+ placement: number | null;
+ }[];
+ prizes: {
+ title: string;
+ place: number;
+ track: string;
+ amount: number | null;
+ }[];
+};
+
+export function LiveBoard({
+ orgSlug,
+ eventSlug,
+}: {
+ orgSlug: string;
+ eventSlug: string;
+}) {
+ const [snapshot, setSnapshot] = useState(null);
+
+ const [reload, setReload] = useState(0);
+
+ useEffect(() => {
+ let stop = false;
+ let timer: ReturnType | undefined;
+
+ const pull = async () => {
+ clearTimeout(timer);
+ const response = await fetch(
+ `${apiBase}/v1/live/${orgSlug}/${eventSlug}`,
+ );
+ if (!response.ok || stop) return;
+ const next = (await response.json()) as Snapshot;
+ setSnapshot(next);
+ // Only live judging changes minute to minute. Polling any other phase,
+ // or a tab nobody is looking at, keeps the database awake for nothing:
+ // Neon suspends only after five idle minutes, and those minutes are the
+ // monthly compute allowance.
+ if (
+ next.phase === "judging_live" &&
+ document.visibilityState === "visible"
+ ) {
+ timer = setTimeout(
+ () => void pull(),
+ Math.max(next.pollSeconds || 5, 5) * 1000,
+ );
+ }
+ };
+ const onVisible = () => {
+ if (document.visibilityState === "visible") void pull();
+ };
+
+ void pull();
+ document.addEventListener("visibilitychange", onVisible);
+ return () => {
+ stop = true;
+ clearTimeout(timer);
+ document.removeEventListener("visibilitychange", onVisible);
+ };
+ }, [orgSlug, eventSlug, reload]);
+
+ if (!snapshot) return Loading the floor.
;
+ return (
+
+
+ {snapshot.phase}
+ {snapshot.phase === "judging_live" ? null : (
+ <>
+ {" · "}
+
+ >
+ )}
+
+ {(snapshot.prizes ?? []).length > 0 ? (
+ <>
+ Prizes
+
+ {(snapshot.prizes ?? []).map((row) => (
+ -
+ {row.track}: {row.place}. {row.title}
+ {row.amount !== null ? ` (${row.amount})` : ""}
+
+ ))}
+
+ >
+ ) : null}
+ {snapshot.phase === "published" ? (
+
+ {snapshot.placements.map((row) => (
+ -
+ {row.placement}. {row.projectName || row.projectId}
+
+ ))}
+
+ ) : (
+
+ {snapshot.projects.map((row) => (
+ -
+ Table {row.tableNumber}: {row.looks} looks
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/sites/mainweb/app/judging/[hackathonId]/feedback/[token]/page.tsx b/sites/mainweb/app/judging/[hackathonId]/feedback/[token]/page.tsx
new file mode 100644
index 00000000..59aae1bf
--- /dev/null
+++ b/sites/mainweb/app/judging/[hackathonId]/feedback/[token]/page.tsx
@@ -0,0 +1,57 @@
+import { panelRequest } from "@/lib/panel.server";
+
+export default async function FeedbackPage({
+ params,
+}: {
+ params: Promise<{ token: string }>;
+}) {
+ const { token } = await params;
+ const response = await panelRequest(
+ `/v1/feedback/${encodeURIComponent(token)}`,
+ );
+ if (response.status === 403) {
+ return (
+
+ Feedback is available after results are published, and only with this
+ project's link.
+
+ );
+ }
+ if (!response.ok) {
+ return (
+
+ This feedback link is not available.
+
+ );
+ }
+ const card = (await response.json()) as {
+ name: string;
+ criteria: { label: string; mean: number; median: number }[];
+ places: { track: string; placement: number | null }[];
+ comments: string[];
+ };
+ return (
+
+ {card.name}
+
+ {card.places.map((item) => (
+ -
+ {item.track}:{" "}
+ {item.placement === null ? "not placed" : item.placement}
+
+ ))}
+
+
+ {card.criteria.map((item) => (
+ -
+ {item.label}: {item.mean.toFixed(1)} versus a median of{" "}
+ {item.median.toFixed(1)}
+
+ ))}
+
+ {card.comments.map((comment, index) => (
+ {comment}
+ ))}
+
+ );
+}
diff --git a/sites/mainweb/app/judging/[hackathonId]/page.tsx b/sites/mainweb/app/judging/[hackathonId]/page.tsx
new file mode 100644
index 00000000..7703f5fb
--- /dev/null
+++ b/sites/mainweb/app/judging/[hackathonId]/page.tsx
@@ -0,0 +1,27 @@
+import { LiveBoard } from "./board";
+import { PANEL_ORG, loadEvent } from "@/lib/panel.server";
+
+export default async function PublicBoard({
+ params,
+}: {
+ params: Promise<{ hackathonId: string }>;
+}) {
+ const { hackathonId } = await params;
+ const event = await loadEvent(hackathonId);
+ if (!event)
+ return (
+ This event is not available.
+ );
+ return (
+
+ {event.orgName}
+ {event.name}
+
+ {event.phase === "published"
+ ? "Results are published."
+ : "Judging is in progress."}
+
+
+
+ );
+}
diff --git a/sites/mainweb/lib/panel-held.ts b/sites/mainweb/lib/panel-held.ts
new file mode 100644
index 00000000..40c3811b
--- /dev/null
+++ b/sites/mainweb/lib/panel-held.ts
@@ -0,0 +1,59 @@
+type HeldVote = {
+ id: string;
+ path: string;
+ body: unknown;
+};
+
+const DB_NAME = "panel";
+const STORE = "votes";
+
+function openDb(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, 1);
+ request.onupgradeneeded = () => {
+ request.result.createObjectStore(STORE, { keyPath: "id" });
+ };
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ });
+}
+
+export async function holdVote(body: { visitId?: string; eventId: string }) {
+ const db = await openDb();
+ await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, "readwrite");
+ tx.objectStore(STORE).put({
+ id: body.visitId ?? crypto.randomUUID(),
+ path: "/v1/session/vote",
+ body,
+ } satisfies HeldVote);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ db.close();
+}
+
+export async function flushHeld(
+ send: (path: string, body: unknown) => Promise,
+): Promise {
+ const db = await openDb();
+ const rows = await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, "readonly");
+ const request = tx.objectStore(STORE).getAll();
+ request.onsuccess = () => resolve(request.result as HeldVote[]);
+ request.onerror = () => reject(request.error);
+ });
+ let sent = 0;
+ for (const row of rows) {
+ await send(row.path, row.body);
+ await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, "readwrite");
+ tx.objectStore(STORE).delete(row.id);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ sent += 1;
+ }
+ db.close();
+ return sent;
+}
diff --git a/sites/mainweb/lib/panel.server.ts b/sites/mainweb/lib/panel.server.ts
new file mode 100644
index 00000000..257d221a
--- /dev/null
+++ b/sites/mainweb/lib/panel.server.ts
@@ -0,0 +1,20 @@
+import { PANEL_ORG, panel } from "@query/api/panel";
+import type { PublicEvent } from "./panel";
+
+/** Calls the judging API in process, the same routes the browser reaches at /api/panel. */
+export async function panelRequest(path: string): Promise {
+ return panel().app.request(path);
+}
+
+/** The judging event for a hackathon edition, or null before it has one. */
+export async function loadEvent(
+ hackathonId: string,
+): Promise {
+ const response = await panelRequest(
+ `/v1/public/${PANEL_ORG.slug}/${encodeURIComponent(hackathonId)}`,
+ );
+ if (!response.ok) return null;
+ return response.json() as Promise;
+}
+
+export { PANEL_ORG };
diff --git a/sites/mainweb/lib/panel.ts b/sites/mainweb/lib/panel.ts
new file mode 100644
index 00000000..79095225
--- /dev/null
+++ b/sites/mainweb/lib/panel.ts
@@ -0,0 +1,10 @@
+/** Judging runs inside this app; its API is mounted here. */
+export const apiBase = "/api/panel";
+
+export type PublicEvent = {
+ eventId: string;
+ name: string;
+ phase: string;
+ branding: { tagline?: string; colors?: { accent?: string } };
+ orgName: string;
+};
diff --git a/sites/mainweb/next.config.mjs b/sites/mainweb/next.config.mjs
index c329b835..7dd94ff7 100644
--- a/sites/mainweb/next.config.mjs
+++ b/sites/mainweb/next.config.mjs
@@ -58,7 +58,15 @@ const cspHeaderName =
const nextConfig = {
output: "standalone",
reactCompiler: true,
- transpilePackages: ["@query/api", "@query/auth", "@query/db", "@query/ui"],
+ transpilePackages: [
+ "@query/api",
+ "@query/auth",
+ "@query/db",
+ "@query/judging-core",
+ "@query/judging-db",
+ "@query/judging-server",
+ "@query/ui",
+ ],
outputFileTracingRoot: path.join(__dirname, "../../"),
// Native/dynamic requires that a bundler mangles. Left external so the
// standalone output loads them from node_modules at runtime.
diff --git a/turbo.json b/turbo.json
index 9a01a7cf..51ec52db 100644
--- a/turbo.json
+++ b/turbo.json
@@ -94,6 +94,11 @@
"NODE_ENV",
"CI",
"npm_lifecycle_event",
- "JUDGING_TEST_DATABASE_URL"
+ "JUDGING_TEST_DATABASE_URL",
+ "PANEL_DATABASE_URL",
+ "PANEL_JWT_SECRET",
+ "PANEL_SMTP_URL",
+ "PANEL_SMTP_FROM",
+ "REDIS_URL"
]
}