From aaad2da573abe51dbbbba54e0462b4d409fc863a Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Wed, 7 Oct 2026 18:17:20 -0400
Subject: [PATCH 1/8] feat(panel): start a standalone judging core
Ranking and dispatch have to be extractable later, so they live under judging/ as pure functions with no host imports.
Co-authored-by: Cursor
---
PLAN.md | 454 ++++++++++++++++++
judging/LICENSE | 21 +
judging/README.md | 32 ++
judging/core/eslint.config.mjs | 45 ++
judging/core/package.json | 22 +
judging/core/src/aggregate/bayes.test.ts | 27 ++
judging/core/src/aggregate/bayes.ts | 19 +
judging/core/src/aggregate/blend.test.ts | 18 +
judging/core/src/aggregate/blend.ts | 11 +
.../core/src/aggregate/bradleyTerry.test.ts | 50 ++
judging/core/src/aggregate/bradleyTerry.ts | 72 +++
judging/core/src/aggregate/rank.test.ts | 204 ++++++++
judging/core/src/aggregate/rank.ts | 304 ++++++++++++
judging/core/src/aggregate/zscore.test.ts | 79 +++
judging/core/src/aggregate/zscore.ts | 22 +
judging/core/src/comparison.ts | 5 +
judging/core/src/dispatch.test.ts | 139 ++++++
judging/core/src/dispatch.ts | 128 +++++
judging/core/src/distance.test.ts | 31 ++
judging/core/src/distance.ts | 30 ++
judging/core/src/events.test.ts | 34 ++
judging/core/src/events.ts | 55 +++
judging/core/src/index.ts | 54 +++
judging/core/src/match.test.ts | 21 +
judging/core/src/match.ts | 20 +
judging/core/src/package.test.ts | 14 +
judging/core/src/phase.test.ts | 59 +++
judging/core/src/phase.ts | 63 +++
judging/core/src/rubric.test.ts | 67 +++
judging/core/src/rubric.ts | 85 ++++
judging/core/src/timers.test.ts | 88 ++++
judging/core/src/timers.ts | 74 +++
judging/core/src/uncertainty.test.ts | 66 +++
judging/core/src/uncertainty.ts | 66 +++
judging/core/tsconfig.json | 10 +
judging/core/vitest.config.ts | 7 +
judging/docs/adr/001-standalone.md | 22 +
judging/docs/adr/002-pure-core.md | 22 +
package.json | 2 +-
pnpm-lock.yaml | 18 +
pnpm-workspace.yaml | 1 +
41 files changed, 2560 insertions(+), 1 deletion(-)
create mode 100644 PLAN.md
create mode 100644 judging/LICENSE
create mode 100644 judging/README.md
create mode 100644 judging/core/eslint.config.mjs
create mode 100644 judging/core/package.json
create mode 100644 judging/core/src/aggregate/bayes.test.ts
create mode 100644 judging/core/src/aggregate/bayes.ts
create mode 100644 judging/core/src/aggregate/blend.test.ts
create mode 100644 judging/core/src/aggregate/blend.ts
create mode 100644 judging/core/src/aggregate/bradleyTerry.test.ts
create mode 100644 judging/core/src/aggregate/bradleyTerry.ts
create mode 100644 judging/core/src/aggregate/rank.test.ts
create mode 100644 judging/core/src/aggregate/rank.ts
create mode 100644 judging/core/src/aggregate/zscore.test.ts
create mode 100644 judging/core/src/aggregate/zscore.ts
create mode 100644 judging/core/src/comparison.ts
create mode 100644 judging/core/src/dispatch.test.ts
create mode 100644 judging/core/src/dispatch.ts
create mode 100644 judging/core/src/distance.test.ts
create mode 100644 judging/core/src/distance.ts
create mode 100644 judging/core/src/events.test.ts
create mode 100644 judging/core/src/events.ts
create mode 100644 judging/core/src/index.ts
create mode 100644 judging/core/src/match.test.ts
create mode 100644 judging/core/src/match.ts
create mode 100644 judging/core/src/package.test.ts
create mode 100644 judging/core/src/phase.test.ts
create mode 100644 judging/core/src/phase.ts
create mode 100644 judging/core/src/rubric.test.ts
create mode 100644 judging/core/src/rubric.ts
create mode 100644 judging/core/src/timers.test.ts
create mode 100644 judging/core/src/timers.ts
create mode 100644 judging/core/src/uncertainty.test.ts
create mode 100644 judging/core/src/uncertainty.ts
create mode 100644 judging/core/tsconfig.json
create mode 100644 judging/core/vitest.config.ts
create mode 100644 judging/docs/adr/001-standalone.md
create mode 100644 judging/docs/adr/002-pure-core.md
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 00000000..00cca049
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,454 @@
+# Judging platform plan
+
+**Repo:** `query` · **Drafted:** 2026-10-07 · **Working name:** `panel` (rename freely)
+
+A standalone hackathon judging platform, built inside this monorepo under
+`judging/`, with no knowledge of DSGT in it. Hacklytics is its first user
+through an adapter. When it is stable it leaves this repo with
+`git subtree split --prefix=judging` and ships as an open-source project with
+its own licence, docs, CI and Docker image.
+
+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
+
+```
+judging/
+ core/ @panel/core pure TS, zero runtime deps. rubric validation,
+ dispatch, distance, uncertainty, aggregators,
+ phase state machine, timers
+ db/ @panel/db Drizzle schema, SQL migrations (generated,
+ committed), seed, demo data
+ server/ @panel/server Node service: tRPC over HTTP, OpenAPI mirror,
+ WebSocket hub, auth adapters, outbox + webhooks,
+ Prometheus metrics
+ web/ @panel/web Next.js: judge PWA, admin console, live board,
+ public leaderboard, team feedback
+ cli/ @panel/cli import/export/seed/migrate/doctor
+ infra/
+ docker/ Dockerfiles, docker-compose.yml (full stack)
+ gcp/ optional Terraform module (Cloud Run, Memorystore,
+ Secret Manager, Monitoring)
+ docs/ architecture, ADRs, deploy, API, rubric guide
+ README.md LICENSE (MIT) CONTRIBUTING.md CHANGELOG.md
+```
+
+Registered as workspaces in `pnpm-workspace.yaml`. Internal imports are
+`@panel/*` only; nothing under `judging/` imports from `packages/*` or
+`sites/*`. A lint rule (`no-restricted-imports`) enforces the boundary, which is
+what makes the later extraction a one-command job.
+
+The reverse direction, `packages/api` importing `@panel/*`, is allowed and is
+how the club site integrates (§7).
+
+---
+
+## 2. Domain model (`@panel/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 `judging/db/migrations`,
+ applied with `panel migrate`. No `drizzle-kit push` in this product; a
+ self-hoster needs reproducible, reviewable migrations.
+
+---
+
+## 3. The core (`@panel/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 (`@panel/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 (`@panel/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 (`@panel/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 `@panel/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.
+- `sites/mainweb` `/judge` becomes a trusted-JWT handoff to the judge PWA.
+ Admin "Judging" tab deep-links into the console with the same 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: `@panel/server` and `@panel/web` as two Cloud Run services in the
+ same GCP project, Postgres as a second Neon database (not a schema in the
+ club DB, so the extraction is clean), Memorystore Redis only if more than
+ one server instance is wanted. Terraform under `judging/infra/gcp`.
+
+---
+
+## 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 `@panel/core` as parametrised
+ functions. Bring `dispatch.test.ts` cases with them.
+- Add `distance`, `phase`, `rubric`, `bradleyTerry`, `blend`, `uncertainty`
+ with tests.
+
+**Verify:** `@panel/core` has no dependencies and 100% of exported functions
+have a test. The legacy `rankings.ts` output on the seed equals
+`@panel/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 — Open-source release (1 week)
+
+- `CONTRIBUTING.md`, issue templates, GitHub Actions (test, lint, typecheck,
+ build, Docker publish to GHCR, `@panel/core` to npm on tag).
+- Docs: quick start (`docker compose up`, `panel seed demo`), deploy guide,
+ API reference from OpenAPI, rubric design guide, scoring explainer.
+- `git subtree split --prefix=judging -b panel-main`, push to the new repo,
+ replace `judging/` here with a git submodule or a pinned npm dependency.
+- Delete `routers/judge/*` and the legacy judging UI from the club site.
+
+**Verify:** a fresh clone of the new repo runs the demo in under five
+minutes with nothing but Docker. No string "DSGT", "Hacklytics" or
+"datasciencegt" anywhere under `judging/`.
+
+---
+
+## 9. Decisions taken and open
+
+**Taken**
+
+- Standalone product in `judging/`, extracted later by subtree. Not a schema
+ inside the club database, so the extraction has no data entanglement.
+- 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/judging/LICENSE b/judging/LICENSE
new file mode 100644
index 00000000..354a5c40
--- /dev/null
+++ b/judging/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Panel contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/judging/README.md b/judging/README.md
new file mode 100644
index 00000000..5b99dcec
--- /dev/null
+++ b/judging/README.md
@@ -0,0 +1,32 @@
+# Panel
+
+A hackathon judging product: its own data, its own API, its own web app.
+Rubrics, tracks, timers, rooms, and dispatch strategy are rows, not code.
+Postgres and Node are enough to run it. Redis is optional and never required
+for a correct result.
+
+`judging/` does not import the host application. The host may call `@panel/*`.
+When the product is stable it leaves this repository with
+`git subtree split --prefix=judging`.
+
+## Layout
+
+| Path | Package | What it is |
+| --- | --- | --- |
+| `core/` | `@panel/core` | Pure functions. No I/O, no clock, no runtime dependencies. |
+| `db/` | `@panel/db` | Schema and committed SQL migrations. Not built yet. |
+| `server/` | `@panel/server` | HTTP API. Not built yet. |
+| `web/` | `@panel/web` | Judge, admin, and public views. Not built yet. |
+| `cli/` | `@panel/cli` | Migrate, seed, import, export. Not built yet. |
+
+## Run the core
+
+From the repository root:
+
+```
+pnpm --filter @panel/core test
+pnpm --filter @panel/core typecheck
+pnpm --filter @panel/core lint
+```
+
+`pnpm test` includes `@panel/core`.
diff --git a/judging/core/eslint.config.mjs b/judging/core/eslint.config.mjs
new file mode 100644
index 00000000..a7d0fb28
--- /dev/null
+++ b/judging/core/eslint.config.mjs
@@ -0,0 +1,45 @@
+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:
+ "@panel/core takes its inputs as arguments. It does not read the environment.",
+ },
+ ],
+ patterns: [
+ {
+ group: ["@query", "@query/*"],
+ message:
+ "judging/ is a standalone product. Import from @panel/* only.",
+ },
+ {
+ group: [
+ "node:*",
+ "fs",
+ "path",
+ "crypto",
+ "node:fs",
+ "node:path",
+ "node:crypto",
+ ],
+ message:
+ "@panel/core has no runtime dependencies and does not call Node APIs.",
+ },
+ ],
+ },
+ ],
+ },
+ },
+];
diff --git a/judging/core/package.json b/judging/core/package.json
new file mode 100644
index 00000000..fd17a170
--- /dev/null
+++ b/judging/core/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@panel/core",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "description": "Pure judging decisions: dispatch, rubric, phase, and ranking.",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "lint": "eslint . --max-warnings 0",
+ "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/judging/core/src/aggregate/bayes.test.ts b/judging/core/src/aggregate/bayes.test.ts
new file mode 100644
index 00000000..df67d118
--- /dev/null
+++ b/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/judging/core/src/aggregate/bayes.ts b/judging/core/src/aggregate/bayes.ts
new file mode 100644
index 00000000..15f764fe
--- /dev/null
+++ b/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/judging/core/src/aggregate/blend.test.ts b/judging/core/src/aggregate/blend.test.ts
new file mode 100644
index 00000000..d40cf033
--- /dev/null
+++ b/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/judging/core/src/aggregate/blend.ts b/judging/core/src/aggregate/blend.ts
new file mode 100644
index 00000000..9b3bba95
--- /dev/null
+++ b/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/judging/core/src/aggregate/bradleyTerry.test.ts b/judging/core/src/aggregate/bradleyTerry.test.ts
new file mode 100644
index 00000000..3a54f9b6
--- /dev/null
+++ b/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/judging/core/src/aggregate/bradleyTerry.ts b/judging/core/src/aggregate/bradleyTerry.ts
new file mode 100644
index 00000000..71132a65
--- /dev/null
+++ b/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/judging/core/src/aggregate/rank.test.ts b/judging/core/src/aggregate/rank.test.ts
new file mode 100644
index 00000000..ce684acd
--- /dev/null
+++ b/judging/core/src/aggregate/rank.test.ts
@@ -0,0 +1,204 @@
+import { describe, expect, it } from "vitest";
+import { zNormalize } from "./zscore";
+import { bayesianShrink, round2 } from "./bayes";
+import { rank } from "./rank";
+import type { ProjectInput, 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"]);
+ });
+});
diff --git a/judging/core/src/aggregate/rank.ts b/judging/core/src/aggregate/rank.ts
new file mode 100644
index 00000000..0b687d32
--- /dev/null
+++ b/judging/core/src/aggregate/rank.ts
@@ -0,0 +1,304 @@
+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;
+ 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/judging/core/src/aggregate/zscore.test.ts b/judging/core/src/aggregate/zscore.test.ts
new file mode 100644
index 00000000..29722da2
--- /dev/null
+++ b/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/judging/core/src/aggregate/zscore.ts b/judging/core/src/aggregate/zscore.ts
new file mode 100644
index 00000000..e6c96bb4
--- /dev/null
+++ b/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/judging/core/src/comparison.ts b/judging/core/src/comparison.ts
new file mode 100644
index 00000000..7937600b
--- /dev/null
+++ b/judging/core/src/comparison.ts
@@ -0,0 +1,5 @@
+export type Comparison = {
+ a: string;
+ b: string;
+ outcome: "a" | "b" | "tie";
+};
diff --git a/judging/core/src/dispatch.test.ts b/judging/core/src/dispatch.test.ts
new file mode 100644
index 00000000..9c2f0ec5
--- /dev/null
+++ b/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/judging/core/src/dispatch.ts b/judging/core/src/dispatch.ts
new file mode 100644
index 00000000..aa453f98
--- /dev/null
+++ b/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/judging/core/src/distance.test.ts b/judging/core/src/distance.test.ts
new file mode 100644
index 00000000..3bd7ee03
--- /dev/null
+++ b/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/judging/core/src/distance.ts b/judging/core/src/distance.ts
new file mode 100644
index 00000000..6601a79c
--- /dev/null
+++ b/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/judging/core/src/events.test.ts b/judging/core/src/events.test.ts
new file mode 100644
index 00000000..72a63800
--- /dev/null
+++ b/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/judging/core/src/events.ts b/judging/core/src/events.ts
new file mode 100644
index 00000000..6e9278ed
--- /dev/null
+++ b/judging/core/src/events.ts
@@ -0,0 +1,55 @@
+/**
+ * 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",
+ "judge.upserted",
+ "judge.approved",
+ "judge.suspended",
+ "visit.handed_out",
+ "visit.arrived",
+ "visit.voided",
+ "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",
+ "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/judging/core/src/index.ts b/judging/core/src/index.ts
new file mode 100644
index 00000000..6acfb348
--- /dev/null
+++ b/judging/core/src/index.ts
@@ -0,0 +1,54 @@
+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 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/judging/core/src/match.test.ts b/judging/core/src/match.test.ts
new file mode 100644
index 00000000..9127ca98
--- /dev/null
+++ b/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/judging/core/src/match.ts b/judging/core/src/match.ts
new file mode 100644
index 00000000..e2abcea4
--- /dev/null
+++ b/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/judging/core/src/package.test.ts b/judging/core/src/package.test.ts
new file mode 100644
index 00000000..678fffae
--- /dev/null
+++ b/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("@panel/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/judging/core/src/phase.test.ts b/judging/core/src/phase.test.ts
new file mode 100644
index 00000000..a4f885ba
--- /dev/null
+++ b/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/judging/core/src/phase.ts b/judging/core/src/phase.ts
new file mode 100644
index 00000000..7a947a65
--- /dev/null
+++ b/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/judging/core/src/rubric.test.ts b/judging/core/src/rubric.test.ts
new file mode 100644
index 00000000..589db6f1
--- /dev/null
+++ b/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/judging/core/src/rubric.ts b/judging/core/src/rubric.ts
new file mode 100644
index 00000000..14292b8d
--- /dev/null
+++ b/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/judging/core/src/timers.test.ts b/judging/core/src/timers.test.ts
new file mode 100644
index 00000000..24c3a19d
--- /dev/null
+++ b/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/judging/core/src/timers.ts b/judging/core/src/timers.ts
new file mode 100644
index 00000000..d14bdd5b
--- /dev/null
+++ b/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/judging/core/src/uncertainty.test.ts b/judging/core/src/uncertainty.test.ts
new file mode 100644
index 00000000..d97fc34e
--- /dev/null
+++ b/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/judging/core/src/uncertainty.ts b/judging/core/src/uncertainty.ts
new file mode 100644
index 00000000..07a5b4b2
--- /dev/null
+++ b/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/judging/core/tsconfig.json b/judging/core/tsconfig.json
new file mode 100644
index 00000000..f54bde2c
--- /dev/null
+++ b/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/judging/core/vitest.config.ts b/judging/core/vitest.config.ts
new file mode 100644
index 00000000..ae847ff6
--- /dev/null
+++ b/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/judging/docs/adr/001-standalone.md b/judging/docs/adr/001-standalone.md
new file mode 100644
index 00000000..6c417fb3
--- /dev/null
+++ b/judging/docs/adr/001-standalone.md
@@ -0,0 +1,22 @@
+# ADR-001: Standalone product
+
+## Status
+
+Accepted
+
+## Decision
+
+Panel lives under `judging/` with its own packages, its own Postgres database,
+and its own deploy. It does not share a schema or a module graph with the
+application that hosts the first event.
+
+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.
+A separate database means the extraction has no data to untangle.
diff --git a/judging/docs/adr/002-pure-core.md b/judging/docs/adr/002-pure-core.md
new file mode 100644
index 00000000..1421615c
--- /dev/null
+++ b/judging/docs/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 `@panel/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/package.json b/package.json
index 6dcbb690..f3ddfc8d 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 judging/core"
},
"dependencies": {
"next": "16.3.7",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8907bf4d..056a6a71 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -41,6 +41,24 @@ importers:
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))
+ 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/api:
dependencies:
'@query/auth':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 4da7f960..c9f5f1e7 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -7,3 +7,4 @@ packages:
- packages/auth
- packages/db
- packages/ui
+ - judging/*
From 5ee687360325f32e0461ec45fc1d1f10efe7db0d Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Thu, 8 Oct 2026 13:14:42 -0400
Subject: [PATCH 2/8] feat(panel): run judging on the shared database
One API and the club Postgres, with the judge desk, organizer console, and a handoff from the club site when an edition opts in.
Co-authored-by: Cursor
---
.github/workflows/panel.yml | 75 ++
docs/architecture.md | 2 +-
judging/.github/ISSUE_TEMPLATE/bug.md | 11 +
judging/.github/ISSUE_TEMPLATE/feature.md | 9 +
judging/CHANGELOG.md | 8 +
judging/CONTRIBUTING.md | 28 +
judging/README.md | 38 +-
judging/cli/eslint.config.mjs | 27 +
judging/cli/package.json | 24 +
judging/cli/src/csv.test.ts | 41 ++
judging/cli/src/csv.ts | 127 ++++
judging/cli/src/main.ts | 153 ++++
judging/cli/src/transfer.ts | 67 ++
judging/cli/tsconfig.json | 10 +
judging/core/package.json | 11 +
judging/core/scripts/emit-extensions.mjs | 25 +
judging/core/src/aggregate/rank.test.ts | 42 +-
judging/core/src/aggregate/rank.ts | 8 +
judging/core/src/dispatch.sim.test.ts | 120 ++++
judging/core/src/events.ts | 11 +
judging/core/src/index.ts | 2 +
judging/core/src/standalone.test.ts | 34 +
judging/core/src/tables.test.ts | 34 +
judging/core/src/tables.ts | 52 ++
judging/core/tsconfig.build.json | 12 +
judging/db/eslint.config.mjs | 24 +
judging/db/package.json | 25 +
judging/db/src/client.ts | 16 +
judging/db/src/env.ts | 11 +
judging/db/src/index.ts | 6 +
judging/db/src/migrate.ts | 69 ++
judging/db/src/schema.ts | 433 ++++++++++++
judging/db/src/seed.ts | 238 +++++++
judging/db/tsconfig.json | 10 +
judging/docs/adr/001-standalone.md | 10 +-
judging/docs/adr/003-one-database.md | 26 +
judging/docs/api.md | 17 +
judging/docs/deploy.md | 25 +
judging/docs/rubric.md | 7 +
judging/docs/scoring.md | 9 +
judging/infra/docker/Dockerfile | 15 +
judging/infra/docker/Dockerfile.web | 30 +
judging/infra/docker/docker-compose.yml | 30 +
judging/infra/gcp/main.tf | 129 ++++
judging/server/eslint.config.mjs | 28 +
judging/server/package.json | 38 +
judging/server/src/app.test.ts | 18 +
judging/server/src/app.ts | 391 +++++++++++
judging/server/src/auth.test.ts | 16 +
judging/server/src/auth.ts | 100 +++
judging/server/src/bus.test.ts | 51 ++
judging/server/src/bus.ts | 131 ++++
judging/server/src/cross-server.check.ts | 111 +++
judging/server/src/env.ts | 11 +
judging/server/src/hub.ts | 31 +
judging/server/src/index.ts | 73 ++
judging/server/src/log.ts | 3 +
judging/server/src/mail.ts | 12 +
judging/server/src/metrics.ts | 40 ++
judging/server/src/router.ts | 652 ++++++++++++++++++
judging/server/src/services/arrive.ts | 76 ++
judging/server/src/services/catalog.ts | 454 ++++++++++++
judging/server/src/services/compare.ts | 63 ++
.../src/services/dispatch.concurrent.test.ts | 33 +
judging/server/src/services/dispatch.ts | 413 +++++++++++
judging/server/src/services/feedback.test.ts | 18 +
judging/server/src/services/feedback.ts | 17 +
judging/server/src/services/floor.test.ts | 29 +
judging/server/src/services/floor.ts | 79 +++
.../src/services/flow.integration.test.ts | 110 +++
judging/server/src/services/live.ts | 122 ++++
judging/server/src/services/logs.test.ts | 16 +
judging/server/src/services/logs.ts | 28 +
judging/server/src/services/outbox.ts | 52 ++
judging/server/src/services/overtime.test.ts | 30 +
judging/server/src/services/overtime.ts | 49 ++
judging/server/src/services/records.ts | 358 ++++++++++
.../src/services/redis.integration.test.ts | 41 ++
judging/server/src/services/results.test.ts | 61 ++
judging/server/src/services/results.ts | 328 +++++++++
judging/server/src/services/vote.test.ts | 48 ++
judging/server/src/services/vote.ts | 175 +++++
judging/server/tsconfig.json | 10 +
judging/server/vitest.config.ts | 8 +
.../web/app/e/[orgSlug]/[eventSlug]/board.tsx | 92 +++
.../[eventSlug]/feedback/[token]/page.tsx | 48 ++
.../web/app/e/[orgSlug]/[eventSlug]/page.tsx | 20 +
.../web/app/j/[orgSlug]/[eventSlug]/desk.tsx | 474 +++++++++++++
.../web/app/j/[orgSlug]/[eventSlug]/page.tsx | 16 +
judging/web/app/layout.tsx | 30 +
.../app/o/[orgSlug]/[eventSlug]/console.tsx | 408 +++++++++++
.../app/o/[orgSlug]/[eventSlug]/editors.tsx | 338 +++++++++
.../web/app/o/[orgSlug]/[eventSlug]/page.tsx | 16 +
judging/web/app/page.tsx | 8 +
judging/web/e2e/visit.spec.ts | 61 ++
judging/web/eslint.config.mjs | 23 +
judging/web/lib/api.ts | 15 +
judging/web/lib/held.ts | 59 ++
judging/web/next.config.ts | 7 +
judging/web/package.json | 26 +
judging/web/playwright.config.ts | 9 +
judging/web/public/manifest.webmanifest | 8 +
judging/web/test-results/.last-run.json | 4 +
judging/web/tsconfig.json | 11 +
package.json | 2 +-
packages/api/src/routers/judge/admin.ts | 11 +-
packages/api/src/routers/judge/portal.ts | 20 +
packages/api/src/routers/judge/rankings.ts | 100 +--
packages/api/src/routers/judge/replay.test.ts | 55 ++
packages/api/src/routers/judge/weight.ts | 84 +++
packages/api/src/services/panel-sync.ts | 225 ++++++
packages/api/tsconfig.json | 2 +-
packages/db/src/schemas/hackathons.ts | 5 +
pnpm-lock.yaml | 286 +++++++-
.../app/(portal)/admin/judging/page.tsx | 10 +
sites/mainweb/app/(portal)/judge/page.tsx | 8 +
turbo.json | 17 +-
117 files changed, 8810 insertions(+), 113 deletions(-)
create mode 100644 .github/workflows/panel.yml
create mode 100644 judging/.github/ISSUE_TEMPLATE/bug.md
create mode 100644 judging/.github/ISSUE_TEMPLATE/feature.md
create mode 100644 judging/CHANGELOG.md
create mode 100644 judging/CONTRIBUTING.md
create mode 100644 judging/cli/eslint.config.mjs
create mode 100644 judging/cli/package.json
create mode 100644 judging/cli/src/csv.test.ts
create mode 100644 judging/cli/src/csv.ts
create mode 100644 judging/cli/src/main.ts
create mode 100644 judging/cli/src/transfer.ts
create mode 100644 judging/cli/tsconfig.json
create mode 100644 judging/core/scripts/emit-extensions.mjs
create mode 100644 judging/core/src/dispatch.sim.test.ts
create mode 100644 judging/core/src/standalone.test.ts
create mode 100644 judging/core/src/tables.test.ts
create mode 100644 judging/core/src/tables.ts
create mode 100644 judging/core/tsconfig.build.json
create mode 100644 judging/db/eslint.config.mjs
create mode 100644 judging/db/package.json
create mode 100644 judging/db/src/client.ts
create mode 100644 judging/db/src/env.ts
create mode 100644 judging/db/src/index.ts
create mode 100644 judging/db/src/migrate.ts
create mode 100644 judging/db/src/schema.ts
create mode 100644 judging/db/src/seed.ts
create mode 100644 judging/db/tsconfig.json
create mode 100644 judging/docs/adr/003-one-database.md
create mode 100644 judging/docs/api.md
create mode 100644 judging/docs/deploy.md
create mode 100644 judging/docs/rubric.md
create mode 100644 judging/docs/scoring.md
create mode 100644 judging/infra/docker/Dockerfile
create mode 100644 judging/infra/docker/Dockerfile.web
create mode 100644 judging/infra/docker/docker-compose.yml
create mode 100644 judging/infra/gcp/main.tf
create mode 100644 judging/server/eslint.config.mjs
create mode 100644 judging/server/package.json
create mode 100644 judging/server/src/app.test.ts
create mode 100644 judging/server/src/app.ts
create mode 100644 judging/server/src/auth.test.ts
create mode 100644 judging/server/src/auth.ts
create mode 100644 judging/server/src/bus.test.ts
create mode 100644 judging/server/src/bus.ts
create mode 100644 judging/server/src/cross-server.check.ts
create mode 100644 judging/server/src/env.ts
create mode 100644 judging/server/src/hub.ts
create mode 100644 judging/server/src/index.ts
create mode 100644 judging/server/src/log.ts
create mode 100644 judging/server/src/mail.ts
create mode 100644 judging/server/src/metrics.ts
create mode 100644 judging/server/src/router.ts
create mode 100644 judging/server/src/services/arrive.ts
create mode 100644 judging/server/src/services/catalog.ts
create mode 100644 judging/server/src/services/compare.ts
create mode 100644 judging/server/src/services/dispatch.concurrent.test.ts
create mode 100644 judging/server/src/services/dispatch.ts
create mode 100644 judging/server/src/services/feedback.test.ts
create mode 100644 judging/server/src/services/feedback.ts
create mode 100644 judging/server/src/services/floor.test.ts
create mode 100644 judging/server/src/services/floor.ts
create mode 100644 judging/server/src/services/flow.integration.test.ts
create mode 100644 judging/server/src/services/live.ts
create mode 100644 judging/server/src/services/logs.test.ts
create mode 100644 judging/server/src/services/logs.ts
create mode 100644 judging/server/src/services/outbox.ts
create mode 100644 judging/server/src/services/overtime.test.ts
create mode 100644 judging/server/src/services/overtime.ts
create mode 100644 judging/server/src/services/records.ts
create mode 100644 judging/server/src/services/redis.integration.test.ts
create mode 100644 judging/server/src/services/results.test.ts
create mode 100644 judging/server/src/services/results.ts
create mode 100644 judging/server/src/services/vote.test.ts
create mode 100644 judging/server/src/services/vote.ts
create mode 100644 judging/server/tsconfig.json
create mode 100644 judging/server/vitest.config.ts
create mode 100644 judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx
create mode 100644 judging/web/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx
create mode 100644 judging/web/app/e/[orgSlug]/[eventSlug]/page.tsx
create mode 100644 judging/web/app/j/[orgSlug]/[eventSlug]/desk.tsx
create mode 100644 judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx
create mode 100644 judging/web/app/layout.tsx
create mode 100644 judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx
create mode 100644 judging/web/app/o/[orgSlug]/[eventSlug]/editors.tsx
create mode 100644 judging/web/app/o/[orgSlug]/[eventSlug]/page.tsx
create mode 100644 judging/web/app/page.tsx
create mode 100644 judging/web/e2e/visit.spec.ts
create mode 100644 judging/web/eslint.config.mjs
create mode 100644 judging/web/lib/api.ts
create mode 100644 judging/web/lib/held.ts
create mode 100644 judging/web/next.config.ts
create mode 100644 judging/web/package.json
create mode 100644 judging/web/playwright.config.ts
create mode 100644 judging/web/public/manifest.webmanifest
create mode 100644 judging/web/test-results/.last-run.json
create mode 100644 judging/web/tsconfig.json
create mode 100644 packages/api/src/routers/judge/replay.test.ts
create mode 100644 packages/api/src/routers/judge/weight.ts
create mode 100644 packages/api/src/services/panel-sync.ts
diff --git a/.github/workflows/panel.yml b/.github/workflows/panel.yml
new file mode 100644
index 00000000..69d4aed6
--- /dev/null
+++ b/.github/workflows/panel.yml
@@ -0,0 +1,75 @@
+name: panel
+
+on:
+ push:
+ paths:
+ - "judging/**"
+ - ".github/workflows/panel.yml"
+ pull_request:
+ paths:
+ - "judging/**"
+ - ".github/workflows/panel.yml"
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: pnpm/action-setup@v5
+ - uses: actions/setup-node@v6
+ with:
+ node-version-file: .nvmrc
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm --filter @panel/core --filter @panel/db --filter @panel/server --filter @panel/cli --filter @panel/web lint
+ - run: pnpm --filter @panel/core --filter @panel/db --filter @panel/server --filter @panel/cli --filter @panel/web typecheck
+ - run: pnpm --filter @panel/core build
+ - run: pnpm --filter @panel/web build
+ - run: pnpm --filter @panel/core --filter @panel/server --filter @panel/cli test
+
+ publish:
+ if: startsWith(github.ref, 'refs/tags/panel-v')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@v6
+ - uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: judging/infra/docker/Dockerfile
+ push: true
+ tags: ghcr.io/${{ github.repository }}/panel-server:${{ github.ref_name }}
+ - uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: judging/infra/docker/Dockerfile.web
+ push: true
+ build-args: NEXT_PUBLIC_PANEL_URL=${{ vars.NEXT_PUBLIC_PANEL_URL || 'http://localhost:8787' }}
+ tags: ghcr.io/${{ github.repository }}/panel-web:${{ github.ref_name }}
+
+ release-core:
+ if: startsWith(github.ref, 'refs/tags/panel-v')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+ - uses: pnpm/action-setup@v5
+ - uses: actions/setup-node@v6
+ with:
+ node-version-file: .nvmrc
+ cache: pnpm
+ registry-url: https://registry.npmjs.org
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm --filter @panel/core build
+ - working-directory: judging/core
+ run: npm pkg set private=false && npm publish --access public
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
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/judging/.github/ISSUE_TEMPLATE/bug.md b/judging/.github/ISSUE_TEMPLATE/bug.md
new file mode 100644
index 00000000..6da6dc23
--- /dev/null
+++ b/judging/.github/ISSUE_TEMPLATE/bug.md
@@ -0,0 +1,11 @@
+---
+name: Bug
+description: Something in panel behaved wrongly
+labels: bug
+---
+
+## What happened
+
+## What you expected
+
+## How to reproduce
diff --git a/judging/.github/ISSUE_TEMPLATE/feature.md b/judging/.github/ISSUE_TEMPLATE/feature.md
new file mode 100644
index 00000000..41836efd
--- /dev/null
+++ b/judging/.github/ISSUE_TEMPLATE/feature.md
@@ -0,0 +1,9 @@
+---
+name: Feature
+description: A change to how panel runs an event
+labels: enhancement
+---
+
+## What should change
+
+## Who it is for
diff --git a/judging/CHANGELOG.md b/judging/CHANGELOG.md
new file mode 100644
index 00000000..2f5090c7
--- /dev/null
+++ b/judging/CHANGELOG.md
@@ -0,0 +1,8 @@
+# Panel
+
+## 0.0.0
+
+- Pure core for dispatch, rubrics, phases, and ranking.
+- Postgres schema with committed SQL migrations, a demo seed, and `panel` commands for migrate, seed, import, export, and doctor.
+- HTTP server for judge sessions, results, health, metrics, and a websocket bus.
+- Judge desk, organizer console, public board, and a feedback link.
diff --git a/judging/CONTRIBUTING.md b/judging/CONTRIBUTING.md
new file mode 100644
index 00000000..6df5c2bf
--- /dev/null
+++ b/judging/CONTRIBUTING.md
@@ -0,0 +1,28 @@
+# Contributing
+
+Panel is the tree under `judging/`. It does not import the rest of this repository.
+
+## Checks
+
+From the repository root:
+
+```
+pnpm --filter @panel/core test
+pnpm --filter @panel/server test
+pnpm --filter @panel/cli test
+pnpm --filter @panel/core lint
+pnpm --filter @panel/server lint
+pnpm --filter @panel/web lint
+pnpm --filter @panel/db typecheck
+pnpm --filter @panel/server typecheck
+pnpm --filter @panel/web typecheck
+pnpm --filter @panel/cli typecheck
+```
+
+## Migrations
+
+Schema changes are SQL files in `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/judging/README.md b/judging/README.md
index 5b99dcec..1a23e22f 100644
--- a/judging/README.md
+++ b/judging/README.md
@@ -2,8 +2,10 @@
A hackathon judging product: its own data, its own API, its own web app.
Rubrics, tracks, timers, rooms, and dispatch strategy are rows, not code.
-Postgres and Node are enough to run it. Redis is optional and never required
-for a correct result.
+Postgres and Node are enough to run it. Set `REDIS_URL` when more than one
+server process should share live updates. Without it, each process fans out
+in memory, and a client that misses a message reloads the same state from
+the next snapshot.
`judging/` does not import the host application. The host may call `@panel/*`.
When the product is stable it leaves this repository with
@@ -14,10 +16,10 @@ When the product is stable it leaves this repository with
| Path | Package | What it is |
| --- | --- | --- |
| `core/` | `@panel/core` | Pure functions. No I/O, no clock, no runtime dependencies. |
-| `db/` | `@panel/db` | Schema and committed SQL migrations. Not built yet. |
-| `server/` | `@panel/server` | HTTP API. Not built yet. |
-| `web/` | `@panel/web` | Judge, admin, and public views. Not built yet. |
-| `cli/` | `@panel/cli` | Migrate, seed, import, export. Not built yet. |
+| `db/` | `@panel/db` | Schema, committed SQL migrations, demo seed. |
+| `server/` | `@panel/server` | HTTP API: dispatch, votes, health, metrics. |
+| `web/` | `@panel/web` | Judge desk, organizer console, public board. |
+| `cli/` | `@panel/cli` | `panel migrate`, `panel seed demo`, `panel doctor`. |
## Run the core
@@ -29,4 +31,26 @@ pnpm --filter @panel/core typecheck
pnpm --filter @panel/core lint
```
-`pnpm test` includes `@panel/core`.
+`pnpm test` includes `@panel/core` and `@panel/server`.
+
+## Database and server
+
+Postgres is the one database the rest of the monorepo uses (`DATABASE_URL`). Panel migrations add its tables there. `PANEL_DATABASE_URL` is only a fallback when `DATABASE_URL` is unset.
+
+```
+docker compose -f judging/infra/docker/docker-compose.yml up --build
+```
+
+On the host, pointed at that database:
+
+```
+$env:PANEL_DATABASE_URL = "postgres://panel:panel@localhost:54329/panel"
+$env:PANEL_JWT_SECRET = "dev-only-change-me"
+pnpm --filter @panel/cli panel migrate
+pnpm --filter @panel/cli panel seed demo
+pnpm --filter @panel/cli panel doctor
+```
+
+`panel doctor` checks Postgres, pending migrations, and `PANEL_JWT_SECRET`. Redis is reported and not required.
+
+The HTTP server is Hono. tRPC is mounted at `/trpc`. `GET /healthz` is the process, `GET /readyz` is Postgres, `GET /metrics` is Prometheus. `GET /openapi.json` lists the REST routes. How to authenticate is in `docs/api.md`. Magic-link codes are returned in the response only when `PANEL_DEV_AUTH=1`, which is how the compose stack signs in without SMTP.
diff --git a/judging/cli/eslint.config.mjs b/judging/cli/eslint.config.mjs
new file mode 100644
index 00000000..a96e7a28
--- /dev/null
+++ b/judging/cli/eslint.config.mjs
@@ -0,0 +1,27 @@
+import { config } from "@query/eslint-config/base";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+ ...config,
+ {
+ files: ["src/**/*.ts"],
+ rules: { "no-console": "off" },
+ },
+ {
+ files: ["src/**/*.ts"],
+ rules: {
+ "no-restricted-imports": [
+ "error",
+ {
+ patterns: [
+ {
+ group: ["@query", "@query/*"],
+ message:
+ "judging/ is a standalone product. Import from @panel/* only.",
+ },
+ ],
+ },
+ ],
+ },
+ },
+];
diff --git a/judging/cli/package.json b/judging/cli/package.json
new file mode 100644
index 00000000..027dc16c
--- /dev/null
+++ b/judging/cli/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@panel/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": {
+ "@panel/db": "workspace:*",
+ "@panel/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/judging/cli/src/csv.test.ts b/judging/cli/src/csv.test.ts
new file mode 100644
index 00000000..b451f79c
--- /dev/null
+++ b/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/judging/cli/src/csv.ts b/judging/cli/src/csv.ts
new file mode 100644
index 00000000..3414472a
--- /dev/null
+++ b/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/judging/cli/src/main.ts b/judging/cli/src/main.ts
new file mode 100644
index 00000000..5e9b0df8
--- /dev/null
+++ b/judging/cli/src/main.ts
@@ -0,0 +1,153 @@
+import {
+ appliedMigrations,
+ createDb,
+ migrate,
+ migrationFiles,
+ databaseUrl,
+ requireDatabaseUrl,
+ seedDemo,
+} from "@panel/db";
+import { drainOutbox } from "@panel/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();
+ }
+ }
+
+ const secret = process.env.PANEL_JWT_SECRET;
+ checks.push({
+ name: "jwt",
+ ok: Boolean(secret),
+ detail: secret ? "PANEL_JWT_SECRET is set" : "PANEL_JWT_SECRET is not set",
+ });
+ 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/judging/cli/src/transfer.ts b/judging/cli/src/transfer.ts
new file mode 100644
index 00000000..8daa0c5c
--- /dev/null
+++ b/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 "@panel/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/judging/cli/tsconfig.json b/judging/cli/tsconfig.json
new file mode 100644
index 00000000..f54bde2c
--- /dev/null
+++ b/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/judging/core/package.json b/judging/core/package.json
index fd17a170..8f0f6bbf 100644
--- a/judging/core/package.json
+++ b/judging/core/package.json
@@ -7,8 +7,19 @@
"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"
},
diff --git a/judging/core/scripts/emit-extensions.mjs b/judging/core/scripts/emit-extensions.mjs
new file mode 100644
index 00000000..4efdcb3b
--- /dev/null
+++ b/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/judging/core/src/aggregate/rank.test.ts b/judging/core/src/aggregate/rank.test.ts
index ce684acd..800c28d9 100644
--- a/judging/core/src/aggregate/rank.test.ts
+++ b/judging/core/src/aggregate/rank.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { zNormalize } from "./zscore";
import { bayesianShrink, round2 } from "./bayes";
import { rank } from "./rank";
-import type { ProjectInput, RankConfig, VoteInput } from "./rank";
+import type { ProjectInput, RankComparison, RankConfig, VoteInput } from "./rank";
/**
* The ranking the current event publishes: z-score per judge against the
@@ -202,3 +202,43 @@ describe("rank", () => {
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/judging/core/src/aggregate/rank.ts b/judging/core/src/aggregate/rank.ts
index 0b687d32..6f1fa9d8 100644
--- a/judging/core/src/aggregate/rank.ts
+++ b/judging/core/src/aggregate/rank.ts
@@ -249,6 +249,14 @@ function rankTrack(
);
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) => {
diff --git a/judging/core/src/dispatch.sim.test.ts b/judging/core/src/dispatch.sim.test.ts
new file mode 100644
index 00000000..fa2f6396
--- /dev/null
+++ b/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/judging/core/src/events.ts b/judging/core/src/events.ts
index 6e9278ed..63c04d3e 100644
--- a/judging/core/src/events.ts
+++ b/judging/core/src/events.ts
@@ -8,12 +8,22 @@ export const LOG_KINDS = [
"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",
@@ -31,6 +41,7 @@ export const OUTBOX_TOPICS = [
"vote.cast",
"phase.changed",
"results.published",
+ "results.unpublished",
"judge.recalled",
] as const;
diff --git a/judging/core/src/index.ts b/judging/core/src/index.ts
index 6acfb348..a65497e1 100644
--- a/judging/core/src/index.ts
+++ b/judging/core/src/index.ts
@@ -12,6 +12,8 @@ export type {
} 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,
diff --git a/judging/core/src/standalone.test.ts b/judging/core/src/standalone.test.ts
new file mode 100644
index 00000000..166726ab
--- /dev/null
+++ b/judging/core/src/standalone.test.ts
@@ -0,0 +1,34 @@
+import { readdir, readFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
+
+describe("standalone tree", () => {
+ it("does not mention the host club", async () => {
+ const hits: string[] = [];
+ await walk(root, hits);
+ expect(hits).toEqual([]);
+ });
+});
+
+async function walk(dir: string, hits: string[]) {
+ const entries = await readdir(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (entry.name === "node_modules" || entry.name === ".next" || entry.name === "dist") {
+ continue;
+ }
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ await walk(path, hits);
+ continue;
+ }
+ if (!/\.(ts|tsx|md|sql|yml|json|tf)$/.test(entry.name)) continue;
+ const text = await readFile(path, "utf8");
+ const needles = ["DS" + "GT", "Hack" + "lytics", "datascience" + "gt"];
+ if (needles.some((needle) => text.toLowerCase().includes(needle.toLowerCase()))) {
+ hits.push(path);
+ }
+ }
+}
diff --git a/judging/core/src/tables.test.ts b/judging/core/src/tables.test.ts
new file mode 100644
index 00000000..b02827c8
--- /dev/null
+++ b/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/judging/core/src/tables.ts b/judging/core/src/tables.ts
new file mode 100644
index 00000000..af287f1d
--- /dev/null
+++ b/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/judging/core/tsconfig.build.json b/judging/core/tsconfig.build.json
new file mode 100644
index 00000000..2da00172
--- /dev/null
+++ b/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/judging/db/eslint.config.mjs b/judging/db/eslint.config.mjs
new file mode 100644
index 00000000..827bfcb7
--- /dev/null
+++ b/judging/db/eslint.config.mjs
@@ -0,0 +1,24 @@
+import { config } from "@query/eslint-config/base";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+ ...config,
+ {
+ files: ["src/**/*.ts"],
+ ignores: ["src/env.ts"],
+ rules: {
+ "no-restricted-imports": [
+ "error",
+ {
+ patterns: [
+ {
+ group: ["@query", "@query/*"],
+ message:
+ "judging/ is a standalone product. Import from @panel/* only.",
+ },
+ ],
+ },
+ ],
+ },
+ },
+];
diff --git a/judging/db/package.json b/judging/db/package.json
new file mode 100644
index 00000000..e6e07b1b
--- /dev/null
+++ b/judging/db/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@panel/db",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "lint": "eslint . --max-warnings 0",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@panel/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/judging/db/src/client.ts b/judging/db/src/client.ts
new file mode 100644
index 00000000..6824b41c
--- /dev/null
+++ b/judging/db/src/client.ts
@@ -0,0 +1,16 @@
+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 });
+ 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/judging/db/src/env.ts b/judging/db/src/env.ts
new file mode 100644
index 00000000..8966aa59
--- /dev/null
+++ b/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/judging/db/src/index.ts b/judging/db/src/index.ts
new file mode 100644
index 00000000..4514dd03
--- /dev/null
+++ b/judging/db/src/index.ts
@@ -0,0 +1,6 @@
+export { createDb } 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/judging/db/src/migrate.ts b/judging/db/src/migrate.ts
new file mode 100644
index 00000000..9a5e8c76
--- /dev/null
+++ b/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/judging/db/src/schema.ts b/judging/db/src/schema.ts
new file mode 100644
index 00000000..e206ba77
--- /dev/null
+++ b/judging/db/src/schema.ts
@@ -0,0 +1,433 @@
+import {
+ boolean,
+ integer,
+ jsonb,
+ numeric,
+ pgEnum,
+ pgTable,
+ primaryKey,
+ text,
+ timestamp,
+ uniqueIndex,
+ uuid,
+} from "drizzle-orm/pg-core";
+import { sql } from "drizzle-orm";
+
+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",
+]);
+
+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/judging/db/src/seed.ts b/judging/db/src/seed.ts
new file mode 100644
index 00000000..a313315f
--- /dev/null
+++ b/judging/db/src/seed.ts
@@ -0,0 +1,238 @@
+import { eq, sql } from "drizzle-orm";
+import { DEFAULT_TIMERS } from "@panel/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/judging/db/tsconfig.json b/judging/db/tsconfig.json
new file mode 100644
index 00000000..f54bde2c
--- /dev/null
+++ b/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/judging/docs/adr/001-standalone.md b/judging/docs/adr/001-standalone.md
index 6c417fb3..dd621080 100644
--- a/judging/docs/adr/001-standalone.md
+++ b/judging/docs/adr/001-standalone.md
@@ -6,9 +6,10 @@ Accepted
## Decision
-Panel lives under `judging/` with its own packages, its own Postgres database,
-and its own deploy. It does not share a schema or a module graph with the
-application that hosts the first event.
+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
@@ -19,4 +20,5 @@ rewrite.
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.
-A separate database means the extraction has no data to untangle.
+The panel tables are prefixed so they can share that database until an
+extraction.
diff --git a/judging/docs/adr/003-one-database.md b/judging/docs/adr/003-one-database.md
new file mode 100644
index 00000000..90a8dc72
--- /dev/null
+++ b/judging/docs/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 `@panel/core` `rank()` at pairwise
+weight 0, so the scoring check does not need that event.
diff --git a/judging/docs/api.md b/judging/docs/api.md
new file mode 100644
index 00000000..dd35ed3e
--- /dev/null
+++ b/judging/docs/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/judging/docs/deploy.md b/judging/docs/deploy.md
new file mode 100644
index 00000000..5b28feed
--- /dev/null
+++ b/judging/docs/deploy.md
@@ -0,0 +1,25 @@
+# Deploy
+
+Docker Compose is the deploy to start from. From the repository root:
+
+```
+docker compose -f judging/infra/docker/docker-compose.yml up --build
+```
+
+Then, against the published Postgres port:
+
+```
+DATABASE_URL=postgres://panel:panel@localhost:54329/panel
+PANEL_JWT_SECRET=dev-only-change-me
+pnpm --filter @panel/cli panel migrate
+pnpm --filter @panel/cli panel seed demo
+pnpm --filter @panel/cli panel doctor
+```
+
+`panel doctor` must report postgres, migrations, and jwt as ok. Set `DATABASE_URL` to the same Postgres the club app uses. `PANEL_DATABASE_URL` is only read when `DATABASE_URL` is unset. The stack is one API server. Leave `REDIS_URL` unset so live updates stay in that process. Clients that miss a socket message recover from `GET /v1/live`.
+
+The optional GCP module is `judging/infra/gcp`. It is not required to run the stack. The web image is `judging/infra/docker/Dockerfile.web`. Pass `NEXT_PUBLIC_PANEL_URL` at build time so the desk calls the API.
+
+Set `PANEL_SMTP_URL` to an SMTP URL when sign-in codes should be emailed.
+Without it, `PANEL_DEV_AUTH=1` returns the code in the response. OIDC waits
+on a provider; trusted JWT from the club site is the handoff that works now.
diff --git a/judging/docs/rubric.md b/judging/docs/rubric.md
new file mode 100644
index 00000000..70b42cc3
--- /dev/null
+++ b/judging/docs/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/judging/docs/scoring.md b/judging/docs/scoring.md
new file mode 100644
index 00000000..65d46488
--- /dev/null
+++ b/judging/docs/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/judging/infra/docker/Dockerfile b/judging/infra/docker/Dockerfile
new file mode 100644
index 00000000..e40b129d
--- /dev/null
+++ b/judging/infra/docker/Dockerfile
@@ -0,0 +1,15 @@
+FROM node:22-bookworm-slim
+
+WORKDIR /app
+RUN corepack enable
+
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
+COPY tooling ./tooling
+COPY judging ./judging
+COPY packages ./packages
+COPY sites ./sites
+
+RUN pnpm install --frozen-lockfile --filter @panel/server...
+
+EXPOSE 8787
+CMD ["pnpm", "--filter", "@panel/server", "start"]
diff --git a/judging/infra/docker/Dockerfile.web b/judging/infra/docker/Dockerfile.web
new file mode 100644
index 00000000..4dee1b6f
--- /dev/null
+++ b/judging/infra/docker/Dockerfile.web
@@ -0,0 +1,30 @@
+FROM node:22-bookworm-slim AS build
+
+WORKDIR /app
+RUN corepack enable
+
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
+COPY tooling ./tooling
+COPY judging ./judging
+COPY packages ./packages
+COPY sites ./sites
+
+ARG NEXT_PUBLIC_PANEL_URL=http://localhost:8787
+ENV NEXT_PUBLIC_PANEL_URL=$NEXT_PUBLIC_PANEL_URL
+
+RUN pnpm install --frozen-lockfile --filter @panel/web...
+RUN pnpm --filter @panel/web build
+
+FROM node:22-bookworm-slim
+
+WORKDIR /app
+ENV NODE_ENV=production
+ENV HOSTNAME=0.0.0.0
+ENV PORT=3000
+
+COPY --from=build /app/judging/web/.next/standalone ./
+COPY --from=build /app/judging/web/.next/static ./judging/web/.next/static
+COPY --from=build /app/judging/web/public ./judging/web/public
+
+EXPOSE 3000
+CMD ["node", "judging/web/server.js"]
diff --git a/judging/infra/docker/docker-compose.yml b/judging/infra/docker/docker-compose.yml
new file mode 100644
index 00000000..4da40215
--- /dev/null
+++ b/judging/infra/docker/docker-compose.yml
@@ -0,0 +1,30 @@
+services:
+ postgres:
+ image: postgres:16
+ environment:
+ POSTGRES_USER: panel
+ POSTGRES_PASSWORD: panel
+ POSTGRES_DB: panel
+ ports:
+ - "54329:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U panel"]
+ interval: 2s
+ timeout: 2s
+ retries: 20
+
+ server:
+ build:
+ context: ../../..
+ dockerfile: judging/infra/docker/Dockerfile
+ environment:
+ PANEL_DATABASE_URL: postgres://panel:panel@postgres:5432/panel
+ DATABASE_URL: postgres://panel:panel@postgres:5432/panel
+ PANEL_JWT_SECRET: dev-only-change-me
+ PANEL_DEV_AUTH: "1"
+ PORT: "8787"
+ ports:
+ - "8787:8787"
+ depends_on:
+ postgres:
+ condition: service_healthy
diff --git a/judging/infra/gcp/main.tf b/judging/infra/gcp/main.tf
new file mode 100644
index 00000000..ad1f1989
--- /dev/null
+++ b/judging/infra/gcp/main.tf
@@ -0,0 +1,129 @@
+variable "project" {
+ type = string
+}
+
+variable "region" {
+ type = string
+ default = "us-east1"
+}
+
+variable "server_image" {
+ type = string
+}
+
+variable "web_image" {
+ type = string
+}
+
+variable "database_url_secret" {
+ type = string
+ description = "Secret Manager secret id holding DATABASE_URL, the shared Postgres database."
+}
+
+resource "google_cloud_run_v2_service" "server" {
+ name = "panel-server"
+ location = var.region
+ project = var.project
+
+ template {
+ containers {
+ image = var.server_image
+ ports {
+ container_port = 8787
+ }
+ env {
+ name = "DATABASE_URL"
+ value_source {
+ secret_key_ref {
+ secret = var.database_url_secret
+ version = "latest"
+ }
+ }
+ }
+ }
+ }
+}
+
+resource "google_cloud_run_v2_service" "web" {
+ name = "panel-web"
+ location = var.region
+ project = var.project
+
+ template {
+ containers {
+ image = var.web_image
+ env {
+ name = "NEXT_PUBLIC_PANEL_URL"
+ value = google_cloud_run_v2_service.server.uri
+ }
+ }
+ }
+}
+
+variable "alert_email" {
+ type = string
+ default = ""
+}
+
+locals {
+ server_host = trimprefix(google_cloud_run_v2_service.server.uri, "https://")
+}
+
+resource "google_monitoring_uptime_check_config" "readyz" {
+ display_name = "panel readyz"
+ timeout = "10s"
+ period = "60s"
+ project = var.project
+
+ http_check {
+ path = "/readyz"
+ port = "443"
+ use_ssl = true
+ validate_ssl = true
+ }
+
+ monitored_resource {
+ type = "uptime_url"
+ labels = {
+ project_id = var.project
+ host = local.server_host
+ }
+ }
+}
+
+resource "google_monitoring_notification_channel" "email" {
+ count = var.alert_email == "" ? 0 : 1
+ display_name = "panel alerts"
+ type = "email"
+ project = var.project
+ labels = {
+ email_address = var.alert_email
+ }
+}
+
+resource "google_monitoring_alert_policy" "readyz" {
+ count = var.alert_email == "" ? 0 : 1
+ display_name = "panel /readyz"
+ combiner = "OR"
+ project = var.project
+
+ conditions {
+ display_name = "readyz check failed"
+ condition_threshold {
+ filter = "metric.type=\"monitoring.googleapis.com/uptime_check/check_passed\" AND resource.type=\"uptime_url\" AND metric.label.check_id=\"${google_monitoring_uptime_check_config.readyz.uptime_check_id}\""
+ duration = "120s"
+ comparison = "COMPARISON_LT"
+ threshold_value = 1
+ }
+ }
+
+ notification_channels = [google_monitoring_notification_channel.email[0].id]
+}
+
+output "server_uri" {
+ value = google_cloud_run_v2_service.server.uri
+}
+
+output "web_uri" {
+ value = google_cloud_run_v2_service.web.uri
+}
diff --git a/judging/server/eslint.config.mjs b/judging/server/eslint.config.mjs
new file mode 100644
index 00000000..d466eefb
--- /dev/null
+++ b/judging/server/eslint.config.mjs
@@ -0,0 +1,28 @@
+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" },
+ },
+ {
+ files: ["src/**/*.ts"],
+ ignores: ["src/env.ts", "src/**/*.test.ts"],
+ rules: {
+ "no-restricted-imports": [
+ "error",
+ {
+ patterns: [
+ {
+ group: ["@query", "@query/*"],
+ message:
+ "judging/ is a standalone product. Import from @panel/* only.",
+ },
+ ],
+ },
+ ],
+ },
+ },
+];
diff --git a/judging/server/package.json b/judging/server/package.json
new file mode 100644
index 00000000..d260728b
--- /dev/null
+++ b/judging/server/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "@panel/server",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ "./outbox": "./src/services/outbox.ts"
+ },
+ "scripts": {
+ "start": "tsx src/index.ts",
+ "lint": "eslint . --max-warnings 0",
+ "typecheck": "tsc --noEmit",
+ "test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts"
+ },
+ "dependencies": {
+ "@hono/node-server": "^1.19.0",
+ "@panel/core": "workspace:*",
+ "@panel/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",
+ "tsx": "^4.23.15",
+ "ws": "8.20.1",
+ "zod": "4.6.5"
+ },
+ "devDependencies": {
+ "@query/eslint-config": "workspace:*",
+ "@query/tsconfig": "workspace:*",
+ "@types/node": "22.20.4",
+ "@types/ws": "^8.18.1",
+ "typescript": "7.0.2",
+ "vitest": "^5.0.1"
+ }
+}
diff --git a/judging/server/src/app.test.ts b/judging/server/src/app.test.ts
new file mode 100644
index 00000000..9d445519
--- /dev/null
+++ b/judging/server/src/app.test.ts
@@ -0,0 +1,18 @@
+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 });
+ });
+});
diff --git a/judging/server/src/app.ts b/judging/server/src/app.ts
new file mode 100644
index 00000000..cdd03e78
--- /dev/null
+++ b/judging/server/src/app.ts
@@ -0,0 +1,391 @@
+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 "@panel/db";
+import { event, loginCode, membership, organization, user, visit } from "@panel/db";
+import { hashCode, newLoginCode, roleAtLeast, signActor, verifyActor } from "./auth";
+import { sendLoginCode } from "./mail";
+import type { 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/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;
+}) {
+ 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 } }): 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);
+ }
+ }
+ 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/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) => {
+ const header = c.req.header("authorization");
+ if (!header?.startsWith("Bearer ")) return c.json({ message: "Unauthorized" }, 401);
+ try {
+ await verifyActor(header.slice("Bearer ".length), {
+ secret: options.jwtSecret,
+ jwksUrl: options.jwksUrl,
+ });
+ } catch {
+ 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) => {
+ const header = c.req.header("authorization");
+ if (!header?.startsWith("Bearer ")) return c.json({ message: "Unauthorized" }, 401);
+ try {
+ await verifyActor(header.slice("Bearer ".length), {
+ secret: options.jwtSecret,
+ jwksUrl: options.jwksUrl,
+ });
+ } catch {
+ 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/judging/server/src/auth.test.ts b/judging/server/src/auth.test.ts
new file mode 100644
index 00000000..33babd47
--- /dev/null
+++ b/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/judging/server/src/auth.ts b/judging/server/src/auth.ts
new file mode 100644
index 00000000..8a51e635
--- /dev/null
+++ b/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/judging/server/src/bus.test.ts b/judging/server/src/bus.test.ts
new file mode 100644
index 00000000..b35b71f4
--- /dev/null
+++ b/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/judging/server/src/bus.ts b/judging/server/src/bus.ts
new file mode 100644
index 00000000..1cc6fe2b
--- /dev/null
+++ b/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/judging/server/src/cross-server.check.ts b/judging/server/src/cross-server.check.ts
new file mode 100644
index 00000000..85c4120c
--- /dev/null
+++ b/judging/server/src/cross-server.check.ts
@@ -0,0 +1,111 @@
+import { WebSocket } from "ws";
+import { eq } from "drizzle-orm";
+import { createDb, event, judge, organization } from "@panel/db";
+import { signActor } from "./auth";
+import { log } from "./log";
+
+const databaseUrl = process.env.DATABASE_URL ?? process.env.PANEL_DATABASE_URL;
+const secret = process.env.PANEL_JWT_SECRET;
+const portA = process.env.PORT_A ?? "8787";
+const portB = process.env.PORT_B ?? portA;
+if (!databaseUrl || !secret) {
+ throw new Error("DATABASE_URL and PANEL_JWT_SECRET are required");
+}
+
+const { db, pool } = createDb(databaseUrl);
+const [demo] = await db
+ .select({ eventId: event.id, org: organization.slug })
+ .from(event)
+ .innerJoin(organization, eq(organization.id, event.orgId))
+ .where(eq(organization.slug, "demo"));
+if (!demo) throw new Error("demo event missing");
+
+const judges = await db.select().from(judge).where(eq(judge.eventId, demo.eventId));
+await pool.end();
+
+const baseA = `http://127.0.0.1:${portA}`;
+const socket = new WebSocket(
+ `ws://127.0.0.1:${portB}/ws?channel=event:${demo.eventId}:board`,
+);
+await new Promise((resolve, reject) => {
+ socket.once("open", () => resolve());
+ socket.once("error", reject);
+});
+
+let accept = false;
+const heard = new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error("no vote on the other server within 1s")), 15000);
+ socket.on("message", (data) => {
+ const text = String(data);
+ if (!accept || !text.includes("vote.cast")) return;
+ clearTimeout(timer);
+ resolve(text);
+ });
+});
+
+let voted = false;
+for (const row of judges) {
+ const token = await signActor(
+ {
+ sub: row.id,
+ email: row.email,
+ name: row.name,
+ org: demo.org,
+ role: null,
+ judgeExternalId: null,
+ },
+ secret,
+ 600,
+ );
+ const headers = {
+ authorization: `Bearer ${token}`,
+ "content-type": "application/json",
+ };
+ const next = await fetch(`${baseA}/v1/session/next`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ eventId: demo.eventId }),
+ });
+ const visit = (await next.json()) as {
+ done?: boolean;
+ visitId?: string;
+ tableNumber?: number | null;
+ message?: string;
+ };
+ if (!next.ok || visit.done || !visit.visitId) continue;
+ await fetch(`${baseA}/v1/session/arrive`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ eventId: demo.eventId, tableNumber: visit.tableNumber }),
+ });
+ const rubricResponse = await fetch(
+ `${baseA}/v1/session/rubric?eventId=${demo.eventId}`,
+ { headers },
+ );
+ const rubric = (await rubricResponse.json()) as { id: string; min: number }[];
+ if (!Array.isArray(rubric) || rubric.length === 0) continue;
+ accept = true;
+ const vote = await fetch(`${baseA}/v1/session/vote`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ eventId: demo.eventId,
+ visitId: visit.visitId,
+ comment: null,
+ scores: rubric.map((item) => ({ criterionId: item.id, value: item.min })),
+ }),
+ });
+ if (!vote.ok) continue;
+ voted = true;
+ break;
+}
+
+if (!voted) throw new Error("no judge could cast a vote");
+const message = await Promise.race([
+ heard,
+ new Promise((_, reject) => {
+ setTimeout(() => reject(new Error("no vote on the other server within 1s")), 1000);
+ }),
+]);
+log({ message });
+socket.close();
diff --git a/judging/server/src/env.ts b/judging/server/src/env.ts
new file mode 100644
index 00000000..4240ad98
--- /dev/null
+++ b/judging/server/src/env.ts
@@ -0,0 +1,11 @@
+export function serverEnv() {
+ return {
+ databaseUrl: process.env.DATABASE_URL ?? process.env.PANEL_DATABASE_URL ?? "",
+ jwtSecret: process.env.PANEL_JWT_SECRET ?? "",
+ jwksUrl: process.env.PANEL_JWKS_URL,
+ devAuth: process.env.PANEL_DEV_AUTH === "1",
+ smtpUrl: process.env.PANEL_SMTP_URL,
+ port: Number(process.env.PORT ?? 8787),
+ redisUrl: process.env.REDIS_URL,
+ };
+}
diff --git a/judging/server/src/hub.ts b/judging/server/src/hub.ts
new file mode 100644
index 00000000..3c97c38a
--- /dev/null
+++ b/judging/server/src/hub.ts
@@ -0,0 +1,31 @@
+import type { Server } from "node:http";
+import { WebSocketServer } from "ws";
+import type { Bus } from "./bus";
+import type { Metrics } from "./metrics";
+
+/** Clients subscribe with `?channel=event:{id}:board`. The bus is the only fan-out. */
+export function attachHub(server: Server, bus: Bus, metrics: Metrics) {
+ const sockets = new WebSocketServer({ server, path: "/ws" });
+ sockets.on("connection", (socket, request) => {
+ const channel = new URL(request.url ?? "/", "http://localhost").searchParams.get(
+ "channel",
+ );
+ metrics.wsConnections.inc();
+ if (!channel) {
+ socket.close();
+ metrics.wsConnections.dec();
+ return;
+ }
+ const unsubscribe = bus.subscribe(channel, (message) => {
+ if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
+ });
+ socket.on("close", () => {
+ unsubscribe();
+ metrics.wsConnections.dec();
+ });
+ });
+ bus.onDown(() => {
+ for (const client of sockets.clients) client.close();
+ });
+ return sockets;
+}
diff --git a/judging/server/src/index.ts b/judging/server/src/index.ts
new file mode 100644
index 00000000..b5ad4d8b
--- /dev/null
+++ b/judging/server/src/index.ts
@@ -0,0 +1,73 @@
+import { serve } from "@hono/node-server";
+import type { Server } from "node:http";
+import { sql } from "drizzle-orm";
+import { boardChannel, judgeChannel } from "@panel/core";
+import { createDb } from "@panel/db";
+import { createBus } from "./bus";
+import { attachHub } from "./hub";
+import { createApp } from "./app";
+import { serverEnv } from "./env";
+import { log } from "./log";
+import { createMetrics } from "./metrics";
+import { drainOutbox } from "./services/outbox";
+import { loadOpenVisits, visitsOverTarget } from "./services/overtime";
+
+const env = serverEnv();
+if (!env.databaseUrl) {
+ log({ level: "error", message: "DATABASE_URL is not set" });
+ process.exit(1);
+}
+
+const { db } = createDb(env.databaseUrl);
+const metrics = createMetrics();
+const bus = createBus(env.redisUrl);
+const app = createApp({
+ db,
+ metrics,
+ jwtSecret: env.jwtSecret,
+ jwksUrl: env.jwksUrl,
+ devAuth: env.devAuth,
+ smtpUrl: env.smtpUrl,
+ busMode: env.redisUrl ? "redis" : "memory",
+ bus,
+});
+
+const notedOvertime = new Set();
+const timer = setInterval(() => {
+ drainOutbox(db)
+ .then(async () => {
+ const pending = await db.execute<{ count: string }>(
+ sql`select count(*)::text as count from outbox where delivered_at is null`,
+ );
+ const count = Number(pending.rows[0]?.count ?? 0);
+ metrics.outboxPending.set(Number.isFinite(count) ? count : 0);
+ })
+ .catch((error: unknown) => {
+ log({
+ level: "error",
+ message: error instanceof Error ? error.message : "outbox",
+ });
+ });
+ loadOpenVisits(db)
+ .then((rows) => {
+ for (const row of visitsOverTarget(rows, new Date())) {
+ if (notedOvertime.has(row.visitId)) continue;
+ notedOvertime.add(row.visitId);
+ const message = { kind: "judge.overtime", eventId: row.eventId, visitId: row.visitId };
+ bus.publish(judgeChannel(row.judgeId), message);
+ bus.publish(boardChannel(row.eventId), message);
+ }
+ })
+ .catch((error: unknown) => {
+ log({
+ level: "error",
+ message: error instanceof Error ? error.message : "overtime",
+ });
+ });
+}, 2000);
+timer.unref();
+
+const server = serve({ fetch: app.fetch, port: env.port }, () => {
+ log({ level: "info", message: "listening", port: env.port });
+});
+attachHub(server as Server, bus, metrics);
diff --git a/judging/server/src/log.ts b/judging/server/src/log.ts
new file mode 100644
index 00000000..7f80e178
--- /dev/null
+++ b/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/judging/server/src/mail.ts b/judging/server/src/mail.ts
new file mode 100644
index 00000000..89d84954
--- /dev/null
+++ b/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/judging/server/src/metrics.ts b/judging/server/src/metrics.ts
new file mode 100644
index 00000000..5089489a
--- /dev/null
+++ b/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/judging/server/src/router.ts b/judging/server/src/router.ts
new file mode 100644
index 00000000..8e7806bc
--- /dev/null
+++ b/judging/server/src/router.ts
@@ -0,0 +1,652 @@
+import { initTRPC, TRPCError } from "@trpc/server";
+import { and, eq, sql } from "drizzle-orm";
+import { z } from "zod";
+import { assertTransition, boardChannel, judgeChannel, leaderboardChannel } from "@panel/core";
+import type { PanelDb } from "@panel/db";
+import { event, eventLog, judge, criterion, rubric, visit, vote } from "@panel/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 } 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";
+
+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);
+ }
+ }),
+ }),
+ 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");
+ 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/judging/server/src/services/arrive.ts b/judging/server/src/services/arrive.ts
new file mode 100644
index 00000000..bd213f61
--- /dev/null
+++ b/judging/server/src/services/arrive.ts
@@ -0,0 +1,76 @@
+import { and, eq, isNull } from "drizzle-orm";
+import type { PanelDb } from "@panel/db";
+import { event, eventLog, outbox, project, visit } from "@panel/db";
+import { phaseAllows } from "@panel/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/judging/server/src/services/catalog.ts b/judging/server/src/services/catalog.ts
new file mode 100644
index 00000000..9bef9d5d
--- /dev/null
+++ b/judging/server/src/services/catalog.ts
@@ -0,0 +1,454 @@
+import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
+import type { PanelDb } from "@panel/db";
+import {
+ apiKey,
+ criterion,
+ event,
+ eventConfig,
+ eventLog,
+ floorTable,
+ judge,
+ organization,
+ prize,
+ project,
+ projectTrack,
+ result,
+ resultRun,
+ rubric,
+ track,
+ vote,
+ voteScore,
+ webhook,
+ zone,
+} from "@panel/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/judging/server/src/services/compare.ts b/judging/server/src/services/compare.ts
new file mode 100644
index 00000000..953314ce
--- /dev/null
+++ b/judging/server/src/services/compare.ts
@@ -0,0 +1,63 @@
+import { and, desc, eq, isNull } from "drizzle-orm";
+import { phaseAllows } from "@panel/core";
+import type { PanelDb } from "@panel/db";
+import { comparison, event, eventLog, visit } from "@panel/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/judging/server/src/services/dispatch.concurrent.test.ts b/judging/server/src/services/dispatch.concurrent.test.ts
new file mode 100644
index 00000000..71eb9e52
--- /dev/null
+++ b/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 "@panel/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("@panel/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/judging/server/src/services/dispatch.ts b/judging/server/src/services/dispatch.ts
new file mode 100644
index 00000000..9210b081
--- /dev/null
+++ b/judging/server/src/services/dispatch.ts
@@ -0,0 +1,413 @@
+import { randomInt } from "node:crypto";
+import { and, eq, isNull, sql } from "drizzle-orm";
+import { isLive, pickNext } from "@panel/core";
+import type { TimerConfig } from "@panel/core";
+import type { PanelDb, PanelTx } from "@panel/db";
+import {
+ event,
+ eventConfig,
+ eventLog,
+ judge,
+ outbox,
+ project,
+ projectTrack,
+ track,
+ visit,
+ vote,
+} from "@panel/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 };
+ });
+}
+
+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/judging/server/src/services/feedback.test.ts b/judging/server/src/services/feedback.test.ts
new file mode 100644
index 00000000..6f897277
--- /dev/null
+++ b/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/judging/server/src/services/feedback.ts b/judging/server/src/services/feedback.ts
new file mode 100644
index 00000000..d12107fc
--- /dev/null
+++ b/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/judging/server/src/services/floor.test.ts b/judging/server/src/services/floor.test.ts
new file mode 100644
index 00000000..1da5647e
--- /dev/null
+++ b/judging/server/src/services/floor.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_TIMERS } from "@panel/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/judging/server/src/services/floor.ts b/judging/server/src/services/floor.ts
new file mode 100644
index 00000000..0358e071
--- /dev/null
+++ b/judging/server/src/services/floor.ts
@@ -0,0 +1,79 @@
+import { and, eq, isNull, sql } from "drizzle-orm";
+import { isOverTarget } from "@panel/core";
+import type { TimerConfig } from "@panel/core";
+import type { PanelDb } from "@panel/db";
+import { eventConfig, judge, project, visit, vote } from "@panel/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/judging/server/src/services/flow.integration.test.ts b/judging/server/src/services/flow.integration.test.ts
new file mode 100644
index 00000000..91f3a024
--- /dev/null
+++ b/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 "@panel/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("@panel/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/judging/server/src/services/live.ts b/judging/server/src/services/live.ts
new file mode 100644
index 00000000..750c7481
--- /dev/null
+++ b/judging/server/src/services/live.ts
@@ -0,0 +1,122 @@
+import { and, eq, isNotNull, isNull, sql } from "drizzle-orm";
+import type { PanelDb } from "@panel/db";
+import { event, eventConfig, judge, organization, prize, project, result, resultRun, track, visit, vote } from "@panel/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/judging/server/src/services/logs.test.ts b/judging/server/src/services/logs.test.ts
new file mode 100644
index 00000000..2be074f6
--- /dev/null
+++ b/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/judging/server/src/services/logs.ts b/judging/server/src/services/logs.ts
new file mode 100644
index 00000000..7c288047
--- /dev/null
+++ b/judging/server/src/services/logs.ts
@@ -0,0 +1,28 @@
+import { isOutboxTopic } from "@panel/core";
+import type { LogKind } from "@panel/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/judging/server/src/services/outbox.ts b/judging/server/src/services/outbox.ts
new file mode 100644
index 00000000..0844039b
--- /dev/null
+++ b/judging/server/src/services/outbox.ts
@@ -0,0 +1,52 @@
+import { createHmac } from "node:crypto";
+import { eq, isNull } from "drizzle-orm";
+import type { PanelDb } from "@panel/db";
+import { outbox, webhook } from "@panel/db";
+
+/** Delivers pending outbox rows. A topic with no active webhook is delivered. */
+export async function drainOutbox(db: PanelDb, now = new Date()): 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
+ .update(outbox)
+ .set({ deliveredAt: now, attempts: row.attempts + 1 })
+ .where(eq(outbox.id, row.id));
+ delivered += 1;
+ }
+ return delivered;
+}
diff --git a/judging/server/src/services/overtime.test.ts b/judging/server/src/services/overtime.test.ts
new file mode 100644
index 00000000..f39cfaef
--- /dev/null
+++ b/judging/server/src/services/overtime.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_TIMERS } from "@panel/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/judging/server/src/services/overtime.ts b/judging/server/src/services/overtime.ts
new file mode 100644
index 00000000..dad634b7
--- /dev/null
+++ b/judging/server/src/services/overtime.ts
@@ -0,0 +1,49 @@
+import { and, eq, isNotNull, isNull } from "drizzle-orm";
+import { isOverTarget } from "@panel/core";
+import type { TimerConfig } from "@panel/core";
+import type { PanelDb } from "@panel/db";
+import { eventConfig, visit } from "@panel/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/judging/server/src/services/records.ts b/judging/server/src/services/records.ts
new file mode 100644
index 00000000..39f865cd
--- /dev/null
+++ b/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 "@panel/core";
+import type { PanelDb } from "@panel/db";
+import { event, eventLog, floorTable, judge, outbox, project, projectTrack, visit } from "@panel/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/judging/server/src/services/redis.integration.test.ts b/judging/server/src/services/redis.integration.test.ts
new file mode 100644
index 00000000..561545b7
--- /dev/null
+++ b/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/judging/server/src/services/results.test.ts b/judging/server/src/services/results.test.ts
new file mode 100644
index 00000000..552c88d4
--- /dev/null
+++ b/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/judging/server/src/services/results.ts b/judging/server/src/services/results.ts
new file mode 100644
index 00000000..486c5d32
--- /dev/null
+++ b/judging/server/src/services/results.ts
@@ -0,0 +1,328 @@
+import { and, desc, eq, isNotNull, isNull, sql } from "drizzle-orm";
+import { phaseAllows, rank } from "@panel/core";
+import type { PanelDb } from "@panel/db";
+import {
+ comparison,
+ event,
+ eventConfig,
+ eventLog,
+ judge,
+ outbox,
+ project,
+ projectTrack,
+ result,
+ resultRun,
+ track,
+ vote,
+} from "@panel/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/judging/server/src/services/vote.test.ts b/judging/server/src/services/vote.test.ts
new file mode 100644
index 00000000..739f5334
--- /dev/null
+++ b/judging/server/src/services/vote.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_TIMERS } from "@panel/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/judging/server/src/services/vote.ts b/judging/server/src/services/vote.ts
new file mode 100644
index 00000000..3d0dfd89
--- /dev/null
+++ b/judging/server/src/services/vote.ts
@@ -0,0 +1,175 @@
+import { and, eq } from "drizzle-orm";
+import { isPastCutoff, phaseAllows, validateScores, weightedTotal } from "@panel/core";
+import type { Criterion, Phase, ScoreInput, TimerConfig } from "@panel/core";
+import type { PanelDb } from "@panel/db";
+import {
+ criterion,
+ event,
+ eventConfig,
+ eventLog,
+ outbox,
+ rubric,
+ track,
+ visit,
+ vote,
+ voteScore,
+} from "@panel/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/judging/server/tsconfig.json b/judging/server/tsconfig.json
new file mode 100644
index 00000000..f54bde2c
--- /dev/null
+++ b/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/judging/server/vitest.config.ts b/judging/server/vitest.config.ts
new file mode 100644
index 00000000..f62398d7
--- /dev/null
+++ b/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/judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx b/judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx
new file mode 100644
index 00000000..1e88cc3d
--- /dev/null
+++ b/judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { apiBase } from "../../../../lib/api";
+
+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 [live, setLive] = useState(false);
+
+ useEffect(() => {
+ let stop = false;
+ let timer: ReturnType | undefined;
+ let socket: WebSocket | undefined;
+
+ const pull = async () => {
+ const response = await fetch(`${apiBase}/v1/live/${orgSlug}/${eventSlug}`);
+ if (!response.ok || stop) return;
+ const next = (await response.json()) as Snapshot;
+ setSnapshot(next);
+ if (!socket) {
+ socket = new WebSocket(
+ `${apiBase.replace(/^http/, "ws")}/ws?channel=event:${next.eventId}:board`,
+ );
+ socket.onopen = () => setLive(true);
+ socket.onclose = () => {
+ setLive(false);
+ socket = undefined;
+ };
+ socket.onmessage = () => {
+ void pull();
+ };
+ }
+ clearTimeout(timer);
+ timer = setTimeout(() => void pull(), (next.pollSeconds || 5) * 1000);
+ };
+
+ void pull();
+ return () => {
+ stop = true;
+ clearTimeout(timer);
+ socket?.close();
+ };
+ }, [orgSlug, eventSlug]);
+
+ if (!snapshot) return Loading the floor.
;
+ return (
+
+
+ {live ? "Live" : "Polling"} · {snapshot.phase}
+
+ {(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/judging/web/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx b/judging/web/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx
new file mode 100644
index 00000000..5e0a3b23
--- /dev/null
+++ b/judging/web/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx
@@ -0,0 +1,48 @@
+import { apiBase } from "../../../../../../lib/api";
+
+export default async function FeedbackPage({
+ params,
+}: {
+ params: Promise<{ token: string }>;
+}) {
+ const { token } = await params;
+ const response = await fetch(`${apiBase}/v1/feedback/${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/judging/web/app/e/[orgSlug]/[eventSlug]/page.tsx b/judging/web/app/e/[orgSlug]/[eventSlug]/page.tsx
new file mode 100644
index 00000000..3761eb51
--- /dev/null
+++ b/judging/web/app/e/[orgSlug]/[eventSlug]/page.tsx
@@ -0,0 +1,20 @@
+import { LiveBoard } from "./board";
+import { loadEvent } from "../../../../lib/api";
+
+export default async function PublicBoard({
+ params,
+}: {
+ params: Promise<{ orgSlug: string; eventSlug: string }>;
+}) {
+ const { orgSlug, eventSlug } = await params;
+ const event = await loadEvent(orgSlug, eventSlug);
+ 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/judging/web/app/j/[orgSlug]/[eventSlug]/desk.tsx b/judging/web/app/j/[orgSlug]/[eventSlug]/desk.tsx
new file mode 100644
index 00000000..95cc9083
--- /dev/null
+++ b/judging/web/app/j/[orgSlug]/[eventSlug]/desk.tsx
@@ -0,0 +1,474 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import type { CSSProperties } from "react";
+import { apiBase } from "../../../../lib/api";
+import type { PublicEvent } from "../../../../lib/api";
+import { flushHeld, holdVote } from "../../../../lib/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({
+ orgSlug,
+ event,
+ ticket,
+}: {
+ orgSlug: string;
+ event: PublicEvent;
+ ticket?: string;
+}) {
+ const [email, setEmail] = useState("");
+ const [code, setCode] = useState("");
+ const [token, setToken] = useState("");
+ 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 [judgeId, setJudgeId] = useState("");
+ const [elapsed, setElapsed] = useState("");
+ const [scanning, setScanning] = useState(false);
+ const videoRef = useRef(null);
+ const accent = event.branding.colors?.accent ?? "#1c1917";
+
+ useEffect(() => {
+ if (!ticket) return;
+ setToken(ticket);
+ void fetch(`${apiBase}/v1/session/rubric?eventId=${event.eventId}`, {
+ headers: { authorization: `Bearer ${ticket}` },
+ })
+ .then((response) => response.json())
+ .then(async (rows: CriterionRow[]) => {
+ if (!Array.isArray(rows)) {
+ setMessage("The handoff ticket was not accepted.");
+ return;
+ }
+ setCriteria(rows);
+ const progressResponse = await fetch(
+ `${apiBase}/v1/session/progress?eventId=${event.eventId}`,
+ { headers: { authorization: `Bearer ${ticket}` } },
+ );
+ if (!progressResponse.ok) return;
+ const row = (await progressResponse.json()) as {
+ completed: number;
+ total: number;
+ judgeId?: string;
+ };
+ setProgress(`${row.completed} of ${row.total} scored`);
+ if (row.judgeId) setJudgeId(row.judgeId);
+ })
+ .catch(() => setMessage("The handoff ticket was not accepted."));
+ }, [ticket, event.eventId]);
+
+ async function call(path: string, body?: unknown) {
+ const response = await fetch(`${apiBase}${path}`, {
+ method: body ? "POST" : "GET",
+ headers: {
+ "content-type": "application/json",
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const payload = (await response.json()) as { message?: string };
+ if (!response.ok) throw new Error(payload.message ?? "Request failed");
+ 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(() => {
+ if (!token) return;
+ const send = () => {
+ void flushHeld((path, payload) =>
+ fetch(`${apiBase}${path}`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify(payload),
+ }).then((response) => {
+ if (!response.ok) throw new Error("still offline");
+ }),
+ ).then((sent) => {
+ if (sent > 0) {
+ setMessage(`Sent ${sent} score${sent === 1 ? "" : "s"} held on this phone.`);
+ }
+ });
+ };
+ window.addEventListener("online", send);
+ return () => window.removeEventListener("online", send);
+ }, [token]);
+
+ useEffect(() => {
+ if (!visit?.handedOutAt || visit.arrivedAt || visit.done) {
+ setElapsed("");
+ return;
+ }
+ const started = new Date(visit.handedOutAt).getTime();
+ const tick = () => {
+ const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000));
+ setElapsed(`${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} walking`);
+ };
+ tick();
+ const timer = setInterval(tick, 1000);
+ return () => clearInterval(timer);
+ }, [visit?.handedOutAt, visit?.arrivedAt, visit?.done]);
+
+ useEffect(() => {
+ if (!token || !judgeId) return;
+ const socket = new WebSocket(
+ `${apiBase.replace(/^http/, "ws")}/ws?channel=judge:${judgeId}`,
+ );
+ socket.onmessage = (incoming) => {
+ let payload: { kind?: string };
+ try {
+ payload = JSON.parse(String(incoming.data)) as { kind?: string };
+ } catch {
+ return;
+ }
+ if (payload.kind === "judge.recalled") {
+ setVisit(null);
+ setMessage("Come back to the desk.");
+ }
+ if (payload.kind === "visit.voided") {
+ setVisit(null);
+ setMessage("This visit was voided.");
+ }
+ if (payload.kind === "judge.overtime") {
+ setMessage("Past the target time at this table.");
+ }
+ };
+ return () => socket.close();
+ }, [token, judgeId]);
+
+ async function loadProgress(bearer: string) {
+ const response = await fetch(
+ `${apiBase}/v1/session/progress?eventId=${event.eventId}`,
+ { headers: { authorization: `Bearer ${bearer}` } },
+ );
+ if (!response.ok) return;
+ const row = (await response.json()) as { completed: number; total: number; judgeId?: string };
+ setProgress(`${row.completed} of ${row.total} scored`);
+ if (row.judgeId) setJudgeId(row.judgeId);
+ }
+
+ return (
+
+ {event.orgName}
+ {event.name}
+ {event.branding.tagline}
+ Phase: {event.phase}
+
+ {!token ? (
+
+ ) : (
+
+
+
+ {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/judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx b/judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx
new file mode 100644
index 00000000..f1db4caa
--- /dev/null
+++ b/judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx
@@ -0,0 +1,16 @@
+import { JudgeDesk } from "./desk";
+import { loadEvent } from "../../../../lib/api";
+
+export default async function JudgePage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ orgSlug: string; eventSlug: string }>;
+ searchParams: Promise<{ ticket?: string }>;
+}) {
+ const { orgSlug, eventSlug } = await params;
+ const { ticket } = await searchParams;
+ const event = await loadEvent(orgSlug, eventSlug);
+ if (!event) return This event is not available.;
+ return ;
+}
diff --git a/judging/web/app/layout.tsx b/judging/web/app/layout.tsx
new file mode 100644
index 00000000..0737340c
--- /dev/null
+++ b/judging/web/app/layout.tsx
@@ -0,0 +1,30 @@
+import type { Metadata, Viewport } from "next";
+
+export const metadata: Metadata = {
+ title: "Panel",
+ description: "Hackathon judging",
+ manifest: "/manifest.webmanifest",
+};
+
+export const viewport: Viewport = {
+ width: "device-width",
+ initialScale: 1,
+ themeColor: "#111111",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx b/judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx
new file mode 100644
index 00000000..cda7ce18
--- /dev/null
+++ b/judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx
@@ -0,0 +1,408 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { apiBase } from "../../../../lib/api";
+import type { PublicEvent } from "../../../../lib/api";
+import { Editors } from "./editors";
+
+const phases = [
+ "setup",
+ "submissions_open",
+ "submissions_closed",
+ "judging_live",
+ "judging_closed",
+ "published",
+ "archived",
+] as const;
+
+export function Console({ event, ticket }: { event: PublicEvent; ticket?: string }) {
+ const [token, setToken] = useState(ticket ?? "");
+ const [message, setMessage] = 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",
+ authorization: `Bearer ${token}`,
+ },
+ 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(() => {
+ if (!token) return;
+ let stop = false;
+ const pull = async () => {
+ const response = await fetch(`${apiBase}/v1/floor/${event.eventId}`, {
+ headers: { authorization: `Bearer ${token}` },
+ });
+ 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 }[];
+ },
+ );
+ };
+ void pull();
+ const timer = setInterval(() => void pull(), 5000);
+ return () => {
+ stop = true;
+ clearInterval(timer);
+ };
+ }, [token, 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/judging/web/app/o/[orgSlug]/[eventSlug]/editors.tsx b/judging/web/app/o/[orgSlug]/[eventSlug]/editors.tsx
new file mode 100644
index 00000000..69104736
--- /dev/null
+++ b/judging/web/app/o/[orgSlug]/[eventSlug]/editors.tsx
@@ -0,0 +1,338 @@
+"use client";
+
+import { useState } from "react";
+import type { CSSProperties, PointerEvent } from "react";
+import { apiBase } from "../../../../lib/api";
+
+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,
+ token,
+ onMessage,
+}: {
+ eventId: string;
+ token: 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",
+ authorization: `Bearer ${token}`,
+ },
+ 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/judging/web/app/o/[orgSlug]/[eventSlug]/page.tsx b/judging/web/app/o/[orgSlug]/[eventSlug]/page.tsx
new file mode 100644
index 00000000..df8e1ec7
--- /dev/null
+++ b/judging/web/app/o/[orgSlug]/[eventSlug]/page.tsx
@@ -0,0 +1,16 @@
+import { Console } from "./console";
+import { loadEvent } from "../../../../lib/api";
+
+export default async function OrganizerPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ orgSlug: string; eventSlug: string }>;
+ searchParams: Promise<{ ticket?: string }>;
+}) {
+ const { orgSlug, eventSlug } = await params;
+ const { ticket } = await searchParams;
+ const event = await loadEvent(orgSlug, eventSlug);
+ if (!event) return This event is not available.;
+ return ;
+}
diff --git a/judging/web/app/page.tsx b/judging/web/app/page.tsx
new file mode 100644
index 00000000..66db153a
--- /dev/null
+++ b/judging/web/app/page.tsx
@@ -0,0 +1,8 @@
+export default function HomePage() {
+ return (
+
+ Panel
+ Judging for one event. Open a judge desk, the organizer console, or the public board.
+
+ );
+}
diff --git a/judging/web/e2e/visit.spec.ts b/judging/web/e2e/visit.spec.ts
new file mode 100644
index 00000000..f15aaee8
--- /dev/null
+++ b/judging/web/e2e/visit.spec.ts
@@ -0,0 +1,61 @@
+import { expect, test } from "@playwright/test";
+
+test.use({ viewport: { width: 390, height: 844 } });
+
+test("a phone visit holds one offline score, then the board shows the published order", async ({
+ page,
+ request,
+}) => {
+ await page.goto("/j/demo/demo");
+ await page.getByLabel("Email").fill("judge3@demo.panel");
+ await page.getByRole("button", { name: "Email me a code" }).click();
+ await page.getByRole("button", { name: "Sign in" }).click();
+ await page.getByRole("button", { name: "Next table" }).click();
+ const heading = page.getByRole("heading", { level: 2 });
+ await expect(heading).toBeVisible();
+ const table = (await heading.innerText()).match(/table (\d+)/)?.[1];
+ expect(table).toBeTruthy();
+ await page.getByLabel("Table number").fill(table ?? "");
+ await page.getByRole("button", { name: "I'm here" }).click();
+ await expect(page.getByText("Arrived.")).toBeVisible();
+ for (const name of ["Creativity", "Impact", "Scope", "Clarity", "Soundness"]) {
+ await page.getByRole("spinbutton", { name }).fill("6");
+ }
+
+ await page.context().setOffline(true);
+ await page.getByRole("button", { name: "Submit score" }).click();
+ await expect(page.getByText("Held on this phone")).toBeVisible();
+ await page.context().setOffline(false);
+ await page.evaluate(() => window.dispatchEvent(new Event("online")));
+ await expect(page.getByText("Sent 1 score")).toBeVisible();
+
+ const issued = await request.post("http://localhost:8787/v1/auth/magic-link", {
+ data: { email: "organizer@demo.panel" },
+ });
+ const code = ((await issued.json()) as { code?: string }).code;
+ expect(code).toBeTruthy();
+ const verified = await request.post("http://localhost:8787/v1/auth/verify", {
+ data: { email: "organizer@demo.panel", code, org: "demo" },
+ });
+ const token = ((await verified.json()) as { token?: string }).token;
+ expect(token).toBeTruthy();
+
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await page.goto("/o/demo/demo");
+ await page.getByLabel("Organizer token").fill(token ?? "");
+ await page.getByRole("button", { name: "judging_closed" }).click();
+ await expect(page.getByText("judging_closed")).toBeVisible();
+ await page.getByRole("button", { name: "Compute results" }).click();
+ await expect(page.getByText("Results computed")).toBeVisible();
+ await page.getByRole("button", { name: "Publish", exact: true }).click();
+ await expect(page.getByText("Published.")).toBeVisible();
+
+ await page.goto("/e/demo/demo");
+ await expect(page.getByRole("listitem").first()).toBeVisible();
+
+ await page.goto("/o/demo/demo");
+ await page.getByLabel("Organizer token").fill(token ?? "");
+ await page.getByRole("button", { name: "judging_closed" }).click();
+ await page.getByRole("button", { name: "judging_live" }).click();
+ await expect(page.getByText("judging_live")).toBeVisible();
+});
diff --git a/judging/web/eslint.config.mjs b/judging/web/eslint.config.mjs
new file mode 100644
index 00000000..1236da8c
--- /dev/null
+++ b/judging/web/eslint.config.mjs
@@ -0,0 +1,23 @@
+import { config } from "@query/eslint-config/base";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+ ...config,
+ {
+ files: ["**/*.ts", "**/*.tsx"],
+ rules: {
+ "no-restricted-imports": [
+ "error",
+ {
+ patterns: [
+ {
+ group: ["@query", "@query/*"],
+ message:
+ "judging/ is a standalone product. Import from @panel/* only.",
+ },
+ ],
+ },
+ ],
+ },
+ },
+];
diff --git a/judging/web/lib/api.ts b/judging/web/lib/api.ts
new file mode 100644
index 00000000..4fbb377d
--- /dev/null
+++ b/judging/web/lib/api.ts
@@ -0,0 +1,15 @@
+export const apiBase = process.env.NEXT_PUBLIC_PANEL_URL ?? "http://localhost:8787";
+
+export type PublicEvent = {
+ eventId: string;
+ name: string;
+ phase: string;
+ branding: { tagline?: string; colors?: { accent?: string } };
+ orgName: string;
+};
+
+export async function loadEvent(orgSlug: string, eventSlug: string): Promise {
+ const response = await fetch(`${apiBase}/v1/public/${orgSlug}/${eventSlug}`);
+ if (!response.ok) return null;
+ return response.json() as Promise;
+}
diff --git a/judging/web/lib/held.ts b/judging/web/lib/held.ts
new file mode 100644
index 00000000..40c3811b
--- /dev/null
+++ b/judging/web/lib/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/judging/web/next.config.ts b/judging/web/next.config.ts
new file mode 100644
index 00000000..68a6c64d
--- /dev/null
+++ b/judging/web/next.config.ts
@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ output: "standalone",
+};
+
+export default nextConfig;
diff --git a/judging/web/package.json b/judging/web/package.json
new file mode 100644
index 00000000..42b7da58
--- /dev/null
+++ b/judging/web/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@panel/web",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev --port 3005",
+ "build": "next build",
+ "lint": "eslint . --max-warnings 0",
+ "typecheck": "tsc --noEmit",
+ "e2e": "playwright test"
+ },
+ "dependencies": {
+ "next": "16.3.7",
+ "react": "19.3.0",
+ "react-dom": "19.3.0"
+ },
+ "devDependencies": {
+ "@query/eslint-config": "workspace:*",
+ "@query/tsconfig": "workspace:*",
+ "@types/node": "22.20.4",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "typescript": "7.0.2",
+ "@playwright/test": "^1.58.2"
+ }
+}
diff --git a/judging/web/playwright.config.ts b/judging/web/playwright.config.ts
new file mode 100644
index 00000000..7d21aa52
--- /dev/null
+++ b/judging/web/playwright.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "e2e",
+ timeout: 60_000,
+ use: {
+ baseURL: "http://localhost:3005",
+ },
+});
diff --git a/judging/web/public/manifest.webmanifest b/judging/web/public/manifest.webmanifest
new file mode 100644
index 00000000..cc5c32f8
--- /dev/null
+++ b/judging/web/public/manifest.webmanifest
@@ -0,0 +1,8 @@
+{
+ "name": "Panel",
+ "short_name": "Panel",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#f6f5f2",
+ "theme_color": "#111111"
+}
diff --git a/judging/web/test-results/.last-run.json b/judging/web/test-results/.last-run.json
new file mode 100644
index 00000000..cbcc1fba
--- /dev/null
+++ b/judging/web/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/judging/web/tsconfig.json b/judging/web/tsconfig.json
new file mode 100644
index 00000000..ea5d5185
--- /dev/null
+++ b/judging/web/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tooling/typescript/nextjs.json",
+ "compilerOptions": {
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
+ "plugins": [{ "name": "next" }],
+ "jsx": "preserve",
+ "noEmit": true
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules", "e2e", "playwright.config.ts"]
+}
diff --git a/package.json b/package.json
index f3ddfc8d..6d7ed480 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 judging/core"
+ "test": "vitest run packages/api packages/auth packages/db sites/mainweb/lib judging/core judging/server judging/cli"
},
"dependencies": {
"next": "16.3.7",
diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts
index bfa2e79d..c03e4ffc 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,12 @@ export const judgeAdminRouter = createTRPCRouter({
}
}
+ if (input.isActive) {
+ await syncJudgeToPanel(ctx.db as DrizzleDB, {
+ hackathonId: updated.hackathonId,
+ email: updated.email,
+ });
+ }
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..89dc0d40 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 } from "../../services/panel-sync";
export const judgePortalRouter = createTRPCRouter({
@@ -68,6 +69,25 @@ export const judgePortalRouter = createTRPCRouter({
return result;
}),
+ /** A short-lived ticket into the panel desk, only for an edition that opted in. */
+ panelDesk: protectedProcedure.query(async ({ ctx }) => {
+ return panelDeskUrl(ctx.db as DrizzleDB, ctx.userId as string);
+ }),
+
+ /** Organizer handoff into the panel console when this edition uses panel. */
+ panelConsole: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ const email = ctx.session?.user?.email;
+ if (!email) return { url: null as string | null };
+ return panelConsoleUrl(ctx.db as DrizzleDB, {
+ hackathonId: input.hackathonId,
+ userId: ctx.userId as string,
+ email,
+ name: ctx.session?.user?.name ?? email,
+ });
+ }),
+
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..5a3fa563
--- /dev/null
+++ b/packages/api/src/routers/judge/replay.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { rank } from "../../../../../judging/core/src/index";
+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..b1f17fac
--- /dev/null
+++ b/packages/api/src/services/panel-sync.ts
@@ -0,0 +1,225 @@
+import { and, eq, inArray, sql } from "drizzle-orm";
+import { hackathonProjects, hackathonResults, hackathons, judges, judgingProjects } from "@query/db";
+import { createHmac } from "node:crypto";
+import type { DrizzleDB } from "@query/db";
+
+/**
+ * Copies an edition into the panel service 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"]),
+ ),
+ );
+ for (const row of rows) {
+ await post(target, "project.upsert", {
+ eventId: target.eventId,
+ externalId: row.id,
+ name: row.name,
+ teamName: null,
+ tableNumber: null,
+ });
+ }
+ } catch {
+ return;
+ }
+}
+
+export async function syncJudgeToPanel(
+ db: DrizzleDB,
+ judge: { hackathonId: string; email: string | null },
+) {
+ try {
+ if (!judge.email) return;
+ const target = await panelTarget(db, judge.hackathonId);
+ if (!target) return;
+ await post(target, "judge.upsert", {
+ eventId: target.eventId,
+ email: judge.email,
+ name: judge.email,
+ externalId: null,
+ });
+ } catch {
+ return;
+ }
+}
+
+async function panelTarget(db: DrizzleDB, hackathonId: string) {
+ const url = process.env.PANEL_URL;
+ const token = process.env.PANEL_SERVICE_TOKEN;
+ const eventId = process.env.PANEL_EVENT_ID;
+ if (!url || !token || !eventId) return null;
+ const [row] = await db
+ .select({ backend: hackathons.judgingBackend })
+ .from(hackathons)
+ .where(eq(hackathons.id, hackathonId));
+ if (row?.backend !== "panel") return null;
+ return { url, token, eventId };
+}
+
+async function post(
+ target: { url: string; token: string },
+ procedure: string,
+ body: unknown,
+) {
+ const response = await fetch(`${target.url.replace(/\/$/, "")}/trpc/${procedure}`, {
+ method: "POST",
+ headers: {
+ authorization: `Bearer ${target.token}`,
+ "content-type": "application/json",
+ },
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) {
+ throw new Error(`panel ${procedure} returned ${response.status}`);
+ }
+}
+
+export async function pullPanelResults(db: DrizzleDB, hackathonId: string) {
+ const target = await panelTarget(db, hackathonId);
+ if (!target) return 0;
+ const response = await fetch(
+ `${target.url.replace(/\/$/, "")}/v1/results/${target.eventId}`,
+ { headers: { authorization: `Bearer ${target.token}` } },
+ );
+ if (response.status === 403) throw new Error("Panel results are not published");
+ if (!response.ok) throw new Error(`panel results returned ${response.status}`);
+ const body = (await response.json()) as {
+ rows: {
+ externalId: string | null;
+ placement: number | null;
+ score: number;
+ track: string;
+ voteCount: number;
+ }[];
+ };
+ 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 web = process.env.PANEL_WEB_URL;
+ const secret = process.env.PANEL_JWT_SECRET;
+ const org = process.env.PANEL_ORG_SLUG;
+ const eventSlug = process.env.PANEL_EVENT_SLUG;
+ if (!web || !secret || !org || !eventSlug) return { url: null as string | null };
+ const [row] = await db
+ .select({ email: judges.email })
+ .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?.email) return { url: null };
+ const now = Math.floor(Date.now() / 1000);
+ const ticket = signHs256(
+ {
+ sub: userId,
+ email: row.email,
+ name: row.email,
+ org,
+ role: null,
+ judge_external_id: null,
+ iat: now,
+ exp: now + 60 * 30,
+ },
+ secret,
+ );
+ const url = `${web.replace(/\/$/, "")}/j/${org}/${eventSlug}?ticket=${encodeURIComponent(ticket)}`;
+ return { url };
+}
+
+export async function panelConsoleUrl(
+ db: DrizzleDB,
+ input: { hackathonId: string; userId: string; email: string; name: string },
+) {
+ const web = process.env.PANEL_WEB_URL;
+ const secret = process.env.PANEL_JWT_SECRET;
+ const org = process.env.PANEL_ORG_SLUG;
+ const eventSlug = process.env.PANEL_EVENT_SLUG;
+ if (!web || !secret || !org || !eventSlug) return { url: null as string | null };
+ const [row] = await db
+ .select({ backend: hackathons.judgingBackend })
+ .from(hackathons)
+ .where(eq(hackathons.id, input.hackathonId));
+ if (row?.backend !== "panel") return { url: null };
+ const now = Math.floor(Date.now() / 1000);
+ const ticket = signHs256(
+ {
+ sub: input.userId,
+ email: input.email,
+ name: input.name,
+ org,
+ role: "organizer",
+ judge_external_id: null,
+ iat: now,
+ exp: now + 60 * 30,
+ },
+ secret,
+ );
+ const url = `${web.replace(/\/$/, "")}/o/${org}/${eventSlug}?ticket=${encodeURIComponent(ticket)}`;
+ return { url };
+}
+
+function signHs256(payload: Record, secret: string) {
+ const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
+ const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
+ const signature = createHmac("sha256", secret).update(`${header}.${body}`).digest("base64url");
+ return `${header}.${body}.${signature}`;
+}
diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json
index 0077a323..1bcd806e 100644
--- a/packages/api/tsconfig.json
+++ b/packages/api/tsconfig.json
@@ -5,5 +5,5 @@
"rootDir": "src"
},
"include": ["src", "src/.internal-tests/**/*.ts"],
- "exclude": ["node_modules", "dist"]
+ "exclude": ["node_modules", "dist", "src/routers/judge/replay.test.ts"]
}
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/pnpm-lock.yaml b/pnpm-lock.yaml
index 056a6a71..17d1b918 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,7 +26,7 @@ importers:
dependencies:
next:
specifier: 16.3.7
- version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
typescript:
specifier: ^7.0.2
version: 7.0.2
@@ -41,6 +41,34 @@ importers:
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))
+ judging/cli:
+ dependencies:
+ '@panel/db':
+ specifier: workspace:*
+ version: link:../db
+ '@panel/server':
+ specifier: workspace:*
+ version: link:../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
+
judging/core:
devDependencies:
'@query/eslint-config':
@@ -59,6 +87,129 @@ importers:
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))
+ judging/db:
+ dependencies:
+ '@panel/core':
+ specifier: workspace:*
+ version: link:../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
+
+ judging/server:
+ dependencies:
+ '@hono/node-server':
+ specifier: ^1.19.0
+ version: 1.19.17(hono@4.13.13)
+ '@panel/core':
+ specifier: workspace:*
+ version: link:../core
+ '@panel/db':
+ specifier: workspace:*
+ version: link:../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
+ tsx:
+ specifier: ^4.23.15
+ version: 4.23.15
+ ws:
+ specifier: ^8.20.1
+ version: 8.21.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
+ '@types/ws':
+ specifier: ^8.18.1
+ version: 8.18.2
+ 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))
+
+ judging/web:
+ dependencies:
+ next:
+ specifier: 16.3.7
+ version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ react:
+ specifier: 19.3.0
+ version: 19.3.0
+ react-dom:
+ specifier: 19.3.0
+ version: 19.3.0(react@19.3.0)
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.58.2
+ version: 1.64.0
+ '@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/react':
+ specifier: ^19.3.0
+ version: 19.3.0
+ '@types/react-dom':
+ specifier: ^19.3.0
+ version: 19.3.0(@types/react@19.3.0)
+ typescript:
+ specifier: 7.0.2
+ version: 7.0.2
+
packages/api:
dependencies:
'@query/auth':
@@ -75,7 +226,7 @@ importers:
version: 11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2)
'@trpc/next':
specifier: 11.19.0
- version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)
+ version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)
'@trpc/react-query':
specifier: 11.19.0
version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2)
@@ -133,7 +284,7 @@ importers:
version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9)
next-auth:
specifier: 5.0.0-beta.32
- version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0)
+ version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0)
nodemailer:
specifier: ^10.0.10
version: 10.0.10
@@ -173,7 +324,7 @@ importers:
version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9)
next-auth:
specifier: 5.0.0-beta.32
- version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0)
+ version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0)
pg:
specifier: 8.23.0
version: 8.23.0
@@ -250,7 +401,7 @@ importers:
version: 1.0.0
next:
specifier: 16.3.7
- version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
react:
specifier: 19.3.0
version: 19.3.0
@@ -329,7 +480,7 @@ importers:
version: 11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2)
'@trpc/next':
specifier: 11.19.0
- version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)
+ version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)
'@trpc/react-query':
specifier: 11.19.0
version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2)
@@ -350,7 +501,7 @@ importers:
version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9)
geist:
specifier: ^1.7.2
- version: 1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))
+ version: 1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))
lucide-react:
specifier: ^1.48.0
version: 1.48.0(react@19.3.0)
@@ -359,10 +510,10 @@ importers:
version: 10.2.6
next:
specifier: 16.3.7
- version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
next-auth:
specifier: 5.0.0-beta.32
- version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0)
+ version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
@@ -551,7 +702,7 @@ importers:
version: 4.3.3
next:
specifier: 16.3.7
- version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
postcss:
specifier: ^8.5.23
version: 8.5.28
@@ -980,6 +1131,12 @@ packages:
resolution: {integrity: sha512-7ckgSjeEprV92VrlLjpYFA3PFerflvFtF7Ea86oQXGNCprGZq5rtpAVK0zgWnCy+44mLjms42M7MiWa433gz2A==}
engines: {node: '>=22'}
+ '@hono/node-server@1.19.17':
+ resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==}
+ engines: {node: '>=18.14.1'}
+ peerDependencies:
+ hono: ^4
+
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -1180,6 +1337,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==}
@@ -1401,6 +1561,11 @@ packages:
'@pdf-lib/upng@1.0.1':
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
+ '@playwright/test@1.64.0':
+ resolution: {integrity: sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==}
+ engines: {node: '>=20'}
+ hasBin: true
+
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -2211,6 +2376,9 @@ packages:
'@types/webxr@0.5.24':
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
+ '@types/ws@8.18.2':
+ resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==}
+
'@typescript-eslint/eslint-plugin@8.70.1':
resolution: {integrity: sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2669,6 +2837,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'}
@@ -2776,6 +2948,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'}
@@ -3375,6 +3551,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==}
@@ -3429,6 +3609,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'}
@@ -4010,6 +4194,16 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
+ playwright-core@1.64.0:
+ resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ playwright@1.64.0:
+ resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
@@ -4222,6 +4416,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'}
@@ -4380,6 +4582,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==}
@@ -5210,6 +5415,10 @@ snapshots:
- encoding
- supports-color
+ '@hono/node-server@1.19.17(hono@4.13.13)':
+ dependencies:
+ hono: 4.13.13
+
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -5344,6 +5553,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
@@ -5525,6 +5736,10 @@ snapshots:
dependencies:
pako: 1.0.11
+ '@playwright/test@1.64.0':
+ dependencies:
+ playwright: 1.64.0
+
'@polka/url@1.0.0-next.29': {}
'@radix-ui/number@1.1.3': {}
@@ -6120,11 +6335,11 @@ snapshots:
'@trpc/server': 11.19.0(typescript@7.0.2)
typescript: 7.0.2
- '@trpc/next@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)':
+ '@trpc/next@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)':
dependencies:
'@trpc/client': 11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2)
'@trpc/server': 11.19.0(typescript@7.0.2)
- next: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ next: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
typescript: 7.0.2
@@ -6233,6 +6448,10 @@ snapshots:
'@types/webxr@0.5.24': {}
+ '@types/ws@8.18.2':
+ dependencies:
+ '@types/node': 22.20.4
+
'@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -6766,6 +6985,8 @@ snapshots:
clsx@2.1.1: {}
+ cluster-key-slot@1.1.1: {}
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -6860,6 +7081,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: {}
@@ -7425,9 +7648,9 @@ snapshots:
- encoding
- supports-color
- geist@1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)):
+ geist@1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)):
dependencies:
- next: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ next: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
generator-function@2.0.1: {}
@@ -7544,6 +7767,8 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
+ hono@4.13.13: {}
+
hookified@1.15.1: {}
hookified@2.2.0: {}
@@ -7598,6 +7823,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
@@ -7914,10 +8151,10 @@ snapshots:
natural-compare@1.4.0: {}
- next-auth@5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0):
+ next-auth@5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0):
dependencies:
'@auth/core': 0.41.3(nodemailer@10.0.10)
- next: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ next: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
react: 19.3.0
optionalDependencies:
nodemailer: 10.0.10
@@ -7927,7 +8164,7 @@ snapshots:
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
- next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+ next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
'@next/env': 16.3.7
'@swc/helpers': 0.5.23
@@ -7947,6 +8184,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 16.3.7
'@next/swc-win32-x64-msvc': 16.3.7
'@opentelemetry/api': 1.9.1
+ '@playwright/test': 1.64.0
babel-plugin-react-compiler: 1.0.0
sharp: 0.35.4(@types/node@22.20.4)
transitivePeerDependencies:
@@ -8126,6 +8364,12 @@ snapshots:
picomatch@4.0.7: {}
+ playwright-core@1.64.0: {}
+
+ playwright@1.64.0:
+ dependencies:
+ playwright-core: 1.64.0
+
pngjs@5.0.0: {}
possible-typed-array-names@1.1.0: {}
@@ -8275,6 +8519,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
@@ -8513,6 +8763,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..ed914c64 100644
--- a/sites/mainweb/app/(portal)/admin/judging/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx
@@ -57,6 +57,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 },
@@ -251,6 +256,11 @@ export default function AdminResultsPage() {
? "Pick a hackathon to see its results."
: "Open and close judging, watch the floor, and compare scores."}
+ {panelConsole?.url ? (
+
+ Open the panel console
+
+ ) : null}
{/* Judging Control Panel */}
diff --git a/sites/mainweb/app/(portal)/judge/page.tsx b/sites/mainweb/app/(portal)/judge/page.tsx
index 8a61fae2..383c6fdf 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/turbo.json b/turbo.json
index 9a01a7cf..0ac93324 100644
--- a/turbo.json
+++ b/turbo.json
@@ -54,6 +54,8 @@
},
"globalEnv": [
"PORT",
+ "PORT_A",
+ "PORT_B",
"DATABASE_URL",
"NEXTAUTH_SECRET",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
@@ -94,6 +96,19 @@
"NODE_ENV",
"CI",
"npm_lifecycle_event",
- "JUDGING_TEST_DATABASE_URL"
+ "JUDGING_TEST_DATABASE_URL",
+ "PANEL_DATABASE_URL",
+ "PANEL_JWT_SECRET",
+ "PANEL_JWKS_URL",
+ "PANEL_DEV_AUTH",
+ "PANEL_SMTP_URL",
+ "PANEL_SMTP_FROM",
+ "PANEL_URL",
+ "PANEL_SERVICE_TOKEN",
+ "PANEL_EVENT_ID",
+ "PANEL_WEB_URL",
+ "PANEL_ORG_SLUG",
+ "PANEL_EVENT_SLUG",
+ "REDIS_URL"
]
}
From e2d1bf585a88ba136ff6780d552bb4894ec00288 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Thu, 8 Oct 2026 13:15:48 -0400
Subject: [PATCH 3/8] fix(panel): keep the SQL migrations in the repo
The root ignore rule was hiding judging/db/migrations, and a Playwright run result had been committed with the desk.
Co-authored-by: Cursor
---
.gitignore | 2 +
judging/db/migrations/0001_init.sql | 309 ++++++++++++++++++
judging/db/migrations/0002_feedback_token.sql | 1 +
.../db/migrations/0003_shared_database.sql | 37 +++
judging/web/.gitignore | 2 +
judging/web/test-results/.last-run.json | 4 -
6 files changed, 351 insertions(+), 4 deletions(-)
create mode 100644 judging/db/migrations/0001_init.sql
create mode 100644 judging/db/migrations/0002_feedback_token.sql
create mode 100644 judging/db/migrations/0003_shared_database.sql
create mode 100644 judging/web/.gitignore
delete mode 100644 judging/web/test-results/.last-run.json
diff --git a/.gitignore b/.gitignore
index a278d653..e1b9457a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,6 +54,8 @@ next-env.d.ts
*.db
*.db-journal
migrations/
+!judging/db/migrations/
+!judging/db/migrations/**
# Editor
.vscode/*
diff --git a/judging/db/migrations/0001_init.sql b/judging/db/migrations/0001_init.sql
new file mode 100644
index 00000000..2037f72f
--- /dev/null
+++ b/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/judging/db/migrations/0002_feedback_token.sql b/judging/db/migrations/0002_feedback_token.sql
new file mode 100644
index 00000000..1fc17747
--- /dev/null
+++ b/judging/db/migrations/0002_feedback_token.sql
@@ -0,0 +1 @@
+alter table project add column feedback_token uuid unique;
diff --git a/judging/db/migrations/0003_shared_database.sql b/judging/db/migrations/0003_shared_database.sql
new file mode 100644
index 00000000..6a29b730
--- /dev/null
+++ b/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/judging/web/.gitignore b/judging/web/.gitignore
new file mode 100644
index 00000000..aaa9103e
--- /dev/null
+++ b/judging/web/.gitignore
@@ -0,0 +1,2 @@
+test-results/
+playwright-report/
diff --git a/judging/web/test-results/.last-run.json b/judging/web/test-results/.last-run.json
deleted file mode 100644
index cbcc1fba..00000000
--- a/judging/web/test-results/.last-run.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "status": "passed",
- "failedTests": []
-}
\ No newline at end of file
From bfd6d0297128d9eb8f491ab1b573cda4b30bdf8e Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Thu, 8 Oct 2026 14:07:06 -0400
Subject: [PATCH 4/8] refactor(panel): fold judging into the monorepo
Judging was a standalone product under judging/ with its own scope, licence, CI
workflow, compose file and an import boundary against @query/*. It now lives as
ordinary workspaces: packages/judging-{core,db,server,cli} and sites/judging,
scoped @query/judging-*. Lint, typecheck, test and build run in ci.yml; image
publishing on panel-v tags moved there too. The judging server joins the root
docker-compose under the "judging" profile on the shared Postgres.
packages/api now depends on @query/judging-core, so the ranking replay test
imports it by name and is back in the api tsconfig. Both pre-existing lint
failures (replay.test.ts and the web e2e spec outside tsconfig) are fixed.
---
.github/workflows/ci.yml | 30 ++
.github/workflows/panel.yml | 75 ----
.gitignore | 4 +-
PLAN.md | 98 +++--
docker-compose.yml | 23 ++
docs/judging/README.md | 55 +++
.../judging}/adr/001-standalone.md | 4 +-
.../judging}/adr/002-pure-core.md | 2 +-
.../judging}/adr/003-one-database.md | 2 +-
{judging/docs => docs/judging}/api.md | 0
docs/judging/contributing.md | 28 ++
{judging/docs => docs/judging}/deploy.md | 12 +-
{judging/docs => docs/judging}/rubric.md | 0
{judging/docs => docs/judging}/scoring.md | 0
judging/.github/ISSUE_TEMPLATE/bug.md | 11 -
judging/.github/ISSUE_TEMPLATE/feature.md | 9 -
judging/CHANGELOG.md | 8 -
judging/CONTRIBUTING.md | 28 --
judging/LICENSE | 21 --
judging/README.md | 56 ---
judging/cli/eslint.config.mjs | 27 --
judging/core/src/standalone.test.ts | 34 --
judging/db/eslint.config.mjs | 24 --
judging/infra/docker/docker-compose.yml | 30 --
judging/server/eslint.config.mjs | 28 --
judging/web/eslint.config.mjs | 23 --
package.json | 2 +-
packages/api/package.json | 1 +
packages/api/src/routers/judge/replay.test.ts | 2 +-
packages/api/tsconfig.json | 2 +-
packages/judging-cli/eslint.config.mjs | 10 +
.../cli => packages/judging-cli}/package.json | 6 +-
.../judging-cli}/src/csv.test.ts | 0
.../cli => packages/judging-cli}/src/csv.ts | 0
.../cli => packages/judging-cli}/src/main.ts | 4 +-
.../judging-cli}/src/transfer.ts | 2 +-
.../judging-cli}/tsconfig.json | 0
.../judging-core}/eslint.config.mjs | 9 +-
.../judging-core}/package.json | 2 +-
.../judging-core}/scripts/emit-extensions.mjs | 0
.../judging-core}/src/aggregate/bayes.test.ts | 0
.../judging-core}/src/aggregate/bayes.ts | 0
.../judging-core}/src/aggregate/blend.test.ts | 0
.../judging-core}/src/aggregate/blend.ts | 0
.../src/aggregate/bradleyTerry.test.ts | 0
.../src/aggregate/bradleyTerry.ts | 0
.../judging-core}/src/aggregate/rank.test.ts | 0
.../judging-core}/src/aggregate/rank.ts | 0
.../src/aggregate/zscore.test.ts | 0
.../judging-core}/src/aggregate/zscore.ts | 0
.../judging-core}/src/comparison.ts | 0
.../judging-core}/src/dispatch.sim.test.ts | 0
.../judging-core}/src/dispatch.test.ts | 0
.../judging-core}/src/dispatch.ts | 0
.../judging-core}/src/distance.test.ts | 0
.../judging-core}/src/distance.ts | 0
.../judging-core}/src/events.test.ts | 0
.../judging-core}/src/events.ts | 0
.../judging-core}/src/index.ts | 0
.../judging-core}/src/match.test.ts | 0
.../judging-core}/src/match.ts | 0
.../judging-core}/src/package.test.ts | 2 +-
.../judging-core}/src/phase.test.ts | 0
.../judging-core}/src/phase.ts | 0
.../judging-core}/src/rubric.test.ts | 0
.../judging-core}/src/rubric.ts | 0
.../judging-core}/src/tables.test.ts | 0
.../judging-core}/src/tables.ts | 0
.../judging-core}/src/timers.test.ts | 0
.../judging-core}/src/timers.ts | 0
.../judging-core}/src/uncertainty.test.ts | 0
.../judging-core}/src/uncertainty.ts | 0
.../judging-core}/tsconfig.build.json | 0
.../judging-core}/tsconfig.json | 0
.../judging-core}/vitest.config.ts | 0
packages/judging-db/eslint.config.mjs | 4 +
.../judging-db}/migrations/0001_init.sql | 0
.../migrations/0002_feedback_token.sql | 0
.../migrations/0003_shared_database.sql | 0
.../db => packages/judging-db}/package.json | 4 +-
.../db => packages/judging-db}/src/client.ts | 0
.../db => packages/judging-db}/src/env.ts | 0
.../db => packages/judging-db}/src/index.ts | 0
.../db => packages/judging-db}/src/migrate.ts | 0
.../db => packages/judging-db}/src/schema.ts | 0
.../db => packages/judging-db}/src/seed.ts | 2 +-
.../db => packages/judging-db}/tsconfig.json | 0
.../judging-server}/Dockerfile | 5 +-
packages/judging-server/eslint.config.mjs | 10 +
.../judging-server}/package.json | 6 +-
.../judging-server}/src/app.test.ts | 0
.../judging-server}/src/app.ts | 4 +-
.../judging-server}/src/auth.test.ts | 0
.../judging-server}/src/auth.ts | 0
.../judging-server}/src/bus.test.ts | 0
.../judging-server}/src/bus.ts | 0
.../judging-server}/src/cross-server.check.ts | 2 +-
.../judging-server}/src/env.ts | 0
.../judging-server}/src/hub.ts | 0
.../judging-server}/src/index.ts | 4 +-
.../judging-server}/src/log.ts | 0
.../judging-server}/src/mail.ts | 0
.../judging-server}/src/metrics.ts | 0
.../judging-server}/src/router.ts | 6 +-
.../judging-server}/src/services/arrive.ts | 6 +-
.../judging-server}/src/services/catalog.ts | 4 +-
.../judging-server}/src/services/compare.ts | 6 +-
.../src/services/dispatch.concurrent.test.ts | 4 +-
.../judging-server}/src/services/dispatch.ts | 8 +-
.../src/services/feedback.test.ts | 0
.../judging-server}/src/services/feedback.ts | 0
.../src/services/floor.test.ts | 2 +-
.../judging-server}/src/services/floor.ts | 8 +-
.../src/services/flow.integration.test.ts | 4 +-
.../judging-server}/src/services/live.ts | 4 +-
.../judging-server}/src/services/logs.test.ts | 0
.../judging-server}/src/services/logs.ts | 4 +-
.../judging-server}/src/services/outbox.ts | 4 +-
.../src/services/overtime.test.ts | 2 +-
.../judging-server}/src/services/overtime.ts | 8 +-
.../judging-server}/src/services/records.ts | 6 +-
.../src/services/redis.integration.test.ts | 0
.../src/services/results.test.ts | 0
.../judging-server}/src/services/results.ts | 6 +-
.../judging-server}/src/services/vote.test.ts | 2 +-
.../judging-server}/src/services/vote.ts | 8 +-
.../judging-server/terraform}/main.tf | 0
.../judging-server}/tsconfig.json | 0
.../judging-server}/vitest.config.ts | 0
pnpm-lock.yaml | 341 +++++++++---------
pnpm-workspace.yaml | 1 -
{judging/web => sites/judging}/.gitignore | 0
.../judging/Dockerfile | 13 +-
.../app/e/[orgSlug]/[eventSlug]/board.tsx | 0
.../[eventSlug]/feedback/[token]/page.tsx | 0
.../app/e/[orgSlug]/[eventSlug]/page.tsx | 0
.../app/j/[orgSlug]/[eventSlug]/desk.tsx | 0
.../app/j/[orgSlug]/[eventSlug]/page.tsx | 0
{judging/web => sites/judging}/app/layout.tsx | 0
.../app/o/[orgSlug]/[eventSlug]/console.tsx | 0
.../app/o/[orgSlug]/[eventSlug]/editors.tsx | 0
.../app/o/[orgSlug]/[eventSlug]/page.tsx | 0
{judging/web => sites/judging}/app/page.tsx | 0
.../web => sites/judging}/e2e/visit.spec.ts | 0
sites/judging/eslint.config.mjs | 8 +
{judging/web => sites/judging}/lib/api.ts | 0
{judging/web => sites/judging}/lib/held.ts | 0
{judging/web => sites/judging}/next.config.ts | 0
{judging/web => sites/judging}/package.json | 2 +-
.../judging}/playwright.config.ts | 0
.../judging}/public/manifest.webmanifest | 0
{judging/web => sites/judging}/tsconfig.json | 0
152 files changed, 477 insertions(+), 695 deletions(-)
delete mode 100644 .github/workflows/panel.yml
create mode 100644 docs/judging/README.md
rename {judging/docs => docs/judging}/adr/001-standalone.md (79%)
rename {judging/docs => docs/judging}/adr/002-pure-core.md (90%)
rename {judging/docs => docs/judging}/adr/003-one-database.md (91%)
rename {judging/docs => docs/judging}/api.md (100%)
create mode 100644 docs/judging/contributing.md
rename {judging/docs => docs/judging}/deploy.md (58%)
rename {judging/docs => docs/judging}/rubric.md (100%)
rename {judging/docs => docs/judging}/scoring.md (100%)
delete mode 100644 judging/.github/ISSUE_TEMPLATE/bug.md
delete mode 100644 judging/.github/ISSUE_TEMPLATE/feature.md
delete mode 100644 judging/CHANGELOG.md
delete mode 100644 judging/CONTRIBUTING.md
delete mode 100644 judging/LICENSE
delete mode 100644 judging/README.md
delete mode 100644 judging/cli/eslint.config.mjs
delete mode 100644 judging/core/src/standalone.test.ts
delete mode 100644 judging/db/eslint.config.mjs
delete mode 100644 judging/infra/docker/docker-compose.yml
delete mode 100644 judging/server/eslint.config.mjs
delete mode 100644 judging/web/eslint.config.mjs
create mode 100644 packages/judging-cli/eslint.config.mjs
rename {judging/cli => packages/judging-cli}/package.json (81%)
rename {judging/cli => packages/judging-cli}/src/csv.test.ts (100%)
rename {judging/cli => packages/judging-cli}/src/csv.ts (100%)
rename {judging/cli => packages/judging-cli}/src/main.ts (98%)
rename {judging/cli => packages/judging-cli}/src/transfer.ts (98%)
rename {judging/cli => packages/judging-cli}/tsconfig.json (100%)
rename {judging/core => packages/judging-core}/eslint.config.mjs (68%)
rename {judging/core => packages/judging-core}/package.json (96%)
rename {judging/core => packages/judging-core}/scripts/emit-extensions.mjs (100%)
rename {judging/core => packages/judging-core}/src/aggregate/bayes.test.ts (100%)
rename {judging/core => packages/judging-core}/src/aggregate/bayes.ts (100%)
rename {judging/core => packages/judging-core}/src/aggregate/blend.test.ts (100%)
rename {judging/core => packages/judging-core}/src/aggregate/blend.ts (100%)
rename {judging/core => packages/judging-core}/src/aggregate/bradleyTerry.test.ts (100%)
rename {judging/core => packages/judging-core}/src/aggregate/bradleyTerry.ts (100%)
rename {judging/core => packages/judging-core}/src/aggregate/rank.test.ts (100%)
rename {judging/core => packages/judging-core}/src/aggregate/rank.ts (100%)
rename {judging/core => packages/judging-core}/src/aggregate/zscore.test.ts (100%)
rename {judging/core => packages/judging-core}/src/aggregate/zscore.ts (100%)
rename {judging/core => packages/judging-core}/src/comparison.ts (100%)
rename {judging/core => packages/judging-core}/src/dispatch.sim.test.ts (100%)
rename {judging/core => packages/judging-core}/src/dispatch.test.ts (100%)
rename {judging/core => packages/judging-core}/src/dispatch.ts (100%)
rename {judging/core => packages/judging-core}/src/distance.test.ts (100%)
rename {judging/core => packages/judging-core}/src/distance.ts (100%)
rename {judging/core => packages/judging-core}/src/events.test.ts (100%)
rename {judging/core => packages/judging-core}/src/events.ts (100%)
rename {judging/core => packages/judging-core}/src/index.ts (100%)
rename {judging/core => packages/judging-core}/src/match.test.ts (100%)
rename {judging/core => packages/judging-core}/src/match.ts (100%)
rename {judging/core => packages/judging-core}/src/package.test.ts (90%)
rename {judging/core => packages/judging-core}/src/phase.test.ts (100%)
rename {judging/core => packages/judging-core}/src/phase.ts (100%)
rename {judging/core => packages/judging-core}/src/rubric.test.ts (100%)
rename {judging/core => packages/judging-core}/src/rubric.ts (100%)
rename {judging/core => packages/judging-core}/src/tables.test.ts (100%)
rename {judging/core => packages/judging-core}/src/tables.ts (100%)
rename {judging/core => packages/judging-core}/src/timers.test.ts (100%)
rename {judging/core => packages/judging-core}/src/timers.ts (100%)
rename {judging/core => packages/judging-core}/src/uncertainty.test.ts (100%)
rename {judging/core => packages/judging-core}/src/uncertainty.ts (100%)
rename {judging/core => packages/judging-core}/tsconfig.build.json (100%)
rename {judging/core => packages/judging-core}/tsconfig.json (100%)
rename {judging/core => packages/judging-core}/vitest.config.ts (100%)
create mode 100644 packages/judging-db/eslint.config.mjs
rename {judging/db => packages/judging-db}/migrations/0001_init.sql (100%)
rename {judging/db => packages/judging-db}/migrations/0002_feedback_token.sql (100%)
rename {judging/db => packages/judging-db}/migrations/0003_shared_database.sql (100%)
rename {judging/db => packages/judging-db}/package.json (86%)
rename {judging/db => packages/judging-db}/src/client.ts (100%)
rename {judging/db => packages/judging-db}/src/env.ts (100%)
rename {judging/db => packages/judging-db}/src/index.ts (100%)
rename {judging/db => packages/judging-db}/src/migrate.ts (100%)
rename {judging/db => packages/judging-db}/src/schema.ts (100%)
rename {judging/db => packages/judging-db}/src/seed.ts (99%)
rename {judging/db => packages/judging-db}/tsconfig.json (100%)
rename {judging/infra/docker => packages/judging-server}/Dockerfile (60%)
create mode 100644 packages/judging-server/eslint.config.mjs
rename {judging/server => packages/judging-server}/package.json (88%)
rename {judging/server => packages/judging-server}/src/app.test.ts (100%)
rename {judging/server => packages/judging-server}/src/app.ts (99%)
rename {judging/server => packages/judging-server}/src/auth.test.ts (100%)
rename {judging/server => packages/judging-server}/src/auth.ts (100%)
rename {judging/server => packages/judging-server}/src/bus.test.ts (100%)
rename {judging/server => packages/judging-server}/src/bus.ts (100%)
rename {judging/server => packages/judging-server}/src/cross-server.check.ts (97%)
rename {judging/server => packages/judging-server}/src/env.ts (100%)
rename {judging/server => packages/judging-server}/src/hub.ts (100%)
rename {judging/server => packages/judging-server}/src/index.ts (95%)
rename {judging/server => packages/judging-server}/src/log.ts (100%)
rename {judging/server => packages/judging-server}/src/mail.ts (100%)
rename {judging/server => packages/judging-server}/src/metrics.ts (100%)
rename {judging/server => packages/judging-server}/src/router.ts (99%)
rename {judging/server => packages/judging-server}/src/services/arrive.ts (92%)
rename {judging/server => packages/judging-server}/src/services/catalog.ts (99%)
rename {judging/server => packages/judging-server}/src/services/compare.ts (90%)
rename {judging/server => packages/judging-server}/src/services/dispatch.concurrent.test.ts (88%)
rename {judging/server => packages/judging-server}/src/services/dispatch.ts (98%)
rename {judging/server => packages/judging-server}/src/services/feedback.test.ts (100%)
rename {judging/server => packages/judging-server}/src/services/feedback.ts (100%)
rename {judging/server => packages/judging-server}/src/services/floor.test.ts (94%)
rename {judging/server => packages/judging-server}/src/services/floor.ts (91%)
rename {judging/server => packages/judging-server}/src/services/flow.integration.test.ts (97%)
rename {judging/server => packages/judging-server}/src/services/live.ts (96%)
rename {judging/server => packages/judging-server}/src/services/logs.test.ts (100%)
rename {judging/server => packages/judging-server}/src/services/logs.ts (85%)
rename {judging/server => packages/judging-server}/src/services/outbox.ts (93%)
rename {judging/server => packages/judging-server}/src/services/overtime.test.ts (93%)
rename {judging/server => packages/judging-server}/src/services/overtime.ts (87%)
rename {judging/server => packages/judging-server}/src/services/records.ts (98%)
rename {judging/server => packages/judging-server}/src/services/redis.integration.test.ts (100%)
rename {judging/server => packages/judging-server}/src/services/results.test.ts (100%)
rename {judging/server => packages/judging-server}/src/services/results.ts (98%)
rename {judging/server => packages/judging-server}/src/services/vote.test.ts (96%)
rename {judging/server => packages/judging-server}/src/services/vote.ts (97%)
rename {judging/infra/gcp => packages/judging-server/terraform}/main.tf (100%)
rename {judging/server => packages/judging-server}/tsconfig.json (100%)
rename {judging/server => packages/judging-server}/vitest.config.ts (100%)
rename {judging/web => sites/judging}/.gitignore (100%)
rename judging/infra/docker/Dockerfile.web => sites/judging/Dockerfile (54%)
rename {judging/web => sites/judging}/app/e/[orgSlug]/[eventSlug]/board.tsx (100%)
rename {judging/web => sites/judging}/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx (100%)
rename {judging/web => sites/judging}/app/e/[orgSlug]/[eventSlug]/page.tsx (100%)
rename {judging/web => sites/judging}/app/j/[orgSlug]/[eventSlug]/desk.tsx (100%)
rename {judging/web => sites/judging}/app/j/[orgSlug]/[eventSlug]/page.tsx (100%)
rename {judging/web => sites/judging}/app/layout.tsx (100%)
rename {judging/web => sites/judging}/app/o/[orgSlug]/[eventSlug]/console.tsx (100%)
rename {judging/web => sites/judging}/app/o/[orgSlug]/[eventSlug]/editors.tsx (100%)
rename {judging/web => sites/judging}/app/o/[orgSlug]/[eventSlug]/page.tsx (100%)
rename {judging/web => sites/judging}/app/page.tsx (100%)
rename {judging/web => sites/judging}/e2e/visit.spec.ts (100%)
create mode 100644 sites/judging/eslint.config.mjs
rename {judging/web => sites/judging}/lib/api.ts (100%)
rename {judging/web => sites/judging}/lib/held.ts (100%)
rename {judging/web => sites/judging}/next.config.ts (100%)
rename {judging/web => sites/judging}/package.json (94%)
rename {judging/web => sites/judging}/playwright.config.ts (100%)
rename {judging/web => sites/judging}/public/manifest.webmanifest (100%)
rename {judging/web => sites/judging}/tsconfig.json (100%)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d0d58879..899084da 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,6 +7,7 @@ name: CI/CD
on:
push:
branches: ["main", "dev"]
+ tags: ["panel-v*"]
pull_request:
concurrency:
@@ -107,3 +108,32 @@ jobs:
projectId: dsgt-website
channelId: live
entryPoint: "."
+
+ publish-judging:
+ name: Publish judging images
+ needs: verify
+ if: startsWith(github.ref, 'refs/tags/panel-v')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@v6
+ - uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: packages/judging-server/Dockerfile
+ push: true
+ tags: ghcr.io/${{ github.repository }}/panel-server:${{ github.ref_name }}
+ - uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: sites/judging/Dockerfile
+ push: true
+ build-args: NEXT_PUBLIC_PANEL_URL=${{ vars.NEXT_PUBLIC_PANEL_URL || 'http://localhost:8787' }}
+ tags: ghcr.io/${{ github.repository }}/panel-web:${{ github.ref_name }}
diff --git a/.github/workflows/panel.yml b/.github/workflows/panel.yml
deleted file mode 100644
index 69d4aed6..00000000
--- a/.github/workflows/panel.yml
+++ /dev/null
@@ -1,75 +0,0 @@
-name: panel
-
-on:
- push:
- paths:
- - "judging/**"
- - ".github/workflows/panel.yml"
- pull_request:
- paths:
- - "judging/**"
- - ".github/workflows/panel.yml"
-
-jobs:
- verify:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v6
- - uses: pnpm/action-setup@v5
- - uses: actions/setup-node@v6
- with:
- node-version-file: .nvmrc
- cache: pnpm
- - run: pnpm install --frozen-lockfile
- - run: pnpm --filter @panel/core --filter @panel/db --filter @panel/server --filter @panel/cli --filter @panel/web lint
- - run: pnpm --filter @panel/core --filter @panel/db --filter @panel/server --filter @panel/cli --filter @panel/web typecheck
- - run: pnpm --filter @panel/core build
- - run: pnpm --filter @panel/web build
- - run: pnpm --filter @panel/core --filter @panel/server --filter @panel/cli test
-
- publish:
- if: startsWith(github.ref, 'refs/tags/panel-v')
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- steps:
- - uses: actions/checkout@v6
- - uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
- - uses: docker/build-push-action@v6
- with:
- context: .
- file: judging/infra/docker/Dockerfile
- push: true
- tags: ghcr.io/${{ github.repository }}/panel-server:${{ github.ref_name }}
- - uses: docker/build-push-action@v6
- with:
- context: .
- file: judging/infra/docker/Dockerfile.web
- push: true
- build-args: NEXT_PUBLIC_PANEL_URL=${{ vars.NEXT_PUBLIC_PANEL_URL || 'http://localhost:8787' }}
- tags: ghcr.io/${{ github.repository }}/panel-web:${{ github.ref_name }}
-
- release-core:
- if: startsWith(github.ref, 'refs/tags/panel-v')
- runs-on: ubuntu-latest
- permissions:
- contents: read
- steps:
- - uses: actions/checkout@v6
- - uses: pnpm/action-setup@v5
- - uses: actions/setup-node@v6
- with:
- node-version-file: .nvmrc
- cache: pnpm
- registry-url: https://registry.npmjs.org
- - run: pnpm install --frozen-lockfile
- - run: pnpm --filter @panel/core build
- - working-directory: judging/core
- run: npm pkg set private=false && npm publish --access public
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.gitignore b/.gitignore
index e1b9457a..cbdd6f3f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,8 +54,8 @@ next-env.d.ts
*.db
*.db-journal
migrations/
-!judging/db/migrations/
-!judging/db/migrations/**
+!packages/judging-db/migrations/
+!packages/judging-db/migrations/**
# Editor
.vscode/*
diff --git a/PLAN.md b/PLAN.md
index 00cca049..ba92e79f 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -2,11 +2,9 @@
**Repo:** `query` · **Drafted:** 2026-10-07 · **Working name:** `panel` (rename freely)
-A standalone hackathon judging platform, built inside this monorepo under
-`judging/`, with no knowledge of DSGT in it. Hacklytics is its first user
-through an adapter. When it is stable it leaves this repo with
-`git subtree split --prefix=judging` and ships as an open-source project with
-its own licence, docs, CI and Docker image.
+A hackathon judging platform built as ordinary workspaces in this monorepo
+(`packages/judging-*`, `sites/judging`, scope `@query/judging-*`). 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.
@@ -42,37 +40,30 @@ per-instance and polling for every live view.
## 1. Layout
```
-judging/
- core/ @panel/core pure TS, zero runtime deps. rubric validation,
- dispatch, distance, uncertainty, aggregators,
- phase state machine, timers
- db/ @panel/db Drizzle schema, SQL migrations (generated,
- committed), seed, demo data
- server/ @panel/server Node service: tRPC over HTTP, OpenAPI mirror,
- WebSocket hub, auth adapters, outbox + webhooks,
- Prometheus metrics
- web/ @panel/web Next.js: judge PWA, admin console, live board,
- public leaderboard, team feedback
- cli/ @panel/cli import/export/seed/migrate/doctor
- infra/
- docker/ Dockerfiles, docker-compose.yml (full stack)
- gcp/ optional Terraform module (Cloud Run, Memorystore,
- Secret Manager, Monitoring)
- docs/ architecture, ADRs, deploy, API, rubric guide
- README.md LICENSE (MIT) CONTRIBUTING.md CHANGELOG.md
+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. Dockerfile and
+ optional Terraform (terraform/)
+packages/judging-cli/ @query/judging-cli import/export/seed/migrate/doctor
+sites/judging/ @query/judging-web Next.js: judge PWA, admin console,
+ live board, public leaderboard,
+ team feedback. Dockerfile
+docs/judging/ ADRs, deploy, API, rubric guide
```
-Registered as workspaces in `pnpm-workspace.yaml`. Internal imports are
-`@panel/*` only; nothing under `judging/` imports from `packages/*` or
-`sites/*`. A lint rule (`no-restricted-imports`) enforces the boundary, which is
-what makes the later extraction a one-command job.
-
-The reverse direction, `packages/api` importing `@panel/*`, is allowed and is
-how the club site integrates (§7).
+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 (`@panel/db`)
+## 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.
@@ -150,13 +141,13 @@ Design notes:
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 `judging/db/migrations`,
+- 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 (`@panel/core`)
+## 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.
@@ -185,7 +176,7 @@ export.
---
-## 4. Server (`@panel/server`)
+## 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
@@ -256,7 +247,7 @@ from `../PLAN.md` W8.
---
-## 5. Web (`@panel/web`)
+## 5. Web (`@query/judging-web`)
Next.js app, standalone output, one Docker image. Branding from
`organization.branding`; no hardcoded name or colours.
@@ -283,7 +274,7 @@ Next.js app, standalone output, one Docker image. Branding from
---
-## 6. CLI (`@panel/cli`)
+## 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
@@ -302,7 +293,7 @@ The club adapter does not use it; it imports through the API.
Thin and replaceable.
- `packages/api/src/routers/judging.ts` (new, small): on `promoteSubmissions`
- calls `@panel/server` `project.upsert` with `external_id = hackathon_project.id`;
+ 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.
@@ -311,10 +302,10 @@ Thin and replaceable.
- 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: `@panel/server` and `@panel/web` as two Cloud Run services in the
+- Deploy: `@query/judging-server` and `@query/judging-web` as two Cloud Run services in the
same GCP project, Postgres as a second Neon database (not a schema in the
club DB, so the extraction is clean), Memorystore Redis only if more than
- one server instance is wanted. Terraform under `judging/infra/gcp`.
+ one server instance is wanted. Terraform under `packages/judging-server/terraform`.
---
@@ -328,14 +319,14 @@ Each phase ships green on `pnpm test`, `lint --max-warnings 0`, `typecheck`,
- 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 `@panel/core` as parametrised
+ `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:** `@panel/core` has no dependencies and 100% of exported functions
+**Verify:** `@query/judging-core` has no dependencies and 100% of exported functions
have a test. The legacy `rankings.ts` output on the seed equals
-`@panel/core` `rank()` output byte for byte at `pairwise_weight = 0`.
+`@query/judging-core` `rank()` output byte for byte at `pairwise_weight = 0`.
### Phase 1 — Data and server (2 weeks)
@@ -398,19 +389,18 @@ before publish and for the wrong token.
**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 — Open-source release (1 week)
+### Phase 6 — Cleanup (1 week)
-- `CONTRIBUTING.md`, issue templates, GitHub Actions (test, lint, typecheck,
- build, Docker publish to GHCR, `@panel/core` to npm on tag).
-- Docs: quick start (`docker compose up`, `panel seed demo`), deploy guide,
- API reference from OpenAPI, rubric design guide, scoring explainer.
-- `git subtree split --prefix=judging -b panel-main`, push to the new repo,
- replace `judging/` here with a git submodule or a pinned npm dependency.
+- 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 of the new repo runs the demo in under five
-minutes with nothing but Docker. No string "DSGT", "Hacklytics" or
-"datasciencegt" anywhere under `judging/`.
+**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.
---
@@ -418,8 +408,8 @@ minutes with nothing but Docker. No string "DSGT", "Hacklytics" or
**Taken**
-- Standalone product in `judging/`, extracted later by subtree. Not a schema
- inside the club database, so the extraction has no data entanglement.
+- 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.
diff --git a/docker-compose.yml b/docker-compose.yml
index 8feef538..32676ea2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -116,6 +116,29 @@ services:
timeout: 5s
retries: 10
+ # ── Judging ──────────────────────────────────────────────────────────────
+ #
+ # The judging server on the same Postgres as the club site:
+ #
+ # docker compose --profile judging up -d --build
+
+ judging-server:
+ build:
+ context: .
+ dockerfile: packages/judging-server/Dockerfile
+ container_name: monorepo-judging-server
+ profiles: ["judging"]
+ environment:
+ DATABASE_URL: postgresql://postgres:postgres@db:5432/neondb
+ PANEL_JWT_SECRET: dev-only-change-me
+ PANEL_DEV_AUTH: "1"
+ PORT: "8787"
+ ports:
+ - "8787:8787"
+ depends_on:
+ db:
+ condition: service_healthy
+
volumes:
pgdata:
promdata:
diff --git a/docs/judging/README.md b/docs/judging/README.md
new file mode 100644
index 00000000..613eb7b7
--- /dev/null
+++ b/docs/judging/README.md
@@ -0,0 +1,55 @@
+# Panel
+
+A hackathon judging product: its own data, its own API, its own web app.
+Rubrics, tracks, timers, rooms, and dispatch strategy are rows, not code.
+Postgres and Node are enough to run it. Set `REDIS_URL` when more than one
+server process should share live updates. Without it, each process fans out
+in memory, and a client that misses a message reloads the same state from
+the next snapshot.
+
+Panel is part of this monorepo. Its packages are ordinary workspaces next to
+the club's, and `packages/api` calls `@query/judging-core` directly.
+
+## 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` | HTTP API: dispatch, votes, health, metrics. |
+| `sites/judging/` | `@query/judging-web` | Judge desk, organizer console, public board. |
+| `packages/judging-cli/` | `@query/judging-cli` | `panel migrate`, `panel seed demo`, `panel doctor`. |
+
+## Run the core
+
+From the repository root:
+
+```
+pnpm --filter @query/judging-core test
+pnpm --filter @query/judging-core typecheck
+pnpm --filter @query/judging-core lint
+```
+
+`pnpm test` includes `@query/judging-core` and `@query/judging-server`.
+
+## Database and server
+
+Postgres is the one database the rest of the monorepo uses (`DATABASE_URL`). Panel migrations add its tables there. `PANEL_DATABASE_URL` is only a fallback when `DATABASE_URL` is unset.
+
+```
+docker compose --profile judging up -d --build
+```
+
+On the host, pointed at that database:
+
+```
+$env:DATABASE_URL = "postgresql://postgres:postgres@localhost:5433/neondb"
+$env:PANEL_JWT_SECRET = "dev-only-change-me"
+pnpm --filter @query/judging-cli panel migrate
+pnpm --filter @query/judging-cli panel seed demo
+pnpm --filter @query/judging-cli panel doctor
+```
+
+`panel doctor` checks Postgres, pending migrations, and `PANEL_JWT_SECRET`. Redis is reported and not required.
+
+The HTTP server is Hono. tRPC is mounted at `/trpc`. `GET /healthz` is the process, `GET /readyz` is Postgres, `GET /metrics` is Prometheus. `GET /openapi.json` lists the REST routes. How to authenticate is in `docs/judging/api.md`. Magic-link codes are returned in the response only when `PANEL_DEV_AUTH=1`, which is how the compose stack signs in without SMTP.
diff --git a/judging/docs/adr/001-standalone.md b/docs/judging/adr/001-standalone.md
similarity index 79%
rename from judging/docs/adr/001-standalone.md
rename to docs/judging/adr/001-standalone.md
index dd621080..f463c613 100644
--- a/judging/docs/adr/001-standalone.md
+++ b/docs/judging/adr/001-standalone.md
@@ -2,7 +2,9 @@
## Status
-Accepted
+Superseded. Panel's packages are ordinary monorepo workspaces
+(`packages/judging-*`, `sites/judging`) under the `@query/*` scope. The import
+boundary and the plan to split it out were dropped.
## Decision
diff --git a/judging/docs/adr/002-pure-core.md b/docs/judging/adr/002-pure-core.md
similarity index 90%
rename from judging/docs/adr/002-pure-core.md
rename to docs/judging/adr/002-pure-core.md
index 1421615c..f5bc4aa5 100644
--- a/judging/docs/adr/002-pure-core.md
+++ b/docs/judging/adr/002-pure-core.md
@@ -8,7 +8,7 @@ Accepted
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 `@panel/core`. Those functions take plain data, including `now` and a
+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.
diff --git a/judging/docs/adr/003-one-database.md b/docs/judging/adr/003-one-database.md
similarity index 91%
rename from judging/docs/adr/003-one-database.md
rename to docs/judging/adr/003-one-database.md
index 90a8dc72..aa2d80d5 100644
--- a/judging/docs/adr/003-one-database.md
+++ b/docs/judging/adr/003-one-database.md
@@ -22,5 +22,5 @@ judge UI, wait until an event has run on panel. Sign-in mail waits on
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 `@panel/core` `rank()` at pairwise
+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/judging/docs/api.md b/docs/judging/api.md
similarity index 100%
rename from judging/docs/api.md
rename to docs/judging/api.md
diff --git a/docs/judging/contributing.md b/docs/judging/contributing.md
new file mode 100644
index 00000000..3e8919c1
--- /dev/null
+++ b/docs/judging/contributing.md
@@ -0,0 +1,28 @@
+# Contributing
+
+Panel is `packages/judging-*` and `sites/judging`. `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 @query/judging-web lint
+pnpm --filter @query/judging-db typecheck
+pnpm --filter @query/judging-server typecheck
+pnpm --filter @query/judging-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/judging/docs/deploy.md b/docs/judging/deploy.md
similarity index 58%
rename from judging/docs/deploy.md
rename to docs/judging/deploy.md
index 5b28feed..706406e2 100644
--- a/judging/docs/deploy.md
+++ b/docs/judging/deploy.md
@@ -3,22 +3,22 @@
Docker Compose is the deploy to start from. From the repository root:
```
-docker compose -f judging/infra/docker/docker-compose.yml up --build
+docker compose --profile judging up -d --build
```
Then, against the published Postgres port:
```
-DATABASE_URL=postgres://panel:panel@localhost:54329/panel
+DATABASE_URL=postgresql://postgres:postgres@localhost:5433/neondb
PANEL_JWT_SECRET=dev-only-change-me
-pnpm --filter @panel/cli panel migrate
-pnpm --filter @panel/cli panel seed demo
-pnpm --filter @panel/cli panel doctor
+pnpm --filter @query/judging-cli panel migrate
+pnpm --filter @query/judging-cli panel seed demo
+pnpm --filter @query/judging-cli panel doctor
```
`panel doctor` must report postgres, migrations, and jwt as ok. Set `DATABASE_URL` to the same Postgres the club app uses. `PANEL_DATABASE_URL` is only read when `DATABASE_URL` is unset. The stack is one API server. Leave `REDIS_URL` unset so live updates stay in that process. Clients that miss a socket message recover from `GET /v1/live`.
-The optional GCP module is `judging/infra/gcp`. It is not required to run the stack. The web image is `judging/infra/docker/Dockerfile.web`. Pass `NEXT_PUBLIC_PANEL_URL` at build time so the desk calls the API.
+The optional GCP module is `packages/judging-server/terraform`. It is not required to run the stack. The server image is `packages/judging-server/Dockerfile` and the web image is `sites/judging/Dockerfile`. Pass `NEXT_PUBLIC_PANEL_URL` at build time so the desk calls the API.
Set `PANEL_SMTP_URL` to an SMTP URL when sign-in codes should be emailed.
Without it, `PANEL_DEV_AUTH=1` returns the code in the response. OIDC waits
diff --git a/judging/docs/rubric.md b/docs/judging/rubric.md
similarity index 100%
rename from judging/docs/rubric.md
rename to docs/judging/rubric.md
diff --git a/judging/docs/scoring.md b/docs/judging/scoring.md
similarity index 100%
rename from judging/docs/scoring.md
rename to docs/judging/scoring.md
diff --git a/judging/.github/ISSUE_TEMPLATE/bug.md b/judging/.github/ISSUE_TEMPLATE/bug.md
deleted file mode 100644
index 6da6dc23..00000000
--- a/judging/.github/ISSUE_TEMPLATE/bug.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-name: Bug
-description: Something in panel behaved wrongly
-labels: bug
----
-
-## What happened
-
-## What you expected
-
-## How to reproduce
diff --git a/judging/.github/ISSUE_TEMPLATE/feature.md b/judging/.github/ISSUE_TEMPLATE/feature.md
deleted file mode 100644
index 41836efd..00000000
--- a/judging/.github/ISSUE_TEMPLATE/feature.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-name: Feature
-description: A change to how panel runs an event
-labels: enhancement
----
-
-## What should change
-
-## Who it is for
diff --git a/judging/CHANGELOG.md b/judging/CHANGELOG.md
deleted file mode 100644
index 2f5090c7..00000000
--- a/judging/CHANGELOG.md
+++ /dev/null
@@ -1,8 +0,0 @@
-# Panel
-
-## 0.0.0
-
-- Pure core for dispatch, rubrics, phases, and ranking.
-- Postgres schema with committed SQL migrations, a demo seed, and `panel` commands for migrate, seed, import, export, and doctor.
-- HTTP server for judge sessions, results, health, metrics, and a websocket bus.
-- Judge desk, organizer console, public board, and a feedback link.
diff --git a/judging/CONTRIBUTING.md b/judging/CONTRIBUTING.md
deleted file mode 100644
index 6df5c2bf..00000000
--- a/judging/CONTRIBUTING.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# Contributing
-
-Panel is the tree under `judging/`. It does not import the rest of this repository.
-
-## Checks
-
-From the repository root:
-
-```
-pnpm --filter @panel/core test
-pnpm --filter @panel/server test
-pnpm --filter @panel/cli test
-pnpm --filter @panel/core lint
-pnpm --filter @panel/server lint
-pnpm --filter @panel/web lint
-pnpm --filter @panel/db typecheck
-pnpm --filter @panel/server typecheck
-pnpm --filter @panel/web typecheck
-pnpm --filter @panel/cli typecheck
-```
-
-## Migrations
-
-Schema changes are SQL files in `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/judging/LICENSE b/judging/LICENSE
deleted file mode 100644
index 354a5c40..00000000
--- a/judging/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2026 Panel contributors
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/judging/README.md b/judging/README.md
deleted file mode 100644
index 1a23e22f..00000000
--- a/judging/README.md
+++ /dev/null
@@ -1,56 +0,0 @@
-# Panel
-
-A hackathon judging product: its own data, its own API, its own web app.
-Rubrics, tracks, timers, rooms, and dispatch strategy are rows, not code.
-Postgres and Node are enough to run it. Set `REDIS_URL` when more than one
-server process should share live updates. Without it, each process fans out
-in memory, and a client that misses a message reloads the same state from
-the next snapshot.
-
-`judging/` does not import the host application. The host may call `@panel/*`.
-When the product is stable it leaves this repository with
-`git subtree split --prefix=judging`.
-
-## Layout
-
-| Path | Package | What it is |
-| --- | --- | --- |
-| `core/` | `@panel/core` | Pure functions. No I/O, no clock, no runtime dependencies. |
-| `db/` | `@panel/db` | Schema, committed SQL migrations, demo seed. |
-| `server/` | `@panel/server` | HTTP API: dispatch, votes, health, metrics. |
-| `web/` | `@panel/web` | Judge desk, organizer console, public board. |
-| `cli/` | `@panel/cli` | `panel migrate`, `panel seed demo`, `panel doctor`. |
-
-## Run the core
-
-From the repository root:
-
-```
-pnpm --filter @panel/core test
-pnpm --filter @panel/core typecheck
-pnpm --filter @panel/core lint
-```
-
-`pnpm test` includes `@panel/core` and `@panel/server`.
-
-## Database and server
-
-Postgres is the one database the rest of the monorepo uses (`DATABASE_URL`). Panel migrations add its tables there. `PANEL_DATABASE_URL` is only a fallback when `DATABASE_URL` is unset.
-
-```
-docker compose -f judging/infra/docker/docker-compose.yml up --build
-```
-
-On the host, pointed at that database:
-
-```
-$env:PANEL_DATABASE_URL = "postgres://panel:panel@localhost:54329/panel"
-$env:PANEL_JWT_SECRET = "dev-only-change-me"
-pnpm --filter @panel/cli panel migrate
-pnpm --filter @panel/cli panel seed demo
-pnpm --filter @panel/cli panel doctor
-```
-
-`panel doctor` checks Postgres, pending migrations, and `PANEL_JWT_SECRET`. Redis is reported and not required.
-
-The HTTP server is Hono. tRPC is mounted at `/trpc`. `GET /healthz` is the process, `GET /readyz` is Postgres, `GET /metrics` is Prometheus. `GET /openapi.json` lists the REST routes. How to authenticate is in `docs/api.md`. Magic-link codes are returned in the response only when `PANEL_DEV_AUTH=1`, which is how the compose stack signs in without SMTP.
diff --git a/judging/cli/eslint.config.mjs b/judging/cli/eslint.config.mjs
deleted file mode 100644
index a96e7a28..00000000
--- a/judging/cli/eslint.config.mjs
+++ /dev/null
@@ -1,27 +0,0 @@
-import { config } from "@query/eslint-config/base";
-
-/** @type {import("eslint").Linter.Config[]} */
-export default [
- ...config,
- {
- files: ["src/**/*.ts"],
- rules: { "no-console": "off" },
- },
- {
- files: ["src/**/*.ts"],
- rules: {
- "no-restricted-imports": [
- "error",
- {
- patterns: [
- {
- group: ["@query", "@query/*"],
- message:
- "judging/ is a standalone product. Import from @panel/* only.",
- },
- ],
- },
- ],
- },
- },
-];
diff --git a/judging/core/src/standalone.test.ts b/judging/core/src/standalone.test.ts
deleted file mode 100644
index 166726ab..00000000
--- a/judging/core/src/standalone.test.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { readdir, readFile } from "node:fs/promises";
-import { dirname, join } from "node:path";
-import { fileURLToPath } from "node:url";
-import { describe, expect, it } from "vitest";
-
-const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
-
-describe("standalone tree", () => {
- it("does not mention the host club", async () => {
- const hits: string[] = [];
- await walk(root, hits);
- expect(hits).toEqual([]);
- });
-});
-
-async function walk(dir: string, hits: string[]) {
- const entries = await readdir(dir, { withFileTypes: true });
- for (const entry of entries) {
- if (entry.name === "node_modules" || entry.name === ".next" || entry.name === "dist") {
- continue;
- }
- const path = join(dir, entry.name);
- if (entry.isDirectory()) {
- await walk(path, hits);
- continue;
- }
- if (!/\.(ts|tsx|md|sql|yml|json|tf)$/.test(entry.name)) continue;
- const text = await readFile(path, "utf8");
- const needles = ["DS" + "GT", "Hack" + "lytics", "datascience" + "gt"];
- if (needles.some((needle) => text.toLowerCase().includes(needle.toLowerCase()))) {
- hits.push(path);
- }
- }
-}
diff --git a/judging/db/eslint.config.mjs b/judging/db/eslint.config.mjs
deleted file mode 100644
index 827bfcb7..00000000
--- a/judging/db/eslint.config.mjs
+++ /dev/null
@@ -1,24 +0,0 @@
-import { config } from "@query/eslint-config/base";
-
-/** @type {import("eslint").Linter.Config[]} */
-export default [
- ...config,
- {
- files: ["src/**/*.ts"],
- ignores: ["src/env.ts"],
- rules: {
- "no-restricted-imports": [
- "error",
- {
- patterns: [
- {
- group: ["@query", "@query/*"],
- message:
- "judging/ is a standalone product. Import from @panel/* only.",
- },
- ],
- },
- ],
- },
- },
-];
diff --git a/judging/infra/docker/docker-compose.yml b/judging/infra/docker/docker-compose.yml
deleted file mode 100644
index 4da40215..00000000
--- a/judging/infra/docker/docker-compose.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-services:
- postgres:
- image: postgres:16
- environment:
- POSTGRES_USER: panel
- POSTGRES_PASSWORD: panel
- POSTGRES_DB: panel
- ports:
- - "54329:5432"
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U panel"]
- interval: 2s
- timeout: 2s
- retries: 20
-
- server:
- build:
- context: ../../..
- dockerfile: judging/infra/docker/Dockerfile
- environment:
- PANEL_DATABASE_URL: postgres://panel:panel@postgres:5432/panel
- DATABASE_URL: postgres://panel:panel@postgres:5432/panel
- PANEL_JWT_SECRET: dev-only-change-me
- PANEL_DEV_AUTH: "1"
- PORT: "8787"
- ports:
- - "8787:8787"
- depends_on:
- postgres:
- condition: service_healthy
diff --git a/judging/server/eslint.config.mjs b/judging/server/eslint.config.mjs
deleted file mode 100644
index d466eefb..00000000
--- a/judging/server/eslint.config.mjs
+++ /dev/null
@@ -1,28 +0,0 @@
-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" },
- },
- {
- files: ["src/**/*.ts"],
- ignores: ["src/env.ts", "src/**/*.test.ts"],
- rules: {
- "no-restricted-imports": [
- "error",
- {
- patterns: [
- {
- group: ["@query", "@query/*"],
- message:
- "judging/ is a standalone product. Import from @panel/* only.",
- },
- ],
- },
- ],
- },
- },
-];
diff --git a/judging/web/eslint.config.mjs b/judging/web/eslint.config.mjs
deleted file mode 100644
index 1236da8c..00000000
--- a/judging/web/eslint.config.mjs
+++ /dev/null
@@ -1,23 +0,0 @@
-import { config } from "@query/eslint-config/base";
-
-/** @type {import("eslint").Linter.Config[]} */
-export default [
- ...config,
- {
- files: ["**/*.ts", "**/*.tsx"],
- rules: {
- "no-restricted-imports": [
- "error",
- {
- patterns: [
- {
- group: ["@query", "@query/*"],
- message:
- "judging/ is a standalone product. Import from @panel/* only.",
- },
- ],
- },
- ],
- },
- },
-];
diff --git a/package.json b/package.json
index 6d7ed480..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 judging/core judging/server judging/cli"
+ "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..2290fd44 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -42,6 +42,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/replay.test.ts b/packages/api/src/routers/judge/replay.test.ts
index 5a3fa563..7d844a15 100644
--- a/packages/api/src/routers/judge/replay.test.ts
+++ b/packages/api/src/routers/judge/replay.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { rank } from "../../../../../judging/core/src/index";
+import { rank } from "@query/judging-core";
import { weightProjects } from "./weight";
const projects = [
diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json
index 1bcd806e..0077a323 100644
--- a/packages/api/tsconfig.json
+++ b/packages/api/tsconfig.json
@@ -5,5 +5,5 @@
"rootDir": "src"
},
"include": ["src", "src/.internal-tests/**/*.ts"],
- "exclude": ["node_modules", "dist", "src/routers/judge/replay.test.ts"]
+ "exclude": ["node_modules", "dist"]
}
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/judging/cli/package.json b/packages/judging-cli/package.json
similarity index 81%
rename from judging/cli/package.json
rename to packages/judging-cli/package.json
index 027dc16c..36268c76 100644
--- a/judging/cli/package.json
+++ b/packages/judging-cli/package.json
@@ -1,5 +1,5 @@
{
- "name": "@panel/cli",
+ "name": "@query/judging-cli",
"version": "0.0.0",
"private": true,
"type": "module",
@@ -10,8 +10,8 @@
"test": "node ../../node_modules/vitest/vitest.mjs run src/csv.test.ts"
},
"dependencies": {
- "@panel/db": "workspace:*",
- "@panel/server": "workspace:*",
+ "@query/judging-db": "workspace:*",
+ "@query/judging-server": "workspace:*",
"drizzle-orm": "0.45.3",
"tsx": "^4.23.15"
},
diff --git a/judging/cli/src/csv.test.ts b/packages/judging-cli/src/csv.test.ts
similarity index 100%
rename from judging/cli/src/csv.test.ts
rename to packages/judging-cli/src/csv.test.ts
diff --git a/judging/cli/src/csv.ts b/packages/judging-cli/src/csv.ts
similarity index 100%
rename from judging/cli/src/csv.ts
rename to packages/judging-cli/src/csv.ts
diff --git a/judging/cli/src/main.ts b/packages/judging-cli/src/main.ts
similarity index 98%
rename from judging/cli/src/main.ts
rename to packages/judging-cli/src/main.ts
index 5e9b0df8..a7d00297 100644
--- a/judging/cli/src/main.ts
+++ b/packages/judging-cli/src/main.ts
@@ -6,8 +6,8 @@ import {
databaseUrl,
requireDatabaseUrl,
seedDemo,
-} from "@panel/db";
-import { drainOutbox } from "@panel/server/outbox";
+} from "@query/judging-db";
+import { drainOutbox } from "@query/judging-server/outbox";
import { exportResults, importProjects } from "./transfer";
const [command, arg] = process.argv.slice(2);
diff --git a/judging/cli/src/transfer.ts b/packages/judging-cli/src/transfer.ts
similarity index 98%
rename from judging/cli/src/transfer.ts
rename to packages/judging-cli/src/transfer.ts
index 8daa0c5c..4469539f 100644
--- a/judging/cli/src/transfer.ts
+++ b/packages/judging-cli/src/transfer.ts
@@ -1,6 +1,6 @@
import { readFile, writeFile } from "node:fs/promises";
import { and, eq } from "drizzle-orm";
-import { createDb, project, requireDatabaseUrl, result, resultRun, track } from "@panel/db";
+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) {
diff --git a/judging/cli/tsconfig.json b/packages/judging-cli/tsconfig.json
similarity index 100%
rename from judging/cli/tsconfig.json
rename to packages/judging-cli/tsconfig.json
diff --git a/judging/core/eslint.config.mjs b/packages/judging-core/eslint.config.mjs
similarity index 68%
rename from judging/core/eslint.config.mjs
rename to packages/judging-core/eslint.config.mjs
index a7d0fb28..09f98a9a 100644
--- a/judging/core/eslint.config.mjs
+++ b/packages/judging-core/eslint.config.mjs
@@ -15,15 +15,10 @@ export default [
name: "process",
importNames: ["env"],
message:
- "@panel/core takes its inputs as arguments. It does not read the environment.",
+ "@query/judging-core takes its inputs as arguments. It does not read the environment.",
},
],
patterns: [
- {
- group: ["@query", "@query/*"],
- message:
- "judging/ is a standalone product. Import from @panel/* only.",
- },
{
group: [
"node:*",
@@ -35,7 +30,7 @@ export default [
"node:crypto",
],
message:
- "@panel/core has no runtime dependencies and does not call Node APIs.",
+ "@query/judging-core has no runtime dependencies and does not call Node APIs.",
},
],
},
diff --git a/judging/core/package.json b/packages/judging-core/package.json
similarity index 96%
rename from judging/core/package.json
rename to packages/judging-core/package.json
index 8f0f6bbf..1e961bdd 100644
--- a/judging/core/package.json
+++ b/packages/judging-core/package.json
@@ -1,5 +1,5 @@
{
- "name": "@panel/core",
+ "name": "@query/judging-core",
"version": "0.0.0",
"private": true,
"type": "module",
diff --git a/judging/core/scripts/emit-extensions.mjs b/packages/judging-core/scripts/emit-extensions.mjs
similarity index 100%
rename from judging/core/scripts/emit-extensions.mjs
rename to packages/judging-core/scripts/emit-extensions.mjs
diff --git a/judging/core/src/aggregate/bayes.test.ts b/packages/judging-core/src/aggregate/bayes.test.ts
similarity index 100%
rename from judging/core/src/aggregate/bayes.test.ts
rename to packages/judging-core/src/aggregate/bayes.test.ts
diff --git a/judging/core/src/aggregate/bayes.ts b/packages/judging-core/src/aggregate/bayes.ts
similarity index 100%
rename from judging/core/src/aggregate/bayes.ts
rename to packages/judging-core/src/aggregate/bayes.ts
diff --git a/judging/core/src/aggregate/blend.test.ts b/packages/judging-core/src/aggregate/blend.test.ts
similarity index 100%
rename from judging/core/src/aggregate/blend.test.ts
rename to packages/judging-core/src/aggregate/blend.test.ts
diff --git a/judging/core/src/aggregate/blend.ts b/packages/judging-core/src/aggregate/blend.ts
similarity index 100%
rename from judging/core/src/aggregate/blend.ts
rename to packages/judging-core/src/aggregate/blend.ts
diff --git a/judging/core/src/aggregate/bradleyTerry.test.ts b/packages/judging-core/src/aggregate/bradleyTerry.test.ts
similarity index 100%
rename from judging/core/src/aggregate/bradleyTerry.test.ts
rename to packages/judging-core/src/aggregate/bradleyTerry.test.ts
diff --git a/judging/core/src/aggregate/bradleyTerry.ts b/packages/judging-core/src/aggregate/bradleyTerry.ts
similarity index 100%
rename from judging/core/src/aggregate/bradleyTerry.ts
rename to packages/judging-core/src/aggregate/bradleyTerry.ts
diff --git a/judging/core/src/aggregate/rank.test.ts b/packages/judging-core/src/aggregate/rank.test.ts
similarity index 100%
rename from judging/core/src/aggregate/rank.test.ts
rename to packages/judging-core/src/aggregate/rank.test.ts
diff --git a/judging/core/src/aggregate/rank.ts b/packages/judging-core/src/aggregate/rank.ts
similarity index 100%
rename from judging/core/src/aggregate/rank.ts
rename to packages/judging-core/src/aggregate/rank.ts
diff --git a/judging/core/src/aggregate/zscore.test.ts b/packages/judging-core/src/aggregate/zscore.test.ts
similarity index 100%
rename from judging/core/src/aggregate/zscore.test.ts
rename to packages/judging-core/src/aggregate/zscore.test.ts
diff --git a/judging/core/src/aggregate/zscore.ts b/packages/judging-core/src/aggregate/zscore.ts
similarity index 100%
rename from judging/core/src/aggregate/zscore.ts
rename to packages/judging-core/src/aggregate/zscore.ts
diff --git a/judging/core/src/comparison.ts b/packages/judging-core/src/comparison.ts
similarity index 100%
rename from judging/core/src/comparison.ts
rename to packages/judging-core/src/comparison.ts
diff --git a/judging/core/src/dispatch.sim.test.ts b/packages/judging-core/src/dispatch.sim.test.ts
similarity index 100%
rename from judging/core/src/dispatch.sim.test.ts
rename to packages/judging-core/src/dispatch.sim.test.ts
diff --git a/judging/core/src/dispatch.test.ts b/packages/judging-core/src/dispatch.test.ts
similarity index 100%
rename from judging/core/src/dispatch.test.ts
rename to packages/judging-core/src/dispatch.test.ts
diff --git a/judging/core/src/dispatch.ts b/packages/judging-core/src/dispatch.ts
similarity index 100%
rename from judging/core/src/dispatch.ts
rename to packages/judging-core/src/dispatch.ts
diff --git a/judging/core/src/distance.test.ts b/packages/judging-core/src/distance.test.ts
similarity index 100%
rename from judging/core/src/distance.test.ts
rename to packages/judging-core/src/distance.test.ts
diff --git a/judging/core/src/distance.ts b/packages/judging-core/src/distance.ts
similarity index 100%
rename from judging/core/src/distance.ts
rename to packages/judging-core/src/distance.ts
diff --git a/judging/core/src/events.test.ts b/packages/judging-core/src/events.test.ts
similarity index 100%
rename from judging/core/src/events.test.ts
rename to packages/judging-core/src/events.test.ts
diff --git a/judging/core/src/events.ts b/packages/judging-core/src/events.ts
similarity index 100%
rename from judging/core/src/events.ts
rename to packages/judging-core/src/events.ts
diff --git a/judging/core/src/index.ts b/packages/judging-core/src/index.ts
similarity index 100%
rename from judging/core/src/index.ts
rename to packages/judging-core/src/index.ts
diff --git a/judging/core/src/match.test.ts b/packages/judging-core/src/match.test.ts
similarity index 100%
rename from judging/core/src/match.test.ts
rename to packages/judging-core/src/match.test.ts
diff --git a/judging/core/src/match.ts b/packages/judging-core/src/match.ts
similarity index 100%
rename from judging/core/src/match.ts
rename to packages/judging-core/src/match.ts
diff --git a/judging/core/src/package.test.ts b/packages/judging-core/src/package.test.ts
similarity index 90%
rename from judging/core/src/package.test.ts
rename to packages/judging-core/src/package.test.ts
index 678fffae..85a15172 100644
--- a/judging/core/src/package.test.ts
+++ b/packages/judging-core/src/package.test.ts
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
-describe("@panel/core package", () => {
+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 {
diff --git a/judging/core/src/phase.test.ts b/packages/judging-core/src/phase.test.ts
similarity index 100%
rename from judging/core/src/phase.test.ts
rename to packages/judging-core/src/phase.test.ts
diff --git a/judging/core/src/phase.ts b/packages/judging-core/src/phase.ts
similarity index 100%
rename from judging/core/src/phase.ts
rename to packages/judging-core/src/phase.ts
diff --git a/judging/core/src/rubric.test.ts b/packages/judging-core/src/rubric.test.ts
similarity index 100%
rename from judging/core/src/rubric.test.ts
rename to packages/judging-core/src/rubric.test.ts
diff --git a/judging/core/src/rubric.ts b/packages/judging-core/src/rubric.ts
similarity index 100%
rename from judging/core/src/rubric.ts
rename to packages/judging-core/src/rubric.ts
diff --git a/judging/core/src/tables.test.ts b/packages/judging-core/src/tables.test.ts
similarity index 100%
rename from judging/core/src/tables.test.ts
rename to packages/judging-core/src/tables.test.ts
diff --git a/judging/core/src/tables.ts b/packages/judging-core/src/tables.ts
similarity index 100%
rename from judging/core/src/tables.ts
rename to packages/judging-core/src/tables.ts
diff --git a/judging/core/src/timers.test.ts b/packages/judging-core/src/timers.test.ts
similarity index 100%
rename from judging/core/src/timers.test.ts
rename to packages/judging-core/src/timers.test.ts
diff --git a/judging/core/src/timers.ts b/packages/judging-core/src/timers.ts
similarity index 100%
rename from judging/core/src/timers.ts
rename to packages/judging-core/src/timers.ts
diff --git a/judging/core/src/uncertainty.test.ts b/packages/judging-core/src/uncertainty.test.ts
similarity index 100%
rename from judging/core/src/uncertainty.test.ts
rename to packages/judging-core/src/uncertainty.test.ts
diff --git a/judging/core/src/uncertainty.ts b/packages/judging-core/src/uncertainty.ts
similarity index 100%
rename from judging/core/src/uncertainty.ts
rename to packages/judging-core/src/uncertainty.ts
diff --git a/judging/core/tsconfig.build.json b/packages/judging-core/tsconfig.build.json
similarity index 100%
rename from judging/core/tsconfig.build.json
rename to packages/judging-core/tsconfig.build.json
diff --git a/judging/core/tsconfig.json b/packages/judging-core/tsconfig.json
similarity index 100%
rename from judging/core/tsconfig.json
rename to packages/judging-core/tsconfig.json
diff --git a/judging/core/vitest.config.ts b/packages/judging-core/vitest.config.ts
similarity index 100%
rename from judging/core/vitest.config.ts
rename to packages/judging-core/vitest.config.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/judging/db/migrations/0001_init.sql b/packages/judging-db/migrations/0001_init.sql
similarity index 100%
rename from judging/db/migrations/0001_init.sql
rename to packages/judging-db/migrations/0001_init.sql
diff --git a/judging/db/migrations/0002_feedback_token.sql b/packages/judging-db/migrations/0002_feedback_token.sql
similarity index 100%
rename from judging/db/migrations/0002_feedback_token.sql
rename to packages/judging-db/migrations/0002_feedback_token.sql
diff --git a/judging/db/migrations/0003_shared_database.sql b/packages/judging-db/migrations/0003_shared_database.sql
similarity index 100%
rename from judging/db/migrations/0003_shared_database.sql
rename to packages/judging-db/migrations/0003_shared_database.sql
diff --git a/judging/db/package.json b/packages/judging-db/package.json
similarity index 86%
rename from judging/db/package.json
rename to packages/judging-db/package.json
index e6e07b1b..87a7eee0 100644
--- a/judging/db/package.json
+++ b/packages/judging-db/package.json
@@ -1,5 +1,5 @@
{
- "name": "@panel/db",
+ "name": "@query/judging-db",
"version": "0.0.0",
"private": true,
"type": "module",
@@ -11,7 +11,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@panel/core": "workspace:*",
+ "@query/judging-core": "workspace:*",
"drizzle-orm": "0.45.3",
"pg": "8.23.0"
},
diff --git a/judging/db/src/client.ts b/packages/judging-db/src/client.ts
similarity index 100%
rename from judging/db/src/client.ts
rename to packages/judging-db/src/client.ts
diff --git a/judging/db/src/env.ts b/packages/judging-db/src/env.ts
similarity index 100%
rename from judging/db/src/env.ts
rename to packages/judging-db/src/env.ts
diff --git a/judging/db/src/index.ts b/packages/judging-db/src/index.ts
similarity index 100%
rename from judging/db/src/index.ts
rename to packages/judging-db/src/index.ts
diff --git a/judging/db/src/migrate.ts b/packages/judging-db/src/migrate.ts
similarity index 100%
rename from judging/db/src/migrate.ts
rename to packages/judging-db/src/migrate.ts
diff --git a/judging/db/src/schema.ts b/packages/judging-db/src/schema.ts
similarity index 100%
rename from judging/db/src/schema.ts
rename to packages/judging-db/src/schema.ts
diff --git a/judging/db/src/seed.ts b/packages/judging-db/src/seed.ts
similarity index 99%
rename from judging/db/src/seed.ts
rename to packages/judging-db/src/seed.ts
index a313315f..9a11405a 100644
--- a/judging/db/src/seed.ts
+++ b/packages/judging-db/src/seed.ts
@@ -1,5 +1,5 @@
import { eq, sql } from "drizzle-orm";
-import { DEFAULT_TIMERS } from "@panel/core";
+import { DEFAULT_TIMERS } from "@query/judging-core";
import type { PanelDb, PanelTx } from "./client";
import {
criterion,
diff --git a/judging/db/tsconfig.json b/packages/judging-db/tsconfig.json
similarity index 100%
rename from judging/db/tsconfig.json
rename to packages/judging-db/tsconfig.json
diff --git a/judging/infra/docker/Dockerfile b/packages/judging-server/Dockerfile
similarity index 60%
rename from judging/infra/docker/Dockerfile
rename to packages/judging-server/Dockerfile
index e40b129d..78dbfbd3 100644
--- a/judging/infra/docker/Dockerfile
+++ b/packages/judging-server/Dockerfile
@@ -5,11 +5,10 @@ RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY tooling ./tooling
-COPY judging ./judging
COPY packages ./packages
COPY sites ./sites
-RUN pnpm install --frozen-lockfile --filter @panel/server...
+RUN pnpm install --frozen-lockfile --filter @query/judging-server...
EXPOSE 8787
-CMD ["pnpm", "--filter", "@panel/server", "start"]
+CMD ["pnpm", "--filter", "@query/judging-server", "start"]
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/judging/server/package.json b/packages/judging-server/package.json
similarity index 88%
rename from judging/server/package.json
rename to packages/judging-server/package.json
index d260728b..141ef278 100644
--- a/judging/server/package.json
+++ b/packages/judging-server/package.json
@@ -1,5 +1,5 @@
{
- "name": "@panel/server",
+ "name": "@query/judging-server",
"version": "0.0.0",
"private": true,
"type": "module",
@@ -14,8 +14,8 @@
},
"dependencies": {
"@hono/node-server": "^1.19.0",
- "@panel/core": "workspace:*",
- "@panel/db": "workspace:*",
+ "@query/judging-core": "workspace:*",
+ "@query/judging-db": "workspace:*",
"@trpc/server": "11.19.0",
"drizzle-orm": "0.45.3",
"hono": "^4.9.0",
diff --git a/judging/server/src/app.test.ts b/packages/judging-server/src/app.test.ts
similarity index 100%
rename from judging/server/src/app.test.ts
rename to packages/judging-server/src/app.test.ts
diff --git a/judging/server/src/app.ts b/packages/judging-server/src/app.ts
similarity index 99%
rename from judging/server/src/app.ts
rename to packages/judging-server/src/app.ts
index cdd03e78..b9d19367 100644
--- a/judging/server/src/app.ts
+++ b/packages/judging-server/src/app.ts
@@ -4,8 +4,8 @@ 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 "@panel/db";
-import { event, loginCode, membership, organization, user, visit } from "@panel/db";
+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 { Role } from "./auth";
diff --git a/judging/server/src/auth.test.ts b/packages/judging-server/src/auth.test.ts
similarity index 100%
rename from judging/server/src/auth.test.ts
rename to packages/judging-server/src/auth.test.ts
diff --git a/judging/server/src/auth.ts b/packages/judging-server/src/auth.ts
similarity index 100%
rename from judging/server/src/auth.ts
rename to packages/judging-server/src/auth.ts
diff --git a/judging/server/src/bus.test.ts b/packages/judging-server/src/bus.test.ts
similarity index 100%
rename from judging/server/src/bus.test.ts
rename to packages/judging-server/src/bus.test.ts
diff --git a/judging/server/src/bus.ts b/packages/judging-server/src/bus.ts
similarity index 100%
rename from judging/server/src/bus.ts
rename to packages/judging-server/src/bus.ts
diff --git a/judging/server/src/cross-server.check.ts b/packages/judging-server/src/cross-server.check.ts
similarity index 97%
rename from judging/server/src/cross-server.check.ts
rename to packages/judging-server/src/cross-server.check.ts
index 85c4120c..dff233ef 100644
--- a/judging/server/src/cross-server.check.ts
+++ b/packages/judging-server/src/cross-server.check.ts
@@ -1,6 +1,6 @@
import { WebSocket } from "ws";
import { eq } from "drizzle-orm";
-import { createDb, event, judge, organization } from "@panel/db";
+import { createDb, event, judge, organization } from "@query/judging-db";
import { signActor } from "./auth";
import { log } from "./log";
diff --git a/judging/server/src/env.ts b/packages/judging-server/src/env.ts
similarity index 100%
rename from judging/server/src/env.ts
rename to packages/judging-server/src/env.ts
diff --git a/judging/server/src/hub.ts b/packages/judging-server/src/hub.ts
similarity index 100%
rename from judging/server/src/hub.ts
rename to packages/judging-server/src/hub.ts
diff --git a/judging/server/src/index.ts b/packages/judging-server/src/index.ts
similarity index 95%
rename from judging/server/src/index.ts
rename to packages/judging-server/src/index.ts
index b5ad4d8b..1ef346c6 100644
--- a/judging/server/src/index.ts
+++ b/packages/judging-server/src/index.ts
@@ -1,8 +1,8 @@
import { serve } from "@hono/node-server";
import type { Server } from "node:http";
import { sql } from "drizzle-orm";
-import { boardChannel, judgeChannel } from "@panel/core";
-import { createDb } from "@panel/db";
+import { boardChannel, judgeChannel } from "@query/judging-core";
+import { createDb } from "@query/judging-db";
import { createBus } from "./bus";
import { attachHub } from "./hub";
import { createApp } from "./app";
diff --git a/judging/server/src/log.ts b/packages/judging-server/src/log.ts
similarity index 100%
rename from judging/server/src/log.ts
rename to packages/judging-server/src/log.ts
diff --git a/judging/server/src/mail.ts b/packages/judging-server/src/mail.ts
similarity index 100%
rename from judging/server/src/mail.ts
rename to packages/judging-server/src/mail.ts
diff --git a/judging/server/src/metrics.ts b/packages/judging-server/src/metrics.ts
similarity index 100%
rename from judging/server/src/metrics.ts
rename to packages/judging-server/src/metrics.ts
diff --git a/judging/server/src/router.ts b/packages/judging-server/src/router.ts
similarity index 99%
rename from judging/server/src/router.ts
rename to packages/judging-server/src/router.ts
index 8e7806bc..6e3d7e9e 100644
--- a/judging/server/src/router.ts
+++ b/packages/judging-server/src/router.ts
@@ -1,9 +1,9 @@
import { initTRPC, TRPCError } from "@trpc/server";
import { and, eq, sql } from "drizzle-orm";
import { z } from "zod";
-import { assertTransition, boardChannel, judgeChannel, leaderboardChannel } from "@panel/core";
-import type { PanelDb } from "@panel/db";
-import { event, eventLog, judge, criterion, rubric, visit, vote } from "@panel/db";
+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";
diff --git a/judging/server/src/services/arrive.ts b/packages/judging-server/src/services/arrive.ts
similarity index 92%
rename from judging/server/src/services/arrive.ts
rename to packages/judging-server/src/services/arrive.ts
index bd213f61..616e98f7 100644
--- a/judging/server/src/services/arrive.ts
+++ b/packages/judging-server/src/services/arrive.ts
@@ -1,7 +1,7 @@
import { and, eq, isNull } from "drizzle-orm";
-import type { PanelDb } from "@panel/db";
-import { event, eventLog, outbox, project, visit } from "@panel/db";
-import { phaseAllows } from "@panel/core";
+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,
diff --git a/judging/server/src/services/catalog.ts b/packages/judging-server/src/services/catalog.ts
similarity index 99%
rename from judging/server/src/services/catalog.ts
rename to packages/judging-server/src/services/catalog.ts
index 9bef9d5d..8f1e3996 100644
--- a/judging/server/src/services/catalog.ts
+++ b/packages/judging-server/src/services/catalog.ts
@@ -1,5 +1,5 @@
import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
-import type { PanelDb } from "@panel/db";
+import type { PanelDb } from "@query/judging-db";
import {
apiKey,
criterion,
@@ -20,7 +20,7 @@ import {
voteScore,
webhook,
zone,
-} from "@panel/db";
+} from "@query/judging-db";
import { randomBytes } from "node:crypto";
import type { Actor } from "../auth";
import { hashApiKey, roleForScopes } from "../auth";
diff --git a/judging/server/src/services/compare.ts b/packages/judging-server/src/services/compare.ts
similarity index 90%
rename from judging/server/src/services/compare.ts
rename to packages/judging-server/src/services/compare.ts
index 953314ce..f1220ce7 100644
--- a/judging/server/src/services/compare.ts
+++ b/packages/judging-server/src/services/compare.ts
@@ -1,7 +1,7 @@
import { and, desc, eq, isNull } from "drizzle-orm";
-import { phaseAllows } from "@panel/core";
-import type { PanelDb } from "@panel/db";
-import { comparison, event, eventLog, visit } from "@panel/db";
+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(
diff --git a/judging/server/src/services/dispatch.concurrent.test.ts b/packages/judging-server/src/services/dispatch.concurrent.test.ts
similarity index 88%
rename from judging/server/src/services/dispatch.concurrent.test.ts
rename to packages/judging-server/src/services/dispatch.concurrent.test.ts
index 71eb9e52..e39c0584 100644
--- a/judging/server/src/services/dispatch.concurrent.test.ts
+++ b/packages/judging-server/src/services/dispatch.concurrent.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { dispatchNext } from "./dispatch";
-import { createDb, databaseUrl, migrate } from "@panel/db";
+import { createDb, databaseUrl, migrate } from "@query/judging-db";
const url = databaseUrl();
@@ -9,7 +9,7 @@ describe.skipIf(!url)("concurrent dispatch", () => {
const { db, pool } = createDb(url as string);
try {
await migrate(pool);
- const { seedDemo, judge, project } = await import("@panel/db");
+ 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));
diff --git a/judging/server/src/services/dispatch.ts b/packages/judging-server/src/services/dispatch.ts
similarity index 98%
rename from judging/server/src/services/dispatch.ts
rename to packages/judging-server/src/services/dispatch.ts
index 9210b081..0b6655c4 100644
--- a/judging/server/src/services/dispatch.ts
+++ b/packages/judging-server/src/services/dispatch.ts
@@ -1,8 +1,8 @@
import { randomInt } from "node:crypto";
import { and, eq, isNull, sql } from "drizzle-orm";
-import { isLive, pickNext } from "@panel/core";
-import type { TimerConfig } from "@panel/core";
-import type { PanelDb, PanelTx } from "@panel/db";
+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,
@@ -14,7 +14,7 @@ import {
track,
visit,
vote,
-} from "@panel/db";
+} from "@query/judging-db";
import { isDeliveredTopic, logsForHandout } from "./logs";
export type DispatchOutcome =
diff --git a/judging/server/src/services/feedback.test.ts b/packages/judging-server/src/services/feedback.test.ts
similarity index 100%
rename from judging/server/src/services/feedback.test.ts
rename to packages/judging-server/src/services/feedback.test.ts
diff --git a/judging/server/src/services/feedback.ts b/packages/judging-server/src/services/feedback.ts
similarity index 100%
rename from judging/server/src/services/feedback.ts
rename to packages/judging-server/src/services/feedback.ts
diff --git a/judging/server/src/services/floor.test.ts b/packages/judging-server/src/services/floor.test.ts
similarity index 94%
rename from judging/server/src/services/floor.test.ts
rename to packages/judging-server/src/services/floor.test.ts
index 1da5647e..aabba96b 100644
--- a/judging/server/src/services/floor.test.ts
+++ b/packages/judging-server/src/services/floor.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { DEFAULT_TIMERS } from "@panel/core";
+import { DEFAULT_TIMERS } from "@query/judging-core";
import { judgeFloorState } from "./floor";
const arrived = new Date("2027-02-28T13:00:00Z");
diff --git a/judging/server/src/services/floor.ts b/packages/judging-server/src/services/floor.ts
similarity index 91%
rename from judging/server/src/services/floor.ts
rename to packages/judging-server/src/services/floor.ts
index 0358e071..5dfe8c0a 100644
--- a/judging/server/src/services/floor.ts
+++ b/packages/judging-server/src/services/floor.ts
@@ -1,8 +1,8 @@
import { and, eq, isNull, sql } from "drizzle-orm";
-import { isOverTarget } from "@panel/core";
-import type { TimerConfig } from "@panel/core";
-import type { PanelDb } from "@panel/db";
-import { eventConfig, judge, project, visit, vote } from "@panel/db";
+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";
diff --git a/judging/server/src/services/flow.integration.test.ts b/packages/judging-server/src/services/flow.integration.test.ts
similarity index 97%
rename from judging/server/src/services/flow.integration.test.ts
rename to packages/judging-server/src/services/flow.integration.test.ts
index 91f3a024..58156201 100644
--- a/judging/server/src/services/flow.integration.test.ts
+++ b/packages/judging-server/src/services/flow.integration.test.ts
@@ -10,7 +10,7 @@ import {
project,
rubric,
seedDemo,
-} from "@panel/db";
+} from "@query/judging-db";
import { arrive } from "./arrive";
import { dispatchNext } from "./dispatch";
import { feedbackCard } from "./catalog";
@@ -25,7 +25,7 @@ describe.skipIf(!url)("a full visit", () => {
try {
await migrate(pool);
const { eventId } = await seedDemo(db);
- const { judge } = await import("@panel/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");
diff --git a/judging/server/src/services/live.ts b/packages/judging-server/src/services/live.ts
similarity index 96%
rename from judging/server/src/services/live.ts
rename to packages/judging-server/src/services/live.ts
index 750c7481..3e75fbe2 100644
--- a/judging/server/src/services/live.ts
+++ b/packages/judging-server/src/services/live.ts
@@ -1,6 +1,6 @@
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm";
-import type { PanelDb } from "@panel/db";
-import { event, eventConfig, judge, organization, prize, project, result, resultRun, track, visit, vote } from "@panel/db";
+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
diff --git a/judging/server/src/services/logs.test.ts b/packages/judging-server/src/services/logs.test.ts
similarity index 100%
rename from judging/server/src/services/logs.test.ts
rename to packages/judging-server/src/services/logs.test.ts
diff --git a/judging/server/src/services/logs.ts b/packages/judging-server/src/services/logs.ts
similarity index 85%
rename from judging/server/src/services/logs.ts
rename to packages/judging-server/src/services/logs.ts
index 7c288047..b7d54c73 100644
--- a/judging/server/src/services/logs.ts
+++ b/packages/judging-server/src/services/logs.ts
@@ -1,5 +1,5 @@
-import { isOutboxTopic } from "@panel/core";
-import type { LogKind } from "@panel/core";
+import { isOutboxTopic } from "@query/judging-core";
+import type { LogKind } from "@query/judging-core";
export type LogDraft = {
kind: LogKind;
diff --git a/judging/server/src/services/outbox.ts b/packages/judging-server/src/services/outbox.ts
similarity index 93%
rename from judging/server/src/services/outbox.ts
rename to packages/judging-server/src/services/outbox.ts
index 0844039b..4f6ca441 100644
--- a/judging/server/src/services/outbox.ts
+++ b/packages/judging-server/src/services/outbox.ts
@@ -1,7 +1,7 @@
import { createHmac } from "node:crypto";
import { eq, isNull } from "drizzle-orm";
-import type { PanelDb } from "@panel/db";
-import { outbox, webhook } from "@panel/db";
+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. */
export async function drainOutbox(db: PanelDb, now = new Date()): Promise {
diff --git a/judging/server/src/services/overtime.test.ts b/packages/judging-server/src/services/overtime.test.ts
similarity index 93%
rename from judging/server/src/services/overtime.test.ts
rename to packages/judging-server/src/services/overtime.test.ts
index f39cfaef..35fdb143 100644
--- a/judging/server/src/services/overtime.test.ts
+++ b/packages/judging-server/src/services/overtime.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { DEFAULT_TIMERS } from "@panel/core";
+import { DEFAULT_TIMERS } from "@query/judging-core";
import { visitsOverTarget } from "./overtime";
const arrived = new Date("2027-02-28T13:00:00Z");
diff --git a/judging/server/src/services/overtime.ts b/packages/judging-server/src/services/overtime.ts
similarity index 87%
rename from judging/server/src/services/overtime.ts
rename to packages/judging-server/src/services/overtime.ts
index dad634b7..db7fc2a0 100644
--- a/judging/server/src/services/overtime.ts
+++ b/packages/judging-server/src/services/overtime.ts
@@ -1,8 +1,8 @@
import { and, eq, isNotNull, isNull } from "drizzle-orm";
-import { isOverTarget } from "@panel/core";
-import type { TimerConfig } from "@panel/core";
-import type { PanelDb } from "@panel/db";
-import { eventConfig, visit } from "@panel/db";
+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;
diff --git a/judging/server/src/services/records.ts b/packages/judging-server/src/services/records.ts
similarity index 98%
rename from judging/server/src/services/records.ts
rename to packages/judging-server/src/services/records.ts
index 39f865cd..7000de5b 100644
--- a/judging/server/src/services/records.ts
+++ b/packages/judging-server/src/services/records.ts
@@ -1,8 +1,8 @@
import { randomUUID } from "node:crypto";
import { and, eq, isNull } from "drizzle-orm";
-import { assignTables, phaseAllows } from "@panel/core";
-import type { PanelDb } from "@panel/db";
-import { event, eventLog, floorTable, judge, outbox, project, projectTrack, visit } from "@panel/db";
+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
diff --git a/judging/server/src/services/redis.integration.test.ts b/packages/judging-server/src/services/redis.integration.test.ts
similarity index 100%
rename from judging/server/src/services/redis.integration.test.ts
rename to packages/judging-server/src/services/redis.integration.test.ts
diff --git a/judging/server/src/services/results.test.ts b/packages/judging-server/src/services/results.test.ts
similarity index 100%
rename from judging/server/src/services/results.test.ts
rename to packages/judging-server/src/services/results.test.ts
diff --git a/judging/server/src/services/results.ts b/packages/judging-server/src/services/results.ts
similarity index 98%
rename from judging/server/src/services/results.ts
rename to packages/judging-server/src/services/results.ts
index 486c5d32..addcd6f1 100644
--- a/judging/server/src/services/results.ts
+++ b/packages/judging-server/src/services/results.ts
@@ -1,6 +1,6 @@
import { and, desc, eq, isNotNull, isNull, sql } from "drizzle-orm";
-import { phaseAllows, rank } from "@panel/core";
-import type { PanelDb } from "@panel/db";
+import { phaseAllows, rank } from "@query/judging-core";
+import type { PanelDb } from "@query/judging-db";
import {
comparison,
event,
@@ -14,7 +14,7 @@ import {
resultRun,
track,
vote,
-} from "@panel/db";
+} from "@query/judging-db";
export type Placement = {
projectId: string;
diff --git a/judging/server/src/services/vote.test.ts b/packages/judging-server/src/services/vote.test.ts
similarity index 96%
rename from judging/server/src/services/vote.test.ts
rename to packages/judging-server/src/services/vote.test.ts
index 739f5334..43dd458b 100644
--- a/judging/server/src/services/vote.test.ts
+++ b/packages/judging-server/src/services/vote.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { DEFAULT_TIMERS } from "@panel/core";
+import { DEFAULT_TIMERS } from "@query/judging-core";
import { assessVote } from "./vote";
const criteria = [{ id: "creativity", min: 0, max: 10, weight: 1 }];
diff --git a/judging/server/src/services/vote.ts b/packages/judging-server/src/services/vote.ts
similarity index 97%
rename from judging/server/src/services/vote.ts
rename to packages/judging-server/src/services/vote.ts
index 3d0dfd89..bb1d8f0d 100644
--- a/judging/server/src/services/vote.ts
+++ b/packages/judging-server/src/services/vote.ts
@@ -1,7 +1,7 @@
import { and, eq } from "drizzle-orm";
-import { isPastCutoff, phaseAllows, validateScores, weightedTotal } from "@panel/core";
-import type { Criterion, Phase, ScoreInput, TimerConfig } from "@panel/core";
-import type { PanelDb } from "@panel/db";
+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,
@@ -13,7 +13,7 @@ import {
visit,
vote,
voteScore,
-} from "@panel/db";
+} from "@query/judging-db";
export type VoteDecision =
| { ok: true; total: number }
diff --git a/judging/infra/gcp/main.tf b/packages/judging-server/terraform/main.tf
similarity index 100%
rename from judging/infra/gcp/main.tf
rename to packages/judging-server/terraform/main.tf
diff --git a/judging/server/tsconfig.json b/packages/judging-server/tsconfig.json
similarity index 100%
rename from judging/server/tsconfig.json
rename to packages/judging-server/tsconfig.json
diff --git a/judging/server/vitest.config.ts b/packages/judging-server/vitest.config.ts
similarity index 100%
rename from judging/server/vitest.config.ts
rename to packages/judging-server/vitest.config.ts
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 17d1b918..c09297d0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -41,175 +41,6 @@ importers:
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))
- judging/cli:
- dependencies:
- '@panel/db':
- specifier: workspace:*
- version: link:../db
- '@panel/server':
- specifier: workspace:*
- version: link:../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
-
- 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))
-
- judging/db:
- dependencies:
- '@panel/core':
- specifier: workspace:*
- version: link:../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
-
- judging/server:
- dependencies:
- '@hono/node-server':
- specifier: ^1.19.0
- version: 1.19.17(hono@4.13.13)
- '@panel/core':
- specifier: workspace:*
- version: link:../core
- '@panel/db':
- specifier: workspace:*
- version: link:../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
- tsx:
- specifier: ^4.23.15
- version: 4.23.15
- ws:
- specifier: ^8.20.1
- version: 8.21.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
- '@types/ws':
- specifier: ^8.18.1
- version: 8.18.2
- 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))
-
- judging/web:
- dependencies:
- next:
- specifier: 16.3.7
- version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
- react:
- specifier: 19.3.0
- version: 19.3.0
- react-dom:
- specifier: 19.3.0
- version: 19.3.0(react@19.3.0)
- devDependencies:
- '@playwright/test':
- specifier: ^1.58.2
- version: 1.64.0
- '@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/react':
- specifier: ^19.3.0
- version: 19.3.0
- '@types/react-dom':
- specifier: ^19.3.0
- version: 19.3.0(@types/react@19.3.0)
- typescript:
- specifier: 7.0.2
- version: 7.0.2
-
packages/api:
dependencies:
'@query/auth':
@@ -255,6 +86,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
@@ -357,6 +191,141 @@ 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:
+ '@hono/node-server':
+ specifier: ^1.19.0
+ version: 1.19.17(hono@4.13.13)
+ '@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
+ tsx:
+ specifier: ^4.23.15
+ version: 4.23.15
+ ws:
+ specifier: ^8.20.1
+ version: 8.21.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
+ '@types/ws':
+ specifier: ^8.18.1
+ version: 8.18.2
+ 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:
@@ -446,6 +415,40 @@ importers:
specifier: ^8.70.1
version: 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2)
+ sites/judging:
+ dependencies:
+ next:
+ specifier: 16.3.7
+ version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ react:
+ specifier: 19.3.0
+ version: 19.3.0
+ react-dom:
+ specifier: 19.3.0
+ version: 19.3.0(react@19.3.0)
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.58.2
+ version: 1.64.0
+ '@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/react':
+ specifier: ^19.3.0
+ version: 19.3.0
+ '@types/react-dom':
+ specifier: ^19.3.0
+ version: 19.3.0(@types/react@19.3.0)
+ typescript:
+ specifier: 7.0.2
+ version: 7.0.2
+
sites/mainweb:
dependencies:
'@google-cloud/storage':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index c9f5f1e7..4da7f960 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -7,4 +7,3 @@ packages:
- packages/auth
- packages/db
- packages/ui
- - judging/*
diff --git a/judging/web/.gitignore b/sites/judging/.gitignore
similarity index 100%
rename from judging/web/.gitignore
rename to sites/judging/.gitignore
diff --git a/judging/infra/docker/Dockerfile.web b/sites/judging/Dockerfile
similarity index 54%
rename from judging/infra/docker/Dockerfile.web
rename to sites/judging/Dockerfile
index 4dee1b6f..488feb4f 100644
--- a/judging/infra/docker/Dockerfile.web
+++ b/sites/judging/Dockerfile
@@ -5,15 +5,14 @@ RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY tooling ./tooling
-COPY judging ./judging
COPY packages ./packages
COPY sites ./sites
ARG NEXT_PUBLIC_PANEL_URL=http://localhost:8787
ENV NEXT_PUBLIC_PANEL_URL=$NEXT_PUBLIC_PANEL_URL
-RUN pnpm install --frozen-lockfile --filter @panel/web...
-RUN pnpm --filter @panel/web build
+RUN pnpm install --frozen-lockfile --filter @query/judging-web...
+RUN pnpm --filter @query/judging-web build
FROM node:22-bookworm-slim
@@ -22,9 +21,9 @@ ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
-COPY --from=build /app/judging/web/.next/standalone ./
-COPY --from=build /app/judging/web/.next/static ./judging/web/.next/static
-COPY --from=build /app/judging/web/public ./judging/web/public
+COPY --from=build /app/sites/judging/.next/standalone ./
+COPY --from=build /app/sites/judging/.next/static ./sites/judging/.next/static
+COPY --from=build /app/sites/judging/public ./sites/judging/public
EXPOSE 3000
-CMD ["node", "judging/web/server.js"]
+CMD ["node", "sites/judging/server.js"]
diff --git a/judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx b/sites/judging/app/e/[orgSlug]/[eventSlug]/board.tsx
similarity index 100%
rename from judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx
rename to sites/judging/app/e/[orgSlug]/[eventSlug]/board.tsx
diff --git a/judging/web/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx b/sites/judging/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx
similarity index 100%
rename from judging/web/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx
rename to sites/judging/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx
diff --git a/judging/web/app/e/[orgSlug]/[eventSlug]/page.tsx b/sites/judging/app/e/[orgSlug]/[eventSlug]/page.tsx
similarity index 100%
rename from judging/web/app/e/[orgSlug]/[eventSlug]/page.tsx
rename to sites/judging/app/e/[orgSlug]/[eventSlug]/page.tsx
diff --git a/judging/web/app/j/[orgSlug]/[eventSlug]/desk.tsx b/sites/judging/app/j/[orgSlug]/[eventSlug]/desk.tsx
similarity index 100%
rename from judging/web/app/j/[orgSlug]/[eventSlug]/desk.tsx
rename to sites/judging/app/j/[orgSlug]/[eventSlug]/desk.tsx
diff --git a/judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx b/sites/judging/app/j/[orgSlug]/[eventSlug]/page.tsx
similarity index 100%
rename from judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx
rename to sites/judging/app/j/[orgSlug]/[eventSlug]/page.tsx
diff --git a/judging/web/app/layout.tsx b/sites/judging/app/layout.tsx
similarity index 100%
rename from judging/web/app/layout.tsx
rename to sites/judging/app/layout.tsx
diff --git a/judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx b/sites/judging/app/o/[orgSlug]/[eventSlug]/console.tsx
similarity index 100%
rename from judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx
rename to sites/judging/app/o/[orgSlug]/[eventSlug]/console.tsx
diff --git a/judging/web/app/o/[orgSlug]/[eventSlug]/editors.tsx b/sites/judging/app/o/[orgSlug]/[eventSlug]/editors.tsx
similarity index 100%
rename from judging/web/app/o/[orgSlug]/[eventSlug]/editors.tsx
rename to sites/judging/app/o/[orgSlug]/[eventSlug]/editors.tsx
diff --git a/judging/web/app/o/[orgSlug]/[eventSlug]/page.tsx b/sites/judging/app/o/[orgSlug]/[eventSlug]/page.tsx
similarity index 100%
rename from judging/web/app/o/[orgSlug]/[eventSlug]/page.tsx
rename to sites/judging/app/o/[orgSlug]/[eventSlug]/page.tsx
diff --git a/judging/web/app/page.tsx b/sites/judging/app/page.tsx
similarity index 100%
rename from judging/web/app/page.tsx
rename to sites/judging/app/page.tsx
diff --git a/judging/web/e2e/visit.spec.ts b/sites/judging/e2e/visit.spec.ts
similarity index 100%
rename from judging/web/e2e/visit.spec.ts
rename to sites/judging/e2e/visit.spec.ts
diff --git a/sites/judging/eslint.config.mjs b/sites/judging/eslint.config.mjs
new file mode 100644
index 00000000..69fbcb17
--- /dev/null
+++ b/sites/judging/eslint.config.mjs
@@ -0,0 +1,8 @@
+import { config } from "@query/eslint-config/base";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+ ...config,
+ // Playwright specs sit outside tsconfig, so the typed parser cannot load them.
+ { ignores: ["e2e/**"] },
+];
diff --git a/judging/web/lib/api.ts b/sites/judging/lib/api.ts
similarity index 100%
rename from judging/web/lib/api.ts
rename to sites/judging/lib/api.ts
diff --git a/judging/web/lib/held.ts b/sites/judging/lib/held.ts
similarity index 100%
rename from judging/web/lib/held.ts
rename to sites/judging/lib/held.ts
diff --git a/judging/web/next.config.ts b/sites/judging/next.config.ts
similarity index 100%
rename from judging/web/next.config.ts
rename to sites/judging/next.config.ts
diff --git a/judging/web/package.json b/sites/judging/package.json
similarity index 94%
rename from judging/web/package.json
rename to sites/judging/package.json
index 42b7da58..35282b2e 100644
--- a/judging/web/package.json
+++ b/sites/judging/package.json
@@ -1,5 +1,5 @@
{
- "name": "@panel/web",
+ "name": "@query/judging-web",
"version": "0.0.0",
"private": true,
"scripts": {
diff --git a/judging/web/playwright.config.ts b/sites/judging/playwright.config.ts
similarity index 100%
rename from judging/web/playwright.config.ts
rename to sites/judging/playwright.config.ts
diff --git a/judging/web/public/manifest.webmanifest b/sites/judging/public/manifest.webmanifest
similarity index 100%
rename from judging/web/public/manifest.webmanifest
rename to sites/judging/public/manifest.webmanifest
diff --git a/judging/web/tsconfig.json b/sites/judging/tsconfig.json
similarity index 100%
rename from judging/web/tsconfig.json
rename to sites/judging/tsconfig.json
From 152e261a342c200e6dba380fa15f125f3b02219d Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Thu, 8 Oct 2026 15:06:23 -0400
Subject: [PATCH 5/8] feat(panel): run judging inside the portal
Judging no longer has its own server or web app. mainweb mounts the judging
API at /api/panel and resolves the caller from the portal session: staff
organize, volunteers volunteer, and a judge is matched by the email on their
portal judge row for a panel edition. The desk, organizer console, public
board and feedback card are portal routes (/judge/panel, /admin/judging/panel,
/judging), so the ticket handoff, magic-link sign-in and organizer token are
gone.
Club-side sync calls the judging procedures in process instead of over HTTP,
and published results are read straight from the shared database. Live views
poll, since the portal process has no WebSocket hub.
Removed the standalone pieces this leaves unused: sites/judging, the server
entrypoint and WebSocket hub, the two-server check, Dockerfiles, Terraform,
and the PANEL_URL / PANEL_SERVICE_TOKEN / PANEL_WEB_URL settings.
---
.github/workflows/ci.yml | 30 --
PLAN.md | 24 +-
docker-compose.yml | 23 -
docs/judging/README.md | 65 +--
docs/judging/adr/001-standalone.md | 2 +-
docs/judging/contributing.md | 6 +-
docs/judging/deploy.md | 24 +-
packages/api/package.json | 5 +-
packages/api/src/routers/judge/portal.ts | 13 +-
packages/api/src/services/panel-sync.ts | 108 +---
packages/api/src/services/panel.ts | 123 +++++
packages/judging-db/src/client.ts | 5 +-
packages/judging-server/Dockerfile | 14 -
packages/judging-server/package.json | 6 +-
packages/judging-server/src/app.test.ts | 22 +
packages/judging-server/src/app.ts | 32 +-
.../judging-server/src/cross-server.check.ts | 111 ----
packages/judging-server/src/env.ts | 11 -
packages/judging-server/src/hub.ts | 31 --
packages/judging-server/src/index.ts | 80 +--
packages/judging-server/terraform/main.tf | 129 -----
pnpm-lock.yaml | 129 +----
sites/judging/.gitignore | 2 -
sites/judging/Dockerfile | 29 --
.../app/j/[orgSlug]/[eventSlug]/desk.tsx | 474 ------------------
.../app/j/[orgSlug]/[eventSlug]/page.tsx | 16 -
sites/judging/app/layout.tsx | 30 --
.../app/o/[orgSlug]/[eventSlug]/page.tsx | 16 -
sites/judging/app/page.tsx | 8 -
sites/judging/e2e/visit.spec.ts | 61 ---
sites/judging/eslint.config.mjs | 8 -
sites/judging/lib/api.ts | 15 -
sites/judging/next.config.ts | 7 -
sites/judging/package.json | 26 -
sites/judging/playwright.config.ts | 9 -
sites/judging/public/manifest.webmanifest | 8 -
sites/judging/tsconfig.json | 11 -
.../app/(portal)/admin/judging/page.tsx | 5 +-
.../panel}/[orgSlug]/[eventSlug]/console.tsx | 143 ++++--
.../panel}/[orgSlug]/[eventSlug]/editors.tsx | 166 ++++--
.../panel/[orgSlug]/[eventSlug]/page.tsx | 16 +
.../app/(portal)/api/panel/[...path]/route.ts | 24 +
sites/mainweb/app/(portal)/judge/page.tsx | 4 +-
.../panel/[orgSlug]/[eventSlug]/desk.tsx | 440 ++++++++++++++++
.../panel/[orgSlug]/[eventSlug]/page.tsx | 24 +
.../judging}/[orgSlug]/[eventSlug]/board.tsx | 47 +-
.../[eventSlug]/feedback/[token]/page.tsx | 21 +-
.../judging}/[orgSlug]/[eventSlug]/page.tsx | 13 +-
.../lib/held.ts => mainweb/lib/panel-held.ts} | 0
sites/mainweb/lib/panel.server.ts | 16 +
sites/mainweb/lib/panel.ts | 10 +
sites/mainweb/next.config.mjs | 10 +-
turbo.json | 7 -
53 files changed, 1085 insertions(+), 1574 deletions(-)
create mode 100644 packages/api/src/services/panel.ts
delete mode 100644 packages/judging-server/Dockerfile
delete mode 100644 packages/judging-server/src/cross-server.check.ts
delete mode 100644 packages/judging-server/src/env.ts
delete mode 100644 packages/judging-server/src/hub.ts
delete mode 100644 packages/judging-server/terraform/main.tf
delete mode 100644 sites/judging/.gitignore
delete mode 100644 sites/judging/Dockerfile
delete mode 100644 sites/judging/app/j/[orgSlug]/[eventSlug]/desk.tsx
delete mode 100644 sites/judging/app/j/[orgSlug]/[eventSlug]/page.tsx
delete mode 100644 sites/judging/app/layout.tsx
delete mode 100644 sites/judging/app/o/[orgSlug]/[eventSlug]/page.tsx
delete mode 100644 sites/judging/app/page.tsx
delete mode 100644 sites/judging/e2e/visit.spec.ts
delete mode 100644 sites/judging/eslint.config.mjs
delete mode 100644 sites/judging/lib/api.ts
delete mode 100644 sites/judging/next.config.ts
delete mode 100644 sites/judging/package.json
delete mode 100644 sites/judging/playwright.config.ts
delete mode 100644 sites/judging/public/manifest.webmanifest
delete mode 100644 sites/judging/tsconfig.json
rename sites/{judging/app/o => mainweb/app/(portal)/admin/judging/panel}/[orgSlug]/[eventSlug]/console.tsx (77%)
rename sites/{judging/app/o => mainweb/app/(portal)/admin/judging/panel}/[orgSlug]/[eventSlug]/editors.tsx (66%)
create mode 100644 sites/mainweb/app/(portal)/admin/judging/panel/[orgSlug]/[eventSlug]/page.tsx
create mode 100644 sites/mainweb/app/(portal)/api/panel/[...path]/route.ts
create mode 100644 sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx
create mode 100644 sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/page.tsx
rename sites/{judging/app/e => mainweb/app/judging}/[orgSlug]/[eventSlug]/board.tsx (64%)
rename sites/{judging/app/e => mainweb/app/judging}/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx (71%)
rename sites/{judging/app/e => mainweb/app/judging}/[orgSlug]/[eventSlug]/page.tsx (62%)
rename sites/{judging/lib/held.ts => mainweb/lib/panel-held.ts} (100%)
create mode 100644 sites/mainweb/lib/panel.server.ts
create mode 100644 sites/mainweb/lib/panel.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 899084da..d0d58879 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,7 +7,6 @@ name: CI/CD
on:
push:
branches: ["main", "dev"]
- tags: ["panel-v*"]
pull_request:
concurrency:
@@ -108,32 +107,3 @@ jobs:
projectId: dsgt-website
channelId: live
entryPoint: "."
-
- publish-judging:
- name: Publish judging images
- needs: verify
- if: startsWith(github.ref, 'refs/tags/panel-v')
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- steps:
- - uses: actions/checkout@v6
- - uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
- - uses: docker/build-push-action@v6
- with:
- context: .
- file: packages/judging-server/Dockerfile
- push: true
- tags: ghcr.io/${{ github.repository }}/panel-server:${{ github.ref_name }}
- - uses: docker/build-push-action@v6
- with:
- context: .
- file: sites/judging/Dockerfile
- push: true
- build-args: NEXT_PUBLIC_PANEL_URL=${{ vars.NEXT_PUBLIC_PANEL_URL || 'http://localhost:8787' }}
- tags: ghcr.io/${{ github.repository }}/panel-web:${{ github.ref_name }}
diff --git a/PLAN.md b/PLAN.md
index ba92e79f..1680889d 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -3,7 +3,8 @@
**Repo:** `query` · **Drafted:** 2026-10-07 · **Working name:** `panel` (rename freely)
A hackathon judging platform built as ordinary workspaces in this monorepo
-(`packages/judging-*`, `sites/judging`, scope `@query/judging-*`). Hacklytics
+(`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
@@ -49,12 +50,13 @@ packages/judging-db/ @query/judging-db Drizzle schema, SQL migrations
packages/judging-server/ @query/judging-server Node service: tRPC over HTTP,
OpenAPI mirror, WebSocket hub, auth
adapters, outbox + webhooks,
- Prometheus metrics. Dockerfile and
- optional Terraform (terraform/)
+ Prometheus metrics. A library:
+ mainweb mounts it at /api/panel
packages/judging-cli/ @query/judging-cli import/export/seed/migrate/doctor
-sites/judging/ @query/judging-web Next.js: judge PWA, admin console,
- live board, public leaderboard,
- team feedback. Dockerfile
+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
```
@@ -297,15 +299,13 @@ Thin and replaceable.
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.
-- `sites/mainweb` `/judge` becomes a trusted-JWT handoff to the judge PWA.
- Admin "Judging" tab deep-links into the console with the same handoff.
+- 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: `@query/judging-server` and `@query/judging-web` as two Cloud Run services in the
- same GCP project, Postgres as a second Neon database (not a schema in the
- club DB, so the extraction is clean), Memorystore Redis only if more than
- one server instance is wanted. Terraform under `packages/judging-server/terraform`.
+- Deploy: ships with mainweb. The API is `/api/panel` in the same process,
+ on the club Postgres (ADR-003). No separate service.
---
diff --git a/docker-compose.yml b/docker-compose.yml
index 32676ea2..8feef538 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -116,29 +116,6 @@ services:
timeout: 5s
retries: 10
- # ── Judging ──────────────────────────────────────────────────────────────
- #
- # The judging server on the same Postgres as the club site:
- #
- # docker compose --profile judging up -d --build
-
- judging-server:
- build:
- context: .
- dockerfile: packages/judging-server/Dockerfile
- container_name: monorepo-judging-server
- profiles: ["judging"]
- environment:
- DATABASE_URL: postgresql://postgres:postgres@db:5432/neondb
- PANEL_JWT_SECRET: dev-only-change-me
- PANEL_DEV_AUTH: "1"
- PORT: "8787"
- ports:
- - "8787:8787"
- depends_on:
- db:
- condition: service_healthy
-
volumes:
pgdata:
promdata:
diff --git a/docs/judging/README.md b/docs/judging/README.md
index 613eb7b7..5043f260 100644
--- a/docs/judging/README.md
+++ b/docs/judging/README.md
@@ -1,14 +1,8 @@
# Panel
-A hackathon judging product: its own data, its own API, its own web app.
-Rubrics, tracks, timers, rooms, and dispatch strategy are rows, not code.
-Postgres and Node are enough to run it. Set `REDIS_URL` when more than one
-server process should share live updates. Without it, each process fans out
-in memory, and a client that misses a message reloads the same state from
-the next snapshot.
-
-Panel is part of this monorepo. Its packages are ordinary workspaces next to
-the club's, and `packages/api` calls `@query/judging-core` directly.
+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
@@ -16,40 +10,51 @@ the club's, and `packages/api` calls `@query/judging-core` directly.
| --- | --- | --- |
| `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` | HTTP API: dispatch, votes, health, metrics. |
-| `sites/judging/` | `@query/judging-web` | Judge desk, organizer console, public board. |
-| `packages/judging-cli/` | `@query/judging-cli` | `panel migrate`, `panel seed demo`, `panel doctor`. |
+| `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. |
-## Run the core
+## In the portal
-From the repository root:
+| Route | Who | What |
+| --- | --- | --- |
+| `/api/panel/*` | the pages below | The judging API. Same routes as `docs/judging/api.md`, without the prefix. |
+| `/judge/panel/[org]/[event]` | approved judges | Judge desk: next table, QR or table number, scores, pairwise, offline hold. |
+| `/admin/judging/panel/[org]/[event]` | staff | Organizer console: phases, floor, tables, rubric, tracks, judges, results. |
+| `/judging/[org]/[event]` | anyone | Public board while judging, placements after publish. |
+| `/judging/[org]/[event]/feedback/[token]` | a team | That team's feedback card after publish. |
-```
-pnpm --filter @query/judging-core test
-pnpm --filter @query/judging-core typecheck
-pnpm --filter @query/judging-core lint
-```
+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 for an edition whose
+`judging_backend` is `panel`. Projects and judges are copied in process when
+an organizer promotes submissions or approves a judge, and published results
+are pulled back into `hackathon_result`.
-`pnpm test` includes `@query/judging-core` and `@query/judging-server`.
+Set `PANEL_EVENT_ID`, `PANEL_ORG_SLUG`, and `PANEL_EVENT_SLUG` to the judging
+event an edition uses. Without them the portal shows no panel links.
-## Database and server
+Live views poll. There is no WebSocket in the portal process; the board uses
+the event's `board_poll_seconds` and the console polls the floor every five
+seconds.
-Postgres is the one database the rest of the monorepo uses (`DATABASE_URL`). Panel migrations add its tables there. `PANEL_DATABASE_URL` is only a fallback when `DATABASE_URL` is unset.
+## Checks
-```
-docker compose --profile judging up -d --build
-```
+`pnpm lint`, `pnpm typecheck`, and `pnpm test` at the root cover these
+packages with the rest of the repository.
-On the host, pointed at that database:
+## 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"
-$env:PANEL_JWT_SECRET = "dev-only-change-me"
pnpm --filter @query/judging-cli panel migrate
pnpm --filter @query/judging-cli panel seed demo
pnpm --filter @query/judging-cli panel doctor
```
-`panel doctor` checks Postgres, pending migrations, and `PANEL_JWT_SECRET`. Redis is reported and not required.
-
-The HTTP server is Hono. tRPC is mounted at `/trpc`. `GET /healthz` is the process, `GET /readyz` is Postgres, `GET /metrics` is Prometheus. `GET /openapi.json` lists the REST routes. How to authenticate is in `docs/judging/api.md`. Magic-link codes are returned in the response only when `PANEL_DEV_AUTH=1`, which is how the compose stack signs in without SMTP.
+Webhooks queue in `outbox`. Drain them with `panel outbox drain`, on a
+schedule if an event uses webhooks.
diff --git a/docs/judging/adr/001-standalone.md b/docs/judging/adr/001-standalone.md
index f463c613..0c335096 100644
--- a/docs/judging/adr/001-standalone.md
+++ b/docs/judging/adr/001-standalone.md
@@ -3,7 +3,7 @@
## Status
Superseded. Panel's packages are ordinary monorepo workspaces
-(`packages/judging-*`, `sites/judging`) under the `@query/*` scope. The import
+(`packages/judging-*`) under the `@query/*` scope, served by the portal. The import
boundary and the plan to split it out were dropped.
## Decision
diff --git a/docs/judging/contributing.md b/docs/judging/contributing.md
index 3e8919c1..8048ca15 100644
--- a/docs/judging/contributing.md
+++ b/docs/judging/contributing.md
@@ -1,6 +1,6 @@
# Contributing
-Panel is `packages/judging-*` and `sites/judging`. `pnpm lint`, `pnpm typecheck`, and `pnpm test` at the root cover it with the rest of the repository.
+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
@@ -12,10 +12,10 @@ 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 @query/judging-web lint
+pnpm --filter web lint
pnpm --filter @query/judging-db typecheck
pnpm --filter @query/judging-server typecheck
-pnpm --filter @query/judging-web typecheck
+pnpm --filter web typecheck
pnpm --filter @query/judging-cli typecheck
```
diff --git a/docs/judging/deploy.md b/docs/judging/deploy.md
index 706406e2..bbd89485 100644
--- a/docs/judging/deploy.md
+++ b/docs/judging/deploy.md
@@ -1,25 +1,19 @@
# Deploy
-Docker Compose is the deploy to start from. From the repository root:
+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`).
-```
-docker compose --profile judging up -d --build
-```
-
-Then, against the published Postgres port:
+Before the first event on panel, against the production database:
```
-DATABASE_URL=postgresql://postgres:postgres@localhost:5433/neondb
-PANEL_JWT_SECRET=dev-only-change-me
+DATABASE_URL=
pnpm --filter @query/judging-cli panel migrate
-pnpm --filter @query/judging-cli panel seed demo
pnpm --filter @query/judging-cli panel doctor
```
-`panel doctor` must report postgres, migrations, and jwt as ok. Set `DATABASE_URL` to the same Postgres the club app uses. `PANEL_DATABASE_URL` is only read when `DATABASE_URL` is unset. The stack is one API server. Leave `REDIS_URL` unset so live updates stay in that process. Clients that miss a socket message recover from `GET /v1/live`.
-
-The optional GCP module is `packages/judging-server/terraform`. It is not required to run the stack. The server image is `packages/judging-server/Dockerfile` and the web image is `sites/judging/Dockerfile`. Pass `NEXT_PUBLIC_PANEL_URL` at build time so the desk calls the API.
+Then set `PANEL_EVENT_ID`, `PANEL_ORG_SLUG`, and `PANEL_EVENT_SLUG` in the
+mainweb environment (`apphosting.yaml`) and switch the edition's
+`judging_backend` to `panel`.
-Set `PANEL_SMTP_URL` to an SMTP URL when sign-in codes should be emailed.
-Without it, `PANEL_DEV_AUTH=1` returns the code in the response. OIDC waits
-on a provider; trusted JWT from the club site is the handoff that works now.
+`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/packages/api/package.json b/packages/api/package.json
index 2290fd44..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",
diff --git a/packages/api/src/routers/judge/portal.ts b/packages/api/src/routers/judge/portal.ts
index 89dc0d40..2b2b8aa2 100644
--- a/packages/api/src/routers/judge/portal.ts
+++ b/packages/api/src/routers/judge/portal.ts
@@ -69,23 +69,16 @@ export const judgePortalRouter = createTRPCRouter({
return result;
}),
- /** A short-lived ticket into the panel desk, only for an edition that opted in. */
+ /** 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);
}),
- /** Organizer handoff into the panel console when this edition uses panel. */
+ /** 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 }) => {
- const email = ctx.session?.user?.email;
- if (!email) return { url: null as string | null };
- return panelConsoleUrl(ctx.db as DrizzleDB, {
- hackathonId: input.hackathonId,
- userId: ctx.userId as string,
- email,
- name: ctx.session?.user?.name ?? email,
- });
+ return panelConsoleUrl(ctx.db as DrizzleDB, input.hackathonId);
}),
getMyAssignments: protectedProcedure.query(async ({ ctx }) => {
diff --git a/packages/api/src/services/panel-sync.ts b/packages/api/src/services/panel-sync.ts
index b1f17fac..d88fdc1e 100644
--- a/packages/api/src/services/panel-sync.ts
+++ b/packages/api/src/services/panel-sync.ts
@@ -1,10 +1,11 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { hackathonProjects, hackathonResults, hackathons, judges, judgingProjects } from "@query/db";
-import { createHmac } from "node:crypto";
import type { DrizzleDB } from "@query/db";
+import { publishedPlacements } from "@query/judging-server";
+import { panel, panelAsPortal } from "./panel";
/**
- * Copies an edition into the panel service when that edition has opted in.
+ * 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.
*/
@@ -24,8 +25,9 @@ export async function syncProjectsToPanel(db: DrizzleDB, hackathonId: string) {
inArray(hackathonProjects.status, ["submitted", "judging"]),
),
);
+ const caller = panelAsPortal();
for (const row of rows) {
- await post(target, "project.upsert", {
+ await caller.project.upsert({
eventId: target.eventId,
externalId: row.id,
name: row.name,
@@ -46,7 +48,7 @@ export async function syncJudgeToPanel(
if (!judge.email) return;
const target = await panelTarget(db, judge.hackathonId);
if (!target) return;
- await post(target, "judge.upsert", {
+ await panelAsPortal().judge.upsert({
eventId: target.eventId,
email: judge.email,
name: judge.email,
@@ -58,54 +60,22 @@ export async function syncJudgeToPanel(
}
async function panelTarget(db: DrizzleDB, hackathonId: string) {
- const url = process.env.PANEL_URL;
- const token = process.env.PANEL_SERVICE_TOKEN;
const eventId = process.env.PANEL_EVENT_ID;
- if (!url || !token || !eventId) return null;
+ if (!eventId) return null;
const [row] = await db
.select({ backend: hackathons.judgingBackend })
.from(hackathons)
.where(eq(hackathons.id, hackathonId));
if (row?.backend !== "panel") return null;
- return { url, token, eventId };
-}
-
-async function post(
- target: { url: string; token: string },
- procedure: string,
- body: unknown,
-) {
- const response = await fetch(`${target.url.replace(/\/$/, "")}/trpc/${procedure}`, {
- method: "POST",
- headers: {
- authorization: `Bearer ${target.token}`,
- "content-type": "application/json",
- },
- body: JSON.stringify(body),
- });
- if (!response.ok) {
- throw new Error(`panel ${procedure} returned ${response.status}`);
- }
+ return { eventId };
}
export async function pullPanelResults(db: DrizzleDB, hackathonId: string) {
const target = await panelTarget(db, hackathonId);
if (!target) return 0;
- const response = await fetch(
- `${target.url.replace(/\/$/, "")}/v1/results/${target.eventId}`,
- { headers: { authorization: `Bearer ${target.token}` } },
- );
- if (response.status === 403) throw new Error("Panel results are not published");
- if (!response.ok) throw new Error(`panel results returned ${response.status}`);
- const body = (await response.json()) as {
- rows: {
- externalId: string | null;
- placement: number | null;
- score: number;
- track: string;
- voteCount: number;
- }[];
- };
+ 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)
@@ -150,11 +120,9 @@ export async function pullPanelResults(db: DrizzleDB, hackathonId: string) {
}
export async function panelDeskUrl(db: DrizzleDB, userId: string) {
- const web = process.env.PANEL_WEB_URL;
- const secret = process.env.PANEL_JWT_SECRET;
const org = process.env.PANEL_ORG_SLUG;
const eventSlug = process.env.PANEL_EVENT_SLUG;
- if (!web || !secret || !org || !eventSlug) return { url: null as string | null };
+ if (!org || !eventSlug) return { url: null as string | null };
const [row] = await db
.select({ email: judges.email })
.from(judges)
@@ -167,59 +135,17 @@ export async function panelDeskUrl(db: DrizzleDB, userId: string) {
),
);
if (!row?.email) return { url: null };
- const now = Math.floor(Date.now() / 1000);
- const ticket = signHs256(
- {
- sub: userId,
- email: row.email,
- name: row.email,
- org,
- role: null,
- judge_external_id: null,
- iat: now,
- exp: now + 60 * 30,
- },
- secret,
- );
- const url = `${web.replace(/\/$/, "")}/j/${org}/${eventSlug}?ticket=${encodeURIComponent(ticket)}`;
- return { url };
+ return { url: `/judge/panel/${org}/${eventSlug}` };
}
-export async function panelConsoleUrl(
- db: DrizzleDB,
- input: { hackathonId: string; userId: string; email: string; name: string },
-) {
- const web = process.env.PANEL_WEB_URL;
- const secret = process.env.PANEL_JWT_SECRET;
+export async function panelConsoleUrl(db: DrizzleDB, hackathonId: string) {
const org = process.env.PANEL_ORG_SLUG;
const eventSlug = process.env.PANEL_EVENT_SLUG;
- if (!web || !secret || !org || !eventSlug) return { url: null as string | null };
+ if (!org || !eventSlug) return { url: null as string | null };
const [row] = await db
.select({ backend: hackathons.judgingBackend })
.from(hackathons)
- .where(eq(hackathons.id, input.hackathonId));
+ .where(eq(hackathons.id, hackathonId));
if (row?.backend !== "panel") return { url: null };
- const now = Math.floor(Date.now() / 1000);
- const ticket = signHs256(
- {
- sub: input.userId,
- email: input.email,
- name: input.name,
- org,
- role: "organizer",
- judge_external_id: null,
- iat: now,
- exp: now + 60 * 30,
- },
- secret,
- );
- const url = `${web.replace(/\/$/, "")}/o/${org}/${eventSlug}?ticket=${encodeURIComponent(ticket)}`;
- return { url };
-}
-
-function signHs256(payload: Record, secret: string) {
- const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
- const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
- const signature = createHmac("sha256", secret).update(`${header}.${body}`).digest("base64url");
- return `${header}.${body}.${signature}`;
+ return { url: `/admin/judging/panel/${org}/${eventSlug}` };
}
diff --git a/packages/api/src/services/panel.ts b/packages/api/src/services/panel.ts
new file mode 100644
index 00000000..cfc23202
--- /dev/null
+++ b/packages/api/src/services/panel.ts
@@ -0,0 +1,123 @@
+import { and, eq } from "drizzle-orm";
+import { admins, hackathons, judges } from "@query/db";
+import type { DrizzleDB } from "@query/db";
+import { createDb } from "@query/judging-db";
+import type { PanelDb } from "@query/judging-db";
+import {
+ InMemoryBus,
+ appRouter,
+ createApp,
+ createMetrics,
+} from "@query/judging-server";
+import type { Actor, Role } from "@query/judging-server";
+import { createContext } from "../context";
+import {
+ isBugTesterRole,
+ isExpiredAdmin,
+ isStaffRole,
+} from "../types/portal-context";
+
+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. One instance per process, so the metrics
+ * registry and the pool are not rebuilt on every request.
+ */
+export function panel(): Embedded {
+ if (globalForPanel.panel) return globalForPanel.panel;
+ const url = process.env.DATABASE_URL;
+ if (!url) throw new Error("DATABASE_URL is not set");
+ const { db } = createDb(url, {
+ ssl:
+ process.env.NODE_ENV === "production"
+ ? { rejectUnauthorized: true }
+ : undefined,
+ });
+ 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: process.env.PANEL_ORG_SLUG ?? null,
+ 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/judging-db/src/client.ts b/packages/judging-db/src/client.ts
index 6824b41c..a658b8c9 100644
--- a/packages/judging-db/src/client.ts
+++ b/packages/judging-db/src/client.ts
@@ -1,9 +1,10 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
+import type { PoolConfig } from "pg";
import * as schema from "./schema";
-export function createDb(url: string) {
- const pool = new Pool({ connectionString: url, max: 10 });
+export function createDb(url: string, options: PoolConfig = {}) {
+ const pool = new Pool({ connectionString: url, max: 10, ...options });
pool.on("error", () => {
// The pool drops a dead client on its own. An unhandled "error" event
// would take the process down.
diff --git a/packages/judging-server/Dockerfile b/packages/judging-server/Dockerfile
deleted file mode 100644
index 78dbfbd3..00000000
--- a/packages/judging-server/Dockerfile
+++ /dev/null
@@ -1,14 +0,0 @@
-FROM node:22-bookworm-slim
-
-WORKDIR /app
-RUN corepack enable
-
-COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
-COPY tooling ./tooling
-COPY packages ./packages
-COPY sites ./sites
-
-RUN pnpm install --frozen-lockfile --filter @query/judging-server...
-
-EXPOSE 8787
-CMD ["pnpm", "--filter", "@query/judging-server", "start"]
diff --git a/packages/judging-server/package.json b/packages/judging-server/package.json
index 141ef278..da7cabd3 100644
--- a/packages/judging-server/package.json
+++ b/packages/judging-server/package.json
@@ -4,16 +4,15 @@
"private": true,
"type": "module",
"exports": {
+ ".": "./src/index.ts",
"./outbox": "./src/services/outbox.ts"
},
"scripts": {
- "start": "tsx src/index.ts",
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc --noEmit",
"test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts"
},
"dependencies": {
- "@hono/node-server": "^1.19.0",
"@query/judging-core": "workspace:*",
"@query/judging-db": "workspace:*",
"@trpc/server": "11.19.0",
@@ -23,15 +22,12 @@
"jose": "^6.1.0",
"nodemailer": "^10.0.10",
"prom-client": "15.1.3",
- "tsx": "^4.23.15",
- "ws": "8.20.1",
"zod": "4.6.5"
},
"devDependencies": {
"@query/eslint-config": "workspace:*",
"@query/tsconfig": "workspace:*",
"@types/node": "22.20.4",
- "@types/ws": "^8.18.1",
"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
index 9d445519..d31f2ae7 100644
--- a/packages/judging-server/src/app.test.ts
+++ b/packages/judging-server/src/app.test.ts
@@ -16,3 +16,25 @@ describe("health", () => {
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
index b9d19367..e6718151 100644
--- a/packages/judging-server/src/app.ts
+++ b/packages/judging-server/src/app.ts
@@ -8,7 +8,7 @@ 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 { Role } from "./auth";
+import type { Actor, Role } from "./auth";
import type { Bus } from "./bus";
import { InMemoryBus } from "./bus";
import { feedbackCard, feedbackTokenForExternal, actorForApiKey } from "./services/catalog";
@@ -64,6 +64,8 @@ export function createApp(options: {
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();
@@ -215,7 +217,9 @@ export function createApp(options: {
return c.json(row);
});
- const contextFor = async (c: { req: { header: (name: string) => string | undefined } }): Promise => {
+ 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 ")) {
@@ -228,6 +232,8 @@ export function createApp(options: {
} 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 };
};
@@ -329,31 +335,13 @@ export function createApp(options: {
});
app.get("/v1/results/run/:runId", async (c) => {
- const header = c.req.header("authorization");
- if (!header?.startsWith("Bearer ")) return c.json({ message: "Unauthorized" }, 401);
- try {
- await verifyActor(header.slice("Bearer ".length), {
- secret: options.jwtSecret,
- jwksUrl: options.jwksUrl,
- });
- } catch {
- return c.json({ message: "Unauthorized" }, 401);
- }
+ 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) => {
- const header = c.req.header("authorization");
- if (!header?.startsWith("Bearer ")) return c.json({ message: "Unauthorized" }, 401);
- try {
- await verifyActor(header.slice("Bearer ".length), {
- secret: options.jwtSecret,
- jwksUrl: options.jwksUrl,
- });
- } catch {
- return c.json({ message: "Unauthorized" }, 401);
- }
+ 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);
diff --git a/packages/judging-server/src/cross-server.check.ts b/packages/judging-server/src/cross-server.check.ts
deleted file mode 100644
index dff233ef..00000000
--- a/packages/judging-server/src/cross-server.check.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import { WebSocket } from "ws";
-import { eq } from "drizzle-orm";
-import { createDb, event, judge, organization } from "@query/judging-db";
-import { signActor } from "./auth";
-import { log } from "./log";
-
-const databaseUrl = process.env.DATABASE_URL ?? process.env.PANEL_DATABASE_URL;
-const secret = process.env.PANEL_JWT_SECRET;
-const portA = process.env.PORT_A ?? "8787";
-const portB = process.env.PORT_B ?? portA;
-if (!databaseUrl || !secret) {
- throw new Error("DATABASE_URL and PANEL_JWT_SECRET are required");
-}
-
-const { db, pool } = createDb(databaseUrl);
-const [demo] = await db
- .select({ eventId: event.id, org: organization.slug })
- .from(event)
- .innerJoin(organization, eq(organization.id, event.orgId))
- .where(eq(organization.slug, "demo"));
-if (!demo) throw new Error("demo event missing");
-
-const judges = await db.select().from(judge).where(eq(judge.eventId, demo.eventId));
-await pool.end();
-
-const baseA = `http://127.0.0.1:${portA}`;
-const socket = new WebSocket(
- `ws://127.0.0.1:${portB}/ws?channel=event:${demo.eventId}:board`,
-);
-await new Promise((resolve, reject) => {
- socket.once("open", () => resolve());
- socket.once("error", reject);
-});
-
-let accept = false;
-const heard = new Promise((resolve, reject) => {
- const timer = setTimeout(() => reject(new Error("no vote on the other server within 1s")), 15000);
- socket.on("message", (data) => {
- const text = String(data);
- if (!accept || !text.includes("vote.cast")) return;
- clearTimeout(timer);
- resolve(text);
- });
-});
-
-let voted = false;
-for (const row of judges) {
- const token = await signActor(
- {
- sub: row.id,
- email: row.email,
- name: row.name,
- org: demo.org,
- role: null,
- judgeExternalId: null,
- },
- secret,
- 600,
- );
- const headers = {
- authorization: `Bearer ${token}`,
- "content-type": "application/json",
- };
- const next = await fetch(`${baseA}/v1/session/next`, {
- method: "POST",
- headers,
- body: JSON.stringify({ eventId: demo.eventId }),
- });
- const visit = (await next.json()) as {
- done?: boolean;
- visitId?: string;
- tableNumber?: number | null;
- message?: string;
- };
- if (!next.ok || visit.done || !visit.visitId) continue;
- await fetch(`${baseA}/v1/session/arrive`, {
- method: "POST",
- headers,
- body: JSON.stringify({ eventId: demo.eventId, tableNumber: visit.tableNumber }),
- });
- const rubricResponse = await fetch(
- `${baseA}/v1/session/rubric?eventId=${demo.eventId}`,
- { headers },
- );
- const rubric = (await rubricResponse.json()) as { id: string; min: number }[];
- if (!Array.isArray(rubric) || rubric.length === 0) continue;
- accept = true;
- const vote = await fetch(`${baseA}/v1/session/vote`, {
- method: "POST",
- headers,
- body: JSON.stringify({
- eventId: demo.eventId,
- visitId: visit.visitId,
- comment: null,
- scores: rubric.map((item) => ({ criterionId: item.id, value: item.min })),
- }),
- });
- if (!vote.ok) continue;
- voted = true;
- break;
-}
-
-if (!voted) throw new Error("no judge could cast a vote");
-const message = await Promise.race([
- heard,
- new Promise((_, reject) => {
- setTimeout(() => reject(new Error("no vote on the other server within 1s")), 1000);
- }),
-]);
-log({ message });
-socket.close();
diff --git a/packages/judging-server/src/env.ts b/packages/judging-server/src/env.ts
deleted file mode 100644
index 4240ad98..00000000
--- a/packages/judging-server/src/env.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export function serverEnv() {
- return {
- databaseUrl: process.env.DATABASE_URL ?? process.env.PANEL_DATABASE_URL ?? "",
- jwtSecret: process.env.PANEL_JWT_SECRET ?? "",
- jwksUrl: process.env.PANEL_JWKS_URL,
- devAuth: process.env.PANEL_DEV_AUTH === "1",
- smtpUrl: process.env.PANEL_SMTP_URL,
- port: Number(process.env.PORT ?? 8787),
- redisUrl: process.env.REDIS_URL,
- };
-}
diff --git a/packages/judging-server/src/hub.ts b/packages/judging-server/src/hub.ts
deleted file mode 100644
index 3c97c38a..00000000
--- a/packages/judging-server/src/hub.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import type { Server } from "node:http";
-import { WebSocketServer } from "ws";
-import type { Bus } from "./bus";
-import type { Metrics } from "./metrics";
-
-/** Clients subscribe with `?channel=event:{id}:board`. The bus is the only fan-out. */
-export function attachHub(server: Server, bus: Bus, metrics: Metrics) {
- const sockets = new WebSocketServer({ server, path: "/ws" });
- sockets.on("connection", (socket, request) => {
- const channel = new URL(request.url ?? "/", "http://localhost").searchParams.get(
- "channel",
- );
- metrics.wsConnections.inc();
- if (!channel) {
- socket.close();
- metrics.wsConnections.dec();
- return;
- }
- const unsubscribe = bus.subscribe(channel, (message) => {
- if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
- });
- socket.on("close", () => {
- unsubscribe();
- metrics.wsConnections.dec();
- });
- });
- bus.onDown(() => {
- for (const client of sockets.clients) client.close();
- });
- return sockets;
-}
diff --git a/packages/judging-server/src/index.ts b/packages/judging-server/src/index.ts
index 1ef346c6..3f3beebb 100644
--- a/packages/judging-server/src/index.ts
+++ b/packages/judging-server/src/index.ts
@@ -1,73 +1,7 @@
-import { serve } from "@hono/node-server";
-import type { Server } from "node:http";
-import { sql } from "drizzle-orm";
-import { boardChannel, judgeChannel } from "@query/judging-core";
-import { createDb } from "@query/judging-db";
-import { createBus } from "./bus";
-import { attachHub } from "./hub";
-import { createApp } from "./app";
-import { serverEnv } from "./env";
-import { log } from "./log";
-import { createMetrics } from "./metrics";
-import { drainOutbox } from "./services/outbox";
-import { loadOpenVisits, visitsOverTarget } from "./services/overtime";
-
-const env = serverEnv();
-if (!env.databaseUrl) {
- log({ level: "error", message: "DATABASE_URL is not set" });
- process.exit(1);
-}
-
-const { db } = createDb(env.databaseUrl);
-const metrics = createMetrics();
-const bus = createBus(env.redisUrl);
-const app = createApp({
- db,
- metrics,
- jwtSecret: env.jwtSecret,
- jwksUrl: env.jwksUrl,
- devAuth: env.devAuth,
- smtpUrl: env.smtpUrl,
- busMode: env.redisUrl ? "redis" : "memory",
- bus,
-});
-
-const notedOvertime = new Set();
-const timer = setInterval(() => {
- drainOutbox(db)
- .then(async () => {
- const pending = await db.execute<{ count: string }>(
- sql`select count(*)::text as count from outbox where delivered_at is null`,
- );
- const count = Number(pending.rows[0]?.count ?? 0);
- metrics.outboxPending.set(Number.isFinite(count) ? count : 0);
- })
- .catch((error: unknown) => {
- log({
- level: "error",
- message: error instanceof Error ? error.message : "outbox",
- });
- });
- loadOpenVisits(db)
- .then((rows) => {
- for (const row of visitsOverTarget(rows, new Date())) {
- if (notedOvertime.has(row.visitId)) continue;
- notedOvertime.add(row.visitId);
- const message = { kind: "judge.overtime", eventId: row.eventId, visitId: row.visitId };
- bus.publish(judgeChannel(row.judgeId), message);
- bus.publish(boardChannel(row.eventId), message);
- }
- })
- .catch((error: unknown) => {
- log({
- level: "error",
- message: error instanceof Error ? error.message : "overtime",
- });
- });
-}, 2000);
-timer.unref();
-
-const server = serve({ fetch: app.fetch, port: env.port }, () => {
- log({ level: "info", message: "listening", port: env.port });
-});
-attachHub(server as Server, bus, metrics);
+export { createApp } from "./app";
+export { appRouter } from "./router";
+export type { Context } from "./router";
+export type { Actor, Role } from "./auth";
+export { InMemoryBus } from "./bus";
+export { createMetrics } from "./metrics";
+export { publishedPlacements } from "./services/results";
diff --git a/packages/judging-server/terraform/main.tf b/packages/judging-server/terraform/main.tf
deleted file mode 100644
index ad1f1989..00000000
--- a/packages/judging-server/terraform/main.tf
+++ /dev/null
@@ -1,129 +0,0 @@
-variable "project" {
- type = string
-}
-
-variable "region" {
- type = string
- default = "us-east1"
-}
-
-variable "server_image" {
- type = string
-}
-
-variable "web_image" {
- type = string
-}
-
-variable "database_url_secret" {
- type = string
- description = "Secret Manager secret id holding DATABASE_URL, the shared Postgres database."
-}
-
-resource "google_cloud_run_v2_service" "server" {
- name = "panel-server"
- location = var.region
- project = var.project
-
- template {
- containers {
- image = var.server_image
- ports {
- container_port = 8787
- }
- env {
- name = "DATABASE_URL"
- value_source {
- secret_key_ref {
- secret = var.database_url_secret
- version = "latest"
- }
- }
- }
- }
- }
-}
-
-resource "google_cloud_run_v2_service" "web" {
- name = "panel-web"
- location = var.region
- project = var.project
-
- template {
- containers {
- image = var.web_image
- env {
- name = "NEXT_PUBLIC_PANEL_URL"
- value = google_cloud_run_v2_service.server.uri
- }
- }
- }
-}
-
-variable "alert_email" {
- type = string
- default = ""
-}
-
-locals {
- server_host = trimprefix(google_cloud_run_v2_service.server.uri, "https://")
-}
-
-resource "google_monitoring_uptime_check_config" "readyz" {
- display_name = "panel readyz"
- timeout = "10s"
- period = "60s"
- project = var.project
-
- http_check {
- path = "/readyz"
- port = "443"
- use_ssl = true
- validate_ssl = true
- }
-
- monitored_resource {
- type = "uptime_url"
- labels = {
- project_id = var.project
- host = local.server_host
- }
- }
-}
-
-resource "google_monitoring_notification_channel" "email" {
- count = var.alert_email == "" ? 0 : 1
- display_name = "panel alerts"
- type = "email"
- project = var.project
- labels = {
- email_address = var.alert_email
- }
-}
-
-resource "google_monitoring_alert_policy" "readyz" {
- count = var.alert_email == "" ? 0 : 1
- display_name = "panel /readyz"
- combiner = "OR"
- project = var.project
-
- conditions {
- display_name = "readyz check failed"
- condition_threshold {
- filter = "metric.type=\"monitoring.googleapis.com/uptime_check/check_passed\" AND resource.type=\"uptime_url\" AND metric.label.check_id=\"${google_monitoring_uptime_check_config.readyz.uptime_check_id}\""
- duration = "120s"
- comparison = "COMPARISON_LT"
- threshold_value = 1
- }
- }
-
- notification_channels = [google_monitoring_notification_channel.email[0].id]
-}
-
-output "server_uri" {
- value = google_cloud_run_v2_service.server.uri
-}
-
-output "web_uri" {
- value = google_cloud_run_v2_service.web.uri
-}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c09297d0..ecbd1840 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,7 +26,7 @@ importers:
dependencies:
next:
specifier: 16.3.7
- version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
typescript:
specifier: ^7.0.2
version: 7.0.2
@@ -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)
@@ -57,7 +63,7 @@ importers:
version: 11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2)
'@trpc/next':
specifier: 11.19.0
- version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)
+ version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)
'@trpc/react-query':
specifier: 11.19.0
version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2)
@@ -118,7 +124,7 @@ importers:
version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9)
next-auth:
specifier: 5.0.0-beta.32
- version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0)
+ version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0)
nodemailer:
specifier: ^10.0.10
version: 10.0.10
@@ -158,7 +164,7 @@ importers:
version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9)
next-auth:
specifier: 5.0.0-beta.32
- version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0)
+ version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0)
pg:
specifier: 8.23.0
version: 8.23.0
@@ -267,9 +273,6 @@ importers:
packages/judging-server:
dependencies:
- '@hono/node-server':
- specifier: ^1.19.0
- version: 1.19.17(hono@4.13.13)
'@query/judging-core':
specifier: workspace:*
version: link:../judging-core
@@ -297,12 +300,6 @@ importers:
prom-client:
specifier: 15.1.3
version: 15.1.3
- tsx:
- specifier: ^4.23.15
- version: 4.23.15
- ws:
- specifier: ^8.20.1
- version: 8.21.3
zod:
specifier: 4.6.5
version: 4.6.5
@@ -316,9 +313,6 @@ importers:
'@types/node':
specifier: 22.20.4
version: 22.20.4
- '@types/ws':
- specifier: ^8.18.1
- version: 8.18.2
typescript:
specifier: 7.0.2
version: 7.0.2
@@ -370,7 +364,7 @@ importers:
version: 1.0.0
next:
specifier: 16.3.7
- version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
react:
specifier: 19.3.0
version: 19.3.0
@@ -415,40 +409,6 @@ importers:
specifier: ^8.70.1
version: 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2)
- sites/judging:
- dependencies:
- next:
- specifier: 16.3.7
- version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
- react:
- specifier: 19.3.0
- version: 19.3.0
- react-dom:
- specifier: 19.3.0
- version: 19.3.0(react@19.3.0)
- devDependencies:
- '@playwright/test':
- specifier: ^1.58.2
- version: 1.64.0
- '@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/react':
- specifier: ^19.3.0
- version: 19.3.0
- '@types/react-dom':
- specifier: ^19.3.0
- version: 19.3.0(@types/react@19.3.0)
- typescript:
- specifier: 7.0.2
- version: 7.0.2
-
sites/mainweb:
dependencies:
'@google-cloud/storage':
@@ -483,7 +443,7 @@ importers:
version: 11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2)
'@trpc/next':
specifier: 11.19.0
- version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)
+ version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)
'@trpc/react-query':
specifier: 11.19.0
version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2)
@@ -504,7 +464,7 @@ importers:
version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9)
geist:
specifier: ^1.7.2
- version: 1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))
+ version: 1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))
lucide-react:
specifier: ^1.48.0
version: 1.48.0(react@19.3.0)
@@ -513,10 +473,10 @@ importers:
version: 10.2.6
next:
specifier: 16.3.7
- version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
next-auth:
specifier: 5.0.0-beta.32
- version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0)
+ version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
@@ -705,7 +665,7 @@ importers:
version: 4.3.3
next:
specifier: 16.3.7
- version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
postcss:
specifier: ^8.5.23
version: 8.5.28
@@ -1134,12 +1094,6 @@ packages:
resolution: {integrity: sha512-7ckgSjeEprV92VrlLjpYFA3PFerflvFtF7Ea86oQXGNCprGZq5rtpAVK0zgWnCy+44mLjms42M7MiWa433gz2A==}
engines: {node: '>=22'}
- '@hono/node-server@1.19.17':
- resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==}
- engines: {node: '>=18.14.1'}
- peerDependencies:
- hono: ^4
-
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -1564,11 +1518,6 @@ packages:
'@pdf-lib/upng@1.0.1':
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
- '@playwright/test@1.64.0':
- resolution: {integrity: sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==}
- engines: {node: '>=20'}
- hasBin: true
-
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -2379,9 +2328,6 @@ packages:
'@types/webxr@0.5.24':
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
- '@types/ws@8.18.2':
- resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==}
-
'@typescript-eslint/eslint-plugin@8.70.1':
resolution: {integrity: sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4197,16 +4143,6 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
- playwright-core@1.64.0:
- resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
- engines: {node: '>=20'}
- hasBin: true
-
- playwright@1.64.0:
- resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
- engines: {node: '>=20'}
- hasBin: true
-
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
@@ -5418,10 +5354,6 @@ snapshots:
- encoding
- supports-color
- '@hono/node-server@1.19.17(hono@4.13.13)':
- dependencies:
- hono: 4.13.13
-
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -5739,10 +5671,6 @@ snapshots:
dependencies:
pako: 1.0.11
- '@playwright/test@1.64.0':
- dependencies:
- playwright: 1.64.0
-
'@polka/url@1.0.0-next.29': {}
'@radix-ui/number@1.1.3': {}
@@ -6338,11 +6266,11 @@ snapshots:
'@trpc/server': 11.19.0(typescript@7.0.2)
typescript: 7.0.2
- '@trpc/next@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)':
+ '@trpc/next@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)':
dependencies:
'@trpc/client': 11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2)
'@trpc/server': 11.19.0(typescript@7.0.2)
- next: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ next: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
typescript: 7.0.2
@@ -6451,10 +6379,6 @@ snapshots:
'@types/webxr@0.5.24': {}
- '@types/ws@8.18.2':
- dependencies:
- '@types/node': 22.20.4
-
'@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -7651,9 +7575,9 @@ snapshots:
- encoding
- supports-color
- geist@1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)):
+ geist@1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)):
dependencies:
- next: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ next: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
generator-function@2.0.1: {}
@@ -8154,10 +8078,10 @@ snapshots:
natural-compare@1.4.0: {}
- next-auth@5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0):
+ next-auth@5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0):
dependencies:
'@auth/core': 0.41.3(nodemailer@10.0.10)
- next: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ next: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
react: 19.3.0
optionalDependencies:
nodemailer: 10.0.10
@@ -8167,7 +8091,7 @@ snapshots:
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
- next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+ next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
'@next/env': 16.3.7
'@swc/helpers': 0.5.23
@@ -8187,7 +8111,6 @@ snapshots:
'@next/swc-win32-arm64-msvc': 16.3.7
'@next/swc-win32-x64-msvc': 16.3.7
'@opentelemetry/api': 1.9.1
- '@playwright/test': 1.64.0
babel-plugin-react-compiler: 1.0.0
sharp: 0.35.4(@types/node@22.20.4)
transitivePeerDependencies:
@@ -8367,12 +8290,6 @@ snapshots:
picomatch@4.0.7: {}
- playwright-core@1.64.0: {}
-
- playwright@1.64.0:
- dependencies:
- playwright-core: 1.64.0
-
pngjs@5.0.0: {}
possible-typed-array-names@1.1.0: {}
diff --git a/sites/judging/.gitignore b/sites/judging/.gitignore
deleted file mode 100644
index aaa9103e..00000000
--- a/sites/judging/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-test-results/
-playwright-report/
diff --git a/sites/judging/Dockerfile b/sites/judging/Dockerfile
deleted file mode 100644
index 488feb4f..00000000
--- a/sites/judging/Dockerfile
+++ /dev/null
@@ -1,29 +0,0 @@
-FROM node:22-bookworm-slim AS build
-
-WORKDIR /app
-RUN corepack enable
-
-COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
-COPY tooling ./tooling
-COPY packages ./packages
-COPY sites ./sites
-
-ARG NEXT_PUBLIC_PANEL_URL=http://localhost:8787
-ENV NEXT_PUBLIC_PANEL_URL=$NEXT_PUBLIC_PANEL_URL
-
-RUN pnpm install --frozen-lockfile --filter @query/judging-web...
-RUN pnpm --filter @query/judging-web build
-
-FROM node:22-bookworm-slim
-
-WORKDIR /app
-ENV NODE_ENV=production
-ENV HOSTNAME=0.0.0.0
-ENV PORT=3000
-
-COPY --from=build /app/sites/judging/.next/standalone ./
-COPY --from=build /app/sites/judging/.next/static ./sites/judging/.next/static
-COPY --from=build /app/sites/judging/public ./sites/judging/public
-
-EXPOSE 3000
-CMD ["node", "sites/judging/server.js"]
diff --git a/sites/judging/app/j/[orgSlug]/[eventSlug]/desk.tsx b/sites/judging/app/j/[orgSlug]/[eventSlug]/desk.tsx
deleted file mode 100644
index 95cc9083..00000000
--- a/sites/judging/app/j/[orgSlug]/[eventSlug]/desk.tsx
+++ /dev/null
@@ -1,474 +0,0 @@
-"use client";
-
-import { useEffect, useRef, useState } from "react";
-import type { CSSProperties } from "react";
-import { apiBase } from "../../../../lib/api";
-import type { PublicEvent } from "../../../../lib/api";
-import { flushHeld, holdVote } from "../../../../lib/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({
- orgSlug,
- event,
- ticket,
-}: {
- orgSlug: string;
- event: PublicEvent;
- ticket?: string;
-}) {
- const [email, setEmail] = useState("");
- const [code, setCode] = useState("");
- const [token, setToken] = useState("");
- 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 [judgeId, setJudgeId] = useState("");
- const [elapsed, setElapsed] = useState("");
- const [scanning, setScanning] = useState(false);
- const videoRef = useRef(null);
- const accent = event.branding.colors?.accent ?? "#1c1917";
-
- useEffect(() => {
- if (!ticket) return;
- setToken(ticket);
- void fetch(`${apiBase}/v1/session/rubric?eventId=${event.eventId}`, {
- headers: { authorization: `Bearer ${ticket}` },
- })
- .then((response) => response.json())
- .then(async (rows: CriterionRow[]) => {
- if (!Array.isArray(rows)) {
- setMessage("The handoff ticket was not accepted.");
- return;
- }
- setCriteria(rows);
- const progressResponse = await fetch(
- `${apiBase}/v1/session/progress?eventId=${event.eventId}`,
- { headers: { authorization: `Bearer ${ticket}` } },
- );
- if (!progressResponse.ok) return;
- const row = (await progressResponse.json()) as {
- completed: number;
- total: number;
- judgeId?: string;
- };
- setProgress(`${row.completed} of ${row.total} scored`);
- if (row.judgeId) setJudgeId(row.judgeId);
- })
- .catch(() => setMessage("The handoff ticket was not accepted."));
- }, [ticket, event.eventId]);
-
- async function call(path: string, body?: unknown) {
- const response = await fetch(`${apiBase}${path}`, {
- method: body ? "POST" : "GET",
- headers: {
- "content-type": "application/json",
- ...(token ? { authorization: `Bearer ${token}` } : {}),
- },
- body: body ? JSON.stringify(body) : undefined,
- });
- const payload = (await response.json()) as { message?: string };
- if (!response.ok) throw new Error(payload.message ?? "Request failed");
- 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(() => {
- if (!token) return;
- const send = () => {
- void flushHeld((path, payload) =>
- fetch(`${apiBase}${path}`, {
- method: "POST",
- headers: {
- "content-type": "application/json",
- authorization: `Bearer ${token}`,
- },
- body: JSON.stringify(payload),
- }).then((response) => {
- if (!response.ok) throw new Error("still offline");
- }),
- ).then((sent) => {
- if (sent > 0) {
- setMessage(`Sent ${sent} score${sent === 1 ? "" : "s"} held on this phone.`);
- }
- });
- };
- window.addEventListener("online", send);
- return () => window.removeEventListener("online", send);
- }, [token]);
-
- useEffect(() => {
- if (!visit?.handedOutAt || visit.arrivedAt || visit.done) {
- setElapsed("");
- return;
- }
- const started = new Date(visit.handedOutAt).getTime();
- const tick = () => {
- const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000));
- setElapsed(`${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} walking`);
- };
- tick();
- const timer = setInterval(tick, 1000);
- return () => clearInterval(timer);
- }, [visit?.handedOutAt, visit?.arrivedAt, visit?.done]);
-
- useEffect(() => {
- if (!token || !judgeId) return;
- const socket = new WebSocket(
- `${apiBase.replace(/^http/, "ws")}/ws?channel=judge:${judgeId}`,
- );
- socket.onmessage = (incoming) => {
- let payload: { kind?: string };
- try {
- payload = JSON.parse(String(incoming.data)) as { kind?: string };
- } catch {
- return;
- }
- if (payload.kind === "judge.recalled") {
- setVisit(null);
- setMessage("Come back to the desk.");
- }
- if (payload.kind === "visit.voided") {
- setVisit(null);
- setMessage("This visit was voided.");
- }
- if (payload.kind === "judge.overtime") {
- setMessage("Past the target time at this table.");
- }
- };
- return () => socket.close();
- }, [token, judgeId]);
-
- async function loadProgress(bearer: string) {
- const response = await fetch(
- `${apiBase}/v1/session/progress?eventId=${event.eventId}`,
- { headers: { authorization: `Bearer ${bearer}` } },
- );
- if (!response.ok) return;
- const row = (await response.json()) as { completed: number; total: number; judgeId?: string };
- setProgress(`${row.completed} of ${row.total} scored`);
- if (row.judgeId) setJudgeId(row.judgeId);
- }
-
- return (
-
- {event.orgName}
- {event.name}
- {event.branding.tagline}
- Phase: {event.phase}
-
- {!token ? (
-
- ) : (
-
-
-
- {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/judging/app/j/[orgSlug]/[eventSlug]/page.tsx b/sites/judging/app/j/[orgSlug]/[eventSlug]/page.tsx
deleted file mode 100644
index f1db4caa..00000000
--- a/sites/judging/app/j/[orgSlug]/[eventSlug]/page.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { JudgeDesk } from "./desk";
-import { loadEvent } from "../../../../lib/api";
-
-export default async function JudgePage({
- params,
- searchParams,
-}: {
- params: Promise<{ orgSlug: string; eventSlug: string }>;
- searchParams: Promise<{ ticket?: string }>;
-}) {
- const { orgSlug, eventSlug } = await params;
- const { ticket } = await searchParams;
- const event = await loadEvent(orgSlug, eventSlug);
- if (!event) return This event is not available.;
- return ;
-}
diff --git a/sites/judging/app/layout.tsx b/sites/judging/app/layout.tsx
deleted file mode 100644
index 0737340c..00000000
--- a/sites/judging/app/layout.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import type { Metadata, Viewport } from "next";
-
-export const metadata: Metadata = {
- title: "Panel",
- description: "Hackathon judging",
- manifest: "/manifest.webmanifest",
-};
-
-export const viewport: Viewport = {
- width: "device-width",
- initialScale: 1,
- themeColor: "#111111",
-};
-
-export default function RootLayout({ children }: { children: React.ReactNode }) {
- return (
-
-
- {children}
-
-
- );
-}
diff --git a/sites/judging/app/o/[orgSlug]/[eventSlug]/page.tsx b/sites/judging/app/o/[orgSlug]/[eventSlug]/page.tsx
deleted file mode 100644
index df8e1ec7..00000000
--- a/sites/judging/app/o/[orgSlug]/[eventSlug]/page.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { Console } from "./console";
-import { loadEvent } from "../../../../lib/api";
-
-export default async function OrganizerPage({
- params,
- searchParams,
-}: {
- params: Promise<{ orgSlug: string; eventSlug: string }>;
- searchParams: Promise<{ ticket?: string }>;
-}) {
- const { orgSlug, eventSlug } = await params;
- const { ticket } = await searchParams;
- const event = await loadEvent(orgSlug, eventSlug);
- if (!event) return This event is not available.;
- return ;
-}
diff --git a/sites/judging/app/page.tsx b/sites/judging/app/page.tsx
deleted file mode 100644
index 66db153a..00000000
--- a/sites/judging/app/page.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-export default function HomePage() {
- return (
-
- Panel
- Judging for one event. Open a judge desk, the organizer console, or the public board.
-
- );
-}
diff --git a/sites/judging/e2e/visit.spec.ts b/sites/judging/e2e/visit.spec.ts
deleted file mode 100644
index f15aaee8..00000000
--- a/sites/judging/e2e/visit.spec.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { expect, test } from "@playwright/test";
-
-test.use({ viewport: { width: 390, height: 844 } });
-
-test("a phone visit holds one offline score, then the board shows the published order", async ({
- page,
- request,
-}) => {
- await page.goto("/j/demo/demo");
- await page.getByLabel("Email").fill("judge3@demo.panel");
- await page.getByRole("button", { name: "Email me a code" }).click();
- await page.getByRole("button", { name: "Sign in" }).click();
- await page.getByRole("button", { name: "Next table" }).click();
- const heading = page.getByRole("heading", { level: 2 });
- await expect(heading).toBeVisible();
- const table = (await heading.innerText()).match(/table (\d+)/)?.[1];
- expect(table).toBeTruthy();
- await page.getByLabel("Table number").fill(table ?? "");
- await page.getByRole("button", { name: "I'm here" }).click();
- await expect(page.getByText("Arrived.")).toBeVisible();
- for (const name of ["Creativity", "Impact", "Scope", "Clarity", "Soundness"]) {
- await page.getByRole("spinbutton", { name }).fill("6");
- }
-
- await page.context().setOffline(true);
- await page.getByRole("button", { name: "Submit score" }).click();
- await expect(page.getByText("Held on this phone")).toBeVisible();
- await page.context().setOffline(false);
- await page.evaluate(() => window.dispatchEvent(new Event("online")));
- await expect(page.getByText("Sent 1 score")).toBeVisible();
-
- const issued = await request.post("http://localhost:8787/v1/auth/magic-link", {
- data: { email: "organizer@demo.panel" },
- });
- const code = ((await issued.json()) as { code?: string }).code;
- expect(code).toBeTruthy();
- const verified = await request.post("http://localhost:8787/v1/auth/verify", {
- data: { email: "organizer@demo.panel", code, org: "demo" },
- });
- const token = ((await verified.json()) as { token?: string }).token;
- expect(token).toBeTruthy();
-
- await page.setViewportSize({ width: 1280, height: 800 });
- await page.goto("/o/demo/demo");
- await page.getByLabel("Organizer token").fill(token ?? "");
- await page.getByRole("button", { name: "judging_closed" }).click();
- await expect(page.getByText("judging_closed")).toBeVisible();
- await page.getByRole("button", { name: "Compute results" }).click();
- await expect(page.getByText("Results computed")).toBeVisible();
- await page.getByRole("button", { name: "Publish", exact: true }).click();
- await expect(page.getByText("Published.")).toBeVisible();
-
- await page.goto("/e/demo/demo");
- await expect(page.getByRole("listitem").first()).toBeVisible();
-
- await page.goto("/o/demo/demo");
- await page.getByLabel("Organizer token").fill(token ?? "");
- await page.getByRole("button", { name: "judging_closed" }).click();
- await page.getByRole("button", { name: "judging_live" }).click();
- await expect(page.getByText("judging_live")).toBeVisible();
-});
diff --git a/sites/judging/eslint.config.mjs b/sites/judging/eslint.config.mjs
deleted file mode 100644
index 69fbcb17..00000000
--- a/sites/judging/eslint.config.mjs
+++ /dev/null
@@ -1,8 +0,0 @@
-import { config } from "@query/eslint-config/base";
-
-/** @type {import("eslint").Linter.Config[]} */
-export default [
- ...config,
- // Playwright specs sit outside tsconfig, so the typed parser cannot load them.
- { ignores: ["e2e/**"] },
-];
diff --git a/sites/judging/lib/api.ts b/sites/judging/lib/api.ts
deleted file mode 100644
index 4fbb377d..00000000
--- a/sites/judging/lib/api.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-export const apiBase = process.env.NEXT_PUBLIC_PANEL_URL ?? "http://localhost:8787";
-
-export type PublicEvent = {
- eventId: string;
- name: string;
- phase: string;
- branding: { tagline?: string; colors?: { accent?: string } };
- orgName: string;
-};
-
-export async function loadEvent(orgSlug: string, eventSlug: string): Promise {
- const response = await fetch(`${apiBase}/v1/public/${orgSlug}/${eventSlug}`);
- if (!response.ok) return null;
- return response.json() as Promise;
-}
diff --git a/sites/judging/next.config.ts b/sites/judging/next.config.ts
deleted file mode 100644
index 68a6c64d..00000000
--- a/sites/judging/next.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { NextConfig } from "next";
-
-const nextConfig: NextConfig = {
- output: "standalone",
-};
-
-export default nextConfig;
diff --git a/sites/judging/package.json b/sites/judging/package.json
deleted file mode 100644
index 35282b2e..00000000
--- a/sites/judging/package.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "name": "@query/judging-web",
- "version": "0.0.0",
- "private": true,
- "scripts": {
- "dev": "next dev --port 3005",
- "build": "next build",
- "lint": "eslint . --max-warnings 0",
- "typecheck": "tsc --noEmit",
- "e2e": "playwright test"
- },
- "dependencies": {
- "next": "16.3.7",
- "react": "19.3.0",
- "react-dom": "19.3.0"
- },
- "devDependencies": {
- "@query/eslint-config": "workspace:*",
- "@query/tsconfig": "workspace:*",
- "@types/node": "22.20.4",
- "@types/react": "^19.3.0",
- "@types/react-dom": "^19.3.0",
- "typescript": "7.0.2",
- "@playwright/test": "^1.58.2"
- }
-}
diff --git a/sites/judging/playwright.config.ts b/sites/judging/playwright.config.ts
deleted file mode 100644
index 7d21aa52..00000000
--- a/sites/judging/playwright.config.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { defineConfig } from "@playwright/test";
-
-export default defineConfig({
- testDir: "e2e",
- timeout: 60_000,
- use: {
- baseURL: "http://localhost:3005",
- },
-});
diff --git a/sites/judging/public/manifest.webmanifest b/sites/judging/public/manifest.webmanifest
deleted file mode 100644
index cc5c32f8..00000000
--- a/sites/judging/public/manifest.webmanifest
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "name": "Panel",
- "short_name": "Panel",
- "start_url": "/",
- "display": "standalone",
- "background_color": "#f6f5f2",
- "theme_color": "#111111"
-}
diff --git a/sites/judging/tsconfig.json b/sites/judging/tsconfig.json
deleted file mode 100644
index ea5d5185..00000000
--- a/sites/judging/tsconfig.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "extends": "../../tooling/typescript/nextjs.json",
- "compilerOptions": {
- "lib": ["ESNext", "DOM", "DOM.Iterable"],
- "plugins": [{ "name": "next" }],
- "jsx": "preserve",
- "noEmit": true
- },
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules", "e2e", "playwright.config.ts"]
-}
diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx
index ed914c64..fa36d7c7 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";
@@ -257,9 +258,9 @@ export default function AdminResultsPage() {
: "Open and close judging, watch the floor, and compare scores."}
{panelConsole?.url ? (
-
+
Open the panel console
-
+
) : null}
diff --git a/sites/judging/app/o/[orgSlug]/[eventSlug]/console.tsx b/sites/mainweb/app/(portal)/admin/judging/panel/[orgSlug]/[eventSlug]/console.tsx
similarity index 77%
rename from sites/judging/app/o/[orgSlug]/[eventSlug]/console.tsx
rename to sites/mainweb/app/(portal)/admin/judging/panel/[orgSlug]/[eventSlug]/console.tsx
index cda7ce18..fa0813b0 100644
--- a/sites/judging/app/o/[orgSlug]/[eventSlug]/console.tsx
+++ b/sites/mainweb/app/(portal)/admin/judging/panel/[orgSlug]/[eventSlug]/console.tsx
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useState } from "react";
-import { apiBase } from "../../../../lib/api";
-import type { PublicEvent } from "../../../../lib/api";
+import { apiBase } from "@/lib/panel";
+import type { PublicEvent } from "@/lib/panel";
import { Editors } from "./editors";
const phases = [
@@ -15,15 +15,24 @@ const phases = [
"archived",
] as const;
-export function Console({ event, ticket }: { event: PublicEvent; ticket?: string }) {
- const [token, setToken] = useState(ticket ?? "");
+export function Console({ event }: { event: PublicEvent }) {
const [message, setMessage] = useState(event.phase);
const [floor, setFloor] = useState<{
tables: { tableNumber: number | null; looks: number }[];
- judges: { name: string; state: string; overtime: boolean; tableNumber: number | null }[];
+ 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 }[]
+ {
+ id: string;
+ name: string;
+ tableNumber: number | null;
+ zoneName: string | null;
+ }[]
>([]);
const [logs, setLogs] = useState<{ id: string; kind: string }[]>([]);
const [runId, setRunId] = useState("");
@@ -44,10 +53,7 @@ export function Console({ event, ticket }: { event: PublicEvent; ticket?: string
async function post(path: string, body: unknown) {
const response = await fetch(`${apiBase}/trpc/${path}`, {
method: "POST",
- headers: {
- "content-type": "application/json",
- authorization: `Bearer ${token}`,
- },
+ headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = (await response.json()) as {
@@ -61,17 +67,19 @@ export function Console({ event, ticket }: { event: PublicEvent; ticket?: string
}
useEffect(() => {
- if (!token) return;
let stop = false;
const pull = async () => {
- const response = await fetch(`${apiBase}/v1/floor/${event.eventId}`, {
- headers: { authorization: `Bearer ${token}` },
- });
+ 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 }[];
+ judges: {
+ name: string;
+ state: string;
+ overtime: boolean;
+ tableNumber: number | null;
+ }[];
},
);
};
@@ -81,7 +89,7 @@ export function Console({ event, ticket }: { event: PublicEvent; ticket?: string
stop = true;
clearInterval(timer);
};
- }, [token, event.eventId]);
+ }, [event.eventId]);
return (
@@ -90,7 +98,15 @@ export function Console({ event, ticket }: { event: PublicEvent; ticket?: string
{floor ? (
Floor
-
+
{floor.tables.map((table) => (
- (
-
{person.name}: {person.state}
- {person.tableNumber !== null ? ` · table ${person.tableNumber}` : ""}
+ {person.tableNumber !== null
+ ? ` · table ${person.tableNumber}`
+ : ""}
{person.overtime ? " · overtime" : ""}
))}
) : null}
-
{phases.map((phase) => (