diff --git a/.gitignore b/.gitignore index a278d653..cbdd6f3f 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,8 @@ next-env.d.ts *.db *.db-journal migrations/ +!packages/judging-db/migrations/ +!packages/judging-db/migrations/** # Editor .vscode/* diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..1680889d --- /dev/null +++ b/PLAN.md @@ -0,0 +1,444 @@ +# Judging platform plan + +**Repo:** `query` · **Drafted:** 2026-10-07 · **Working name:** `panel` (rename freely) + +A hackathon judging platform built as ordinary workspaces in this monorepo +(`packages/judging-*`, scope `@query/judging-*`) and served by the portal +(`sites/mainweb`). Hacklytics +is its first user. It is not being split into its own repository. + +The current judging code in `packages/api/src/routers/judge` and +`sites/mainweb` keeps running until cutover, then is deleted. + +--- + +## 0. Why build it this way + +**What exists** works for one event: pool dispatch with QR arrival +(`dispatch.ts`), per-judge z-scores with a Bayesian shrink (`rankings.ts`), +frozen results, printable table cards. It is also welded to one edition: a +five-column rubric in `judge_vote`, timers as constants, tracks as `text[]`, +a `"createx"` string in the matcher, one results track, table distance as +`abs(n1 - n2)`, auth and roles borrowed from the club portal, cache +per-instance and polling for every live view. + +**What "real infrastructure" means here** + +1. **A product, not a feature.** Own data model, own API, own UI, own deploy. + Anyone with Postgres can run it. The club site is a client of it. +2. **Everything is data.** Rubric, tracks, prizes, timers, rooms, scoring + weights, dispatch strategy, branding: rows and config, never code. +3. **Correct under load and partition.** Advisory locks and unique indexes for + every write that matters; realtime as a convenience layer that can be + switched off without losing correctness. +4. **Boring and portable.** Postgres, Node, one Docker image, optional Redis. + No vendor SDK in the core. Cloud bits live behind adapters. +5. **Pure core.** Every decision (next table, score, rank) is a function from + plain data to plain data, tested without a database. + +--- + +## 1. Layout + +``` +packages/judging-core/ @query/judging-core pure TS, zero runtime deps. rubric + validation, dispatch, distance, + uncertainty, aggregators, phase + state machine, timers +packages/judging-db/ @query/judging-db Drizzle schema, SQL migrations + (generated, committed), seed, demo +packages/judging-server/ @query/judging-server Node service: tRPC over HTTP, + OpenAPI mirror, WebSocket hub, auth + adapters, outbox + webhooks, + Prometheus metrics. A library: + mainweb mounts it at /api/panel +packages/judging-cli/ @query/judging-cli import/export/seed/migrate/doctor +sites/mainweb/ web portal routes: judge desk + (/judge/panel), organizer console + (/admin/judging/panel), public + board and team feedback (/judging) +docs/judging/ ADRs, deploy, API, rubric guide +``` + +Lint, typecheck, test and build run with the rest of the repo in `ci.yml`. +`packages/api` imports `@query/judging-*` like any other workspace (§7). + +--- + +## 2. Domain model (`@query/judging-db`) + +Multi-tenant from day one so one deployment can host many events; a +single-event self-host simply has one org and one event. + +``` +organization id, slug, name, branding jsonb (logo, colors, tagline) +event id, org_id, slug, name, starts_at, ends_at, + phase enum (setup | submissions_open | submissions_closed | + judging_live | judging_closed | published | archived) +event_config event_id pk, target_seconds, hard_limit_seconds, + walk_limit_seconds, submit_grace_seconds, + min_looks_per_project, dispatch_strategy (coverage|uncertainty), + pairwise_enabled, pairwise_weight, bayesian_c, + calibration_looks, calibration_weight, + feedback_cards_enabled, leaderboard_public bool, + board_poll_seconds (fallback when ws is off) + +track id, event_id, slug, name, kind (main|sponsor|special), + judge_group text, rubric_id null, position, is_active +prize id, track_id, place, title, amount, description + +rubric id, event_id, name, is_default +criterion id, rubric_id, position, key, label, description, + min, max, weight, anchors jsonb + +zone id, event_id, name, position +table event_id, number, zone_id, x null, y null unique(event_id, number) + +project id, event_id, external_id null, name, description, + team_name, members jsonb, links jsonb, table_number null, + zone_id null, qr_token uuid unique, arrived_first_at, + withdrawn_at +project_track project_id, track_id + +judge id, event_id, external_id null, name, email, org, title, + status (invited|applied|approved|suspended), + track_id null, zone_id null, is_lead + unique(event_id, email) + +visit id, event_id, judge_id, project_id, judge_group, + handed_out_at, arrived_at null, completed_at null, + voided_at null, void_reason null +vote id, visit_id unique, judge_id, project_id, event_id, + total numeric, comment, duration_seconds, is_calibration +vote_score vote_id, criterion_id, value unique(vote_id, criterion_id) +comparison id, event_id, judge_id, judge_group, a_project_id, + b_project_id, outcome (a|b|tie), created_at + +result_run id, event_id, computed_at, computed_by, config_snapshot jsonb, + published_at null, notes +result run_id, project_id, track_id, placement, score numeric, + rubric_component, pairwise_component, vote_count, + comparison_count, flags text[] + unique(run_id, project_id, track_id) + +event_log id, event_id, kind, actor jsonb, subject jsonb, payload jsonb, + created_at -- append only +outbox id, event_id, topic, payload jsonb, created_at, + delivered_at null, attempts +webhook id, org_id, url, secret, topics text[], is_active +api_key id, org_id, hashed_key, scopes text[], created_at, revoked_at + +user id, email, name -- platform identities +membership user_id, org_id, role (owner|admin|organizer|volunteer) +judge_identity judge_id, user_id -- which login is which judge +``` + +Design notes: + +- `visit` replaces `judge_queue`: it is a record of a hand-out, not a plan. + A visit without a vote past the hard limit is voided, not deleted. +- `vote.total` is derived at write time from `vote_score` and the criterion + weights; the score rows are the truth. +- `result_run` snapshots the config used, so a result is reproducible and an + admin can compute twice and compare before publishing one. +- `external_id` on project and judge is how an integrator (the club site, a + Devpost CSV, a Google Form) keeps its own identity in sync. +- Migrations are generated SQL files committed under `packages/judging-db/migrations`, + applied with `panel migrate`. No `drizzle-kit push` in this product; a + self-hoster needs reproducible, reviewable migrations. + +--- + +## 3. The core (`@query/judging-core`) + +No IO, no dates from the clock (every function takes `now`), no randomness +without an injected source. Each module has a fixture-based test file. + +- `phase.ts` state machine: allowed transitions, what each phase permits + (submit, apply, dispatch, vote, compute, publish). Server checks it on + every mutation. +- `rubric.ts` `validateScores(criteria, input)`, `weightedTotal(criteria, scores)`. +- `timers.ts` `isLive`, `isPastCutoff`, taking the config. +- `distance.ts` `distance(a, b, zones)`: same zone `abs(n1 - n2)`; different + zone a large constant plus the diff; Euclidean when `x, y` set. +- `dispatch.ts` `pickNext(candidates, judge, config, random)` with strategies: + - `coverage`: fewest looks, nearest, random (today's algorithm). + - `uncertainty`: coverage until `min_looks_per_project` everywhere, then + highest rank-uncertainty first. Uncertainty = variance of normalised + scores / sqrt(votes), plus a bonus for contested pairwise neighbours. +- `aggregate/zscore.ts`, `aggregate/bayes.ts`, `aggregate/bradleyTerry.ts`, + `aggregate/blend.ts`, `aggregate/rank.ts` composing them per track and + judge group, honouring calibration down-weighting. +- `uncertainty.ts` per-project vector, consumed by dispatch. +- `events.ts` typed `kind` union for the log, outbox topics and ws messages, + shared by server and web. + +Published to npm on release so anyone can run the ranking offline on an +export. + +--- + +## 4. Server (`@query/judging-server`) + +Node 22, Hono as the HTTP shell, tRPC router mounted on it, `trpc-openapi` +exposing the same procedures as REST with a generated OpenAPI document for +non-TypeScript integrators. Single process; horizontal scale is stateless +plus Redis. + +**Routers** + +- `org`, `event`, `config`, `track`, `prize`, `rubric`, `zone`, `table`: + organizer CRUD, phase-gated. +- `project`: import (API, CSV via CLI), assign tables (per zone, by track + balance), withdraw, QR token rotate. +- `judge`: invite, apply, approve, suspend, assign track and zone. +- `session` (judge-facing): `next`, `arrive(qrToken)`, `vote`, `compare`, + `skip`, `progress`, `rubric`. +- `results`: `compute` (new `result_run`), `diff(runA, runB)`, `publish`, + `unpublish`, `export`. +- `live`: snapshot for the admin board and public leaderboard. +- `feedback`: a team's card after publish. +- `webhook`, `apiKey`: integrator management. + +**Concurrency** + +- `pg_advisory_xact_lock(hashtext('dispatch:' || event_id))` around `next`, + exactly as today. Dispatch is milliseconds and runs a few times a minute + even at 60 judges. +- Unique indexes on `(judge_id, project_id)` across live visits (partial + index where `voided_at is null`), on `vote.visit_id`, on + `(run_id, project_id, track_id)`. +- Every mutation writes its `event_log` row and any `outbox` row in the same + transaction. The outbox is drained by the same process on a short loop (no + external scheduler) and by `panel outbox drain` for operators who want cron. + +**Realtime** + +- WebSocket hub on `/ws` (`ws` library). Clients subscribe to channels: + `event:{id}:board`, `event:{id}:leaderboard`, `judge:{id}`. +- Fan-out adapter interface: `InMemoryBus` (single instance, default) and + `RedisBus` (ioredis pub/sub) selected by `REDIS_URL`. Correctness never + depends on the bus; a client that misses a message gets the same state on + its next snapshot, and the web falls back to polling at `board_poll_seconds` + when the socket is down. +- Server pushes: dispatch handed out, arrival, vote landed, judge overtime, + phase change, results published. Admin can push `recall` to a judge + (bring them back to the desk) and `void` a visit. + +**Auth adapters** (`server/src/auth/`) + +- `magic-link`: email code, built in, zero config beyond SMTP. +- `oidc`: any provider (Google, GitHub, Okta) via `openid-client`. +- `trusted-jwt`: an integrator (the club site) mints a short-lived JWT with + `{ sub, email, name, org, role, judge_external_id }`; the server verifies + with a shared JWKS. This is how mainweb users land in the judge PWA with + no second login. +- `api-key`: for machine integrators (import, export, webhooks). + +Roles: `owner`, `admin`, `organizer`, `volunteer` on the org; `judge` is a +row on the event, not a role, and `is_lead` is a flag. Every procedure +declares the minimum role; there is no unguarded "protected" alias, the lesson +from `../PLAN.md` W8. + +**Observability** + +- `/metrics` Prometheus: dispatch latency, votes per minute, live judges, + coverage histogram per event, ws connections, outbox lag. +- Structured JSON logs with `event_id`, `judge_id`, `visit_id` on every line. +- `/healthz` (process), `/readyz` (Postgres, bus). + +--- + +## 5. Web (`@query/judging-web`) + +Next.js app, standalone output, one Docker image. Branding from +`organization.branding`; no hardcoded name or colours. + +- **Judge PWA** (`/j/[eventSlug]`): installable, works on a phone with one + hand. Screens: queue/next, walking (shows table, zone and a count-up), + arrive by camera QR or typed table number, score (criteria rendered from + rubric rows, anchors as tap targets, comment), compare (previous table vs + this one, one tap), done. Scores are held in IndexedDB until the server + acknowledges, so a dead Wi-Fi corner loses nothing; the server rejects a + late vote past the cutoff and the client shows why. +- **Admin console** (`/o/[orgSlug]/[eventSlug]`): phase control, rubric and + track editors, zone and table layout (grid with drag to set `x, y`), + judges (invite, approve, assign, recall), projects (import, tables, + withdraw), table cards print view per zone, live board (coverage heatmap by + table, judges on the floor, overtime, idle), results (compute, diff two + runs, inspect a project's rubric and pairwise components, publish), logs. +- **Public** (`/e/[orgSlug]/[eventSlug]`): leaderboard after publish, live + "judging in progress" board if `leaderboard_public` (no scores, just + coverage and phase), prize list per track. +- **Team feedback** (`/e/.../feedback/[token]`): per-criterion mean vs event + median, anonymised comments, placement per track or "not placed". Token + minted per project at publish; integrators can fetch it by `external_id`. + +--- + +## 6. CLI (`@query/judging-cli`) + +`panel migrate`, `panel seed demo` (an event with 40 projects, 6 judges, +three tracks, a sponsor rubric), `panel import projects ` (column map in +a yaml; Devpost export supported out of the box), `panel export results + --format csv|json`, `panel outbox drain`, `panel doctor` (checks +Postgres, Redis, migrations, config). + +CSV import was removed from the club site on purpose (`../PLAN.md` §"what not +to build"). It is in the product because a self-hoster has no other source. +The club adapter does not use it; it imports through the API. + +--- + +## 7. Integrating the club site + +Thin and replaceable. + +- `packages/api/src/routers/judging.ts` (new, small): on `promoteSubmissions` + calls `@query/judging-server` `project.upsert` with `external_id = hackathon_project.id`; + on judge approval calls `judge.upsert`; on publish, pulls results and + feedback tokens back into `hackathon_result` so `/hackathons/[id]` keeps + rendering from its own tables. +- The judge desk and organizer console are portal routes on the portal + session (`/judge/panel/...`, `/admin/judging/panel/...`). No handoff. +- Old `routers/judge/*` and the judging UI stay behind a feature flag per + edition (`hackathon.judging_backend = legacy | panel`) until one event has + run on the new system, then are deleted. +- Deploy: ships with mainweb. The API is `/api/panel` in the same process, + on the club Postgres (ADR-003). No separate service. + +--- + +## 8. Phases + +Each phase ships green on `pnpm test`, `lint --max-warnings 0`, `typecheck`, +`build`, and leaves the current event-day path untouched. + +### Phase 0 — Scaffold and core (1 week) + +- Workspaces, tsconfig, eslint boundary rule, `judging/README.md` with the + vision, MIT `LICENSE`, ADR-001 (why standalone), ADR-002 (pure core). +- Port `pickNext`, `isLive`, `isPastCutoff`, `projectMatchesTrack`, + `zNormalize`, the Bayesian step into `@query/judging-core` as parametrised + functions. Bring `dispatch.test.ts` cases with them. +- Add `distance`, `phase`, `rubric`, `bradleyTerry`, `blend`, `uncertainty` + with tests. + +**Verify:** `@query/judging-core` has no dependencies and 100% of exported functions +have a test. The legacy `rankings.ts` output on the seed equals +`@query/judging-core` `rank()` output byte for byte at `pairwise_weight = 0`. + +### Phase 1 — Data and server (2 weeks) + +- Schema and first migration. Seed and demo. +- Hono + tRPC + OpenAPI. Routers from §4 except `live` and `feedback`. +- Auth: magic-link and trusted-jwt. Roles enforced per procedure. +- Advisory lock dispatch, visit voiding, outbox table and in-process drain. +- `event_log` on every mutation. `/metrics`, `/healthz`, `/readyz`. +- Dockerfile, `docker-compose.yml` (Postgres, server). + +**Verify:** DB tests: two concurrent `next` calls never hand out the same +table; a vote after cutoff is rejected with the configured limit in the +message; phase machine refuses `vote` outside `judging_live`; every mutation +in the flow test produces exactly one log row. `panel doctor` passes in the +compose stack. + +### Phase 2 — Judge PWA and admin console (3 weeks) + +- Judge flow end to end with offline hold. Camera QR via `getUserMedia`. +- Admin: phases, rubric, tracks, prizes, zones, tables, judges, projects, + table cards, results compute and publish. +- Branding from org row. + +**Verify:** Playwright: seed demo, approve a judge, run a full visit on a +phone viewport, compute, publish, see the leaderboard. Kill the network +mid-score, restore, vote lands once. + +### Phase 3 — Realtime and live board (1 week) + +- WebSocket hub, `InMemoryBus`, `RedisBus`. Live board and leaderboard + subscribe; fallback polling. +- Admin `recall` and `void` pushes. + +**Verify:** two server instances behind compose with Redis; a vote on one +appears on a board connected to the other within one second. Stop Redis; +boards degrade to polling with a visible indicator and no errors. + +### Phase 4 — Hybrid scoring and uncertainty dispatch (2 weeks) + +- Compare screen, `comparison` rows, Bradley–Terry in results, blend + components visible in the results inspector and the run diff. +- `uncertainty` strategy with a cached vector per event (in-process, hint + only). +- Team feedback cards and tokens. + +**Verify:** property test: consistent comparisons produce the transitive +order at `w = 1`; `w = 0` reproduces Phase 2 results. Simulation: 200 +projects, 20 judges, three synthetic hours; top-10 Kendall tau is higher +under `uncertainty` than `coverage` at equal looks. Feedback returns 403 +before publish and for the wrong token. + +### Phase 5 — Club integration and first live event (2 weeks plus the event) + +- `routers/judging.ts` adapter, trusted-JWT handoff, results pull-back, + feature flag per edition. +- Terraform: two Cloud Run services, Neon connection via Secret Manager, + optional Memorystore, uptime check and alert on `/readyz`. +- Run a small internal event on `panel`, then Hacklytics. + +**Verify:** the legacy and new systems produce the same placings on a replay +of an old event's votes. Post-event retro recorded as ADR-00x. + +### Phase 6 — Cleanup (1 week) + +- Docs: quick start (`docker compose --profile judging up`, `panel seed demo`), + deploy guide, API reference from OpenAPI, rubric design guide, scoring + explainer. +- Delete `routers/judge/*` and the legacy judging UI from the club site. + +**Verify:** a fresh clone runs the demo in under five minutes with nothing +but Docker. + +Dropped: splitting judging into its own repository. It stays in this +monorepo. + +--- + +## 9. Decisions taken and open + +**Taken** + +- Judging lives in the monorepo as `@query/judging-*` workspaces on the club + Postgres (ADR-003). No later extraction. +- WebSockets on our own server with a bus adapter; Redis optional. +- Terraform for GCP as an optional module; Docker Compose is the primary + documented deploy. +- Committed SQL migrations, not `push`. +- Pairwise comparisons against the judge's previous table, not a + server-chosen comparator (no extra walk, no recall burden). Revisit after + the Phase 4 simulation. +- CSV import exists in the product; the club adapter does not use it. + +**Open** + +- **Name.** `panel` is a placeholder; check npm and GitHub availability + before Phase 6. +- **Rank visibility.** Teams outside the podium see "not placed", not a + number. Organizer's call per event; default off. +- **Hosted mode.** Multi-tenant tables exist, but billing, org sign-up and + abuse controls are not in this plan. Self-host first. +- **Peer voting.** Possible as `judge_group = 'peer'` with its own weight. + Not designed here. + +--- + +## 10. Not in this plan + +- A scheduler or job queue service. The outbox drain runs in-process; cron is + an operator option, not a requirement. +- Devpost API sync. CSV import covers it; an API adapter can come as a + community contribution. +- Replacing the club site's registration, check-in, teams or submissions. + Those stay in `packages/api`; only judging moves. +- Any change to `packages/db` schema beyond the `judging_backend` flag and the + feedback token column needed for cutover. diff --git a/docs/architecture.md b/docs/architecture.md index 64914af3..af80d523 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,7 +33,7 @@ ## Two products, one database -The schema is split on purpose. Mixing them previously made membership vanish when a new hackathon edition was drafted. +The schema is split on purpose. Mixing them previously made membership vanish when a new hackathon edition was drafted. Panel judging tables live in this same Postgres database. ### Club diff --git a/docs/judging/README.md b/docs/judging/README.md new file mode 100644 index 00000000..823e34c5 --- /dev/null +++ b/docs/judging/README.md @@ -0,0 +1,72 @@ +# Panel + +Hackathon judging inside the portal. Rubrics, tracks, timers, rooms, and +dispatch strategy are rows, not code. It runs in the mainweb process on the +club Postgres, and the portal sign-in decides who is judging or organizing. + +## Layout + +| Path | Package | What it is | +| --- | --- | --- | +| `packages/judging-core/` | `@query/judging-core` | Pure functions. No I/O, no clock, no runtime dependencies. | +| `packages/judging-db/` | `@query/judging-db` | Schema, committed SQL migrations, demo seed. | +| `packages/judging-server/` | `@query/judging-server` | The judging API (Hono + tRPC) as a library. | +| `packages/judging-cli/` | `@query/judging-cli` | `panel migrate`, `panel seed demo`, `panel doctor`, `panel outbox drain`. | +| `packages/api/src/services/panel.ts` | `@query/api/panel` | One API instance per process, and the portal user as a judging actor. | + +## In the portal + +Judging is per hackathon edition. On `/admin/judging`, staff pick the edition +and press "Switch to panel judging". That creates the edition's judging event +(organization `hacklytics`, event slug = the hackathon id) in `setup`, with +default timers and the club's five criteria out of ten, then copies in the +submitted projects and the portal's judges. Switching back to classic leaves +the event in place. + +| Route | Who | What | +| --- | --- | --- | +| `/api/panel/*` | the pages below | The judging API. Same routes as `docs/judging/api.md`, without the prefix. | +| `/judge/panel/[hackathonId]` | approved judges | Judge desk: next table, QR or table number, scores, pairwise, offline hold. | +| `/admin/judging/panel/[hackathonId]` | staff | Organizer console: phases, floor, tables, rubric, tracks, judges, results. | +| `/judging/[hackathonId]` | anyone | Public board while judging, placements after publish. | +| `/judging/[hackathonId]/feedback/[token]` | a team | That team's feedback card after publish. | + +Roles come from the `admins` row: `super_admin` is owner, other staff are +admin, a volunteer row is volunteer, a bug tester has none. A judge is matched +by the email on their portal judge row. Approving a judge in the portal +approves them in judging, and deactivating suspends them. Promoting +submissions copies projects in, and pulling results writes published +placements back into `hackathon_result`. + +Live views poll, and only while they can change. The board polls at the +event's `board_poll_seconds` and the console polls the floor every five +seconds, both only during `judging_live` and only while the tab is visible. The judge +desk asks `/v1/session/status` every 15 seconds while a visit is open and the +screen is on, which is how a recall or a voided visit reaches the phone. +Neon suspends after five idle minutes and the free plan has 100 compute hours +a month, so a forgotten board in any other phase must not keep it awake. +`/api/panel/readyz` and `/api/panel/metrics` are not exposed for the same +reason. Judging uses the club's connection pool rather than a second one. + +## Checks + +`pnpm lint`, `pnpm typecheck`, and `pnpm test` at the root cover these +packages with the rest of the repository. + +## Database + +Postgres is the one database the rest of the monorepo uses (`DATABASE_URL`). +Panel migrations add its tables there. + +``` +docker compose up -d +$env:DATABASE_URL = "postgresql://postgres:postgres@localhost:5433/neondb" +pnpm --filter @query/judging-cli panel migrate +pnpm --filter @query/judging-cli panel seed demo +pnpm --filter @query/judging-cli panel doctor +``` + +Webhooks queue in `outbox`. Publishing results drains it; delivered rows are +deleted, since `event_log` keeps the history. A delivery that failed stays +queued for `panel outbox drain`. A Hacklytics-size event is on the order of +20 MB across the judging tables. diff --git a/docs/judging/adr/001-standalone.md b/docs/judging/adr/001-standalone.md new file mode 100644 index 00000000..0c335096 --- /dev/null +++ b/docs/judging/adr/001-standalone.md @@ -0,0 +1,26 @@ +# ADR-001: Standalone product + +## Status + +Superseded. Panel's packages are ordinary monorepo workspaces +(`packages/judging-*`) under the `@query/*` scope, served by the portal. The import +boundary and the plan to split it out were dropped. + +## Decision + +Panel lives under `judging/` with its own packages and its own deploy. It does +not share a module graph with the application that hosts the first event. +ADR-003 puts the tables in that application's Postgres instead of a second +database. + +Internal imports are `@panel/*` only. A lint rule rejects imports from the +host. The host is allowed to import `@panel/*`. That one-way edge is what +makes `git subtree split --prefix=judging` a later extraction rather than a +rewrite. + +## Why + +Judging welded into one event's tables cannot be given to the next event, or +to anyone else, without carrying the rest of that application with it. +The panel tables are prefixed so they can share that database until an +extraction. diff --git a/docs/judging/adr/002-pure-core.md b/docs/judging/adr/002-pure-core.md new file mode 100644 index 00000000..f5bc4aa5 --- /dev/null +++ b/docs/judging/adr/002-pure-core.md @@ -0,0 +1,22 @@ +# ADR-002: Pure core + +## Status + +Accepted + +## Decision + +Every decision Panel makes — the next table, whether a visit is still live, +whether a score is valid, how a set of votes becomes a ranking — is a function +in `@query/judging-core`. Those functions take plain data, including `now` and a +`random` source, and return plain data. They do not read the clock, generate +randomness, or touch a database, the network, or the filesystem. + +The server is the only place that performs I/O. It loads rows, calls the +core, and writes the result in the same transaction as the event log. + +## Why + +A ranking you cannot recompute from the votes is not a result you can defend. +Tests of the core need no database, so the scoring bugs surface without +standing up Postgres. The same functions can later run offline on an export. diff --git a/docs/judging/adr/003-one-database.md b/docs/judging/adr/003-one-database.md new file mode 100644 index 00000000..aa2d80d5 --- /dev/null +++ b/docs/judging/adr/003-one-database.md @@ -0,0 +1,26 @@ +# ADR-003: One database, one server + +## Status + +Accepted. Supersedes the separate-database sentence in ADR-001. + +## Decision + +Panel tables live in the same Postgres as the club app. The connection is +`DATABASE_URL`. `PANEL_DATABASE_URL` is read only when that is unset. The +tables that would collide with the club schema are `panel_user`, +`panel_event`, and `panel_judge`. + +There is one API process. Redis is optional. When `REDIS_URL` is unset or +Redis fails, that process keeps delivering live updates in memory. + +`judging/` stays in this monorepo. Splitting it out, and deleting the legacy +judge UI, wait until an event has run on panel. Sign-in mail waits on +`PANEL_SMTP_URL`. Neither connection is made yet. + +## Why + +The host is the existing app. A second database and a second API fleet were +the extraction plan, and they are not how this checkout runs. The replay of +the published score fixture still matches `@query/judging-core` `rank()` at pairwise +weight 0, so the scoring check does not need that event. diff --git a/docs/judging/api.md b/docs/judging/api.md new file mode 100644 index 00000000..dd35ed3e --- /dev/null +++ b/docs/judging/api.md @@ -0,0 +1,17 @@ +# API + +`GET /openapi.json` is the route list (OpenAPI 3.0.3). The same procedures are also mounted at `/trpc`. + +Send `Authorization: Bearer` with a JWT from `POST /v1/auth/verify`, or with an API key from `catalog.issueApiKey`. A key whose scopes include `import`, `export`, or `webhooks` acts as an organizer. A revoked key is rejected. + +These routes do not require a token: + +- `GET /healthz`, `GET /readyz`, `GET /metrics`, `GET /openapi.json` +- `POST /v1/auth/magic-link` and `POST /v1/auth/verify` +- `GET /v1/public/{orgSlug}/{eventSlug}` +- `GET /v1/live/{orgSlug}/{eventSlug}` +- `GET /v1/feedback/{token}` + +`GET /v1/results/{eventId}` requires a bearer token and returns 403 until results are published. Feedback returns 403 for an unknown token and before publish. + +Live clients connect to `/ws?channel=event:{eventId}:board`. The channels are `event:{id}:board`, `event:{id}:leaderboard`, and `judge:{id}`. diff --git a/docs/judging/contributing.md b/docs/judging/contributing.md new file mode 100644 index 00000000..8048ca15 --- /dev/null +++ b/docs/judging/contributing.md @@ -0,0 +1,28 @@ +# Contributing + +Panel is `packages/judging-*` plus its portal routes in `sites/mainweb` (listed in `README.md`). `pnpm lint`, `pnpm typecheck`, and `pnpm test` at the root cover it with the rest of the repository. + +## Checks + +From the repository root: + +``` +pnpm --filter @query/judging-core test +pnpm --filter @query/judging-server test +pnpm --filter @query/judging-cli test +pnpm --filter @query/judging-core lint +pnpm --filter @query/judging-server lint +pnpm --filter web lint +pnpm --filter @query/judging-db typecheck +pnpm --filter @query/judging-server typecheck +pnpm --filter web typecheck +pnpm --filter @query/judging-cli typecheck +``` + +## Migrations + +Schema changes are SQL files in `packages/judging-db/migrations`, applied with `panel migrate`. Do not rename or drop a column in the same change that ships code reading the new shape. + +## Pull requests + +Describe the behaviour a judge or an organizer will see. Include the command you ran. diff --git a/docs/judging/deploy.md b/docs/judging/deploy.md new file mode 100644 index 00000000..f13a850c --- /dev/null +++ b/docs/judging/deploy.md @@ -0,0 +1,23 @@ +# Deploy + +Judging ships with mainweb. There is no separate service or image: the API is +`/api/panel` in the portal, and the pages are portal routes (see `README.md`). + +Before the first event on panel, against the production database: + +``` +DATABASE_URL= +pnpm --filter @query/judging-cli panel migrate +pnpm --filter @query/judging-cli panel doctor +``` + +Then switch the edition to panel on `/admin/judging`. Nothing goes in the +environment: the event is created from the hackathon row. + +`packages/db/drizzle.config.ts` hides the judging tables from the deploy's +`drizzle-kit push` and declares the judging enum types +(`packages/judging-db/src/enums.ts`), so push neither tries to drop them nor +stalls on a prompt. + +`PANEL_JWT_SECRET` is only needed for callers outside the portal that send a +bearer token; API keys from `catalog.issueApiKey` work without it. diff --git a/docs/judging/rubric.md b/docs/judging/rubric.md new file mode 100644 index 00000000..70b42cc3 --- /dev/null +++ b/docs/judging/rubric.md @@ -0,0 +1,7 @@ +# Rubric + +A rubric is a named list of criteria. Each criterion has a key, a label, a minimum, a maximum, and a weight. The default rubric is what judges see. A track can point at a different rubric, which is how a sponsor prize asks different questions. + +Scores outside a criterion's range are rejected. The visit total is the weighted mean of the criteria. Comments are stored with the vote and shown on the team card only after results are published. + +Create one from the organizer console with the track form for a new prize group, or through `catalog.saveRubric` with the criteria in order. Anchors are the numbers a judge can tap; they are the integers from min to max. diff --git a/docs/judging/scoring.md b/docs/judging/scoring.md new file mode 100644 index 00000000..65d46488 --- /dev/null +++ b/docs/judging/scoring.md @@ -0,0 +1,9 @@ +# Scoring + +A project's rubric score is the weighted mean of its criterion values. Each judge's scores are then z-scored against that judge's own mean and spread and placed on the field's mean and spread, so a harsh judge and a lenient judge who ranked the same projects agree. + +Projects with few votes are shrunk toward the field average. The config value `bayesian_c` is how many average votes that prior is worth. At 2, two real votes and the prior weigh the same. + +Pairwise comparisons are a separate component. `pairwise_weight` is the share of the final score that comes from them. At 0 the ranking is the rubric score only, which matches a results run that never asked judges to compare tables. + +Publishing freezes one result run. A later run can be diffed against it before it is published. diff --git a/package.json b/package.json index 6dcbb690..3e16ba56 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint": "turbo run lint", "format": "prettier --write .", "typecheck": "turbo run typecheck", - "test": "vitest run packages/api packages/auth packages/db sites/mainweb/lib" + "test": "vitest run packages/api packages/auth packages/db sites/mainweb/lib packages/judging-core packages/judging-server packages/judging-cli" }, "dependencies": { "next": "16.3.7", diff --git a/packages/api/package.json b/packages/api/package.json index 4f8b792d..795d7c4e 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -18,7 +18,8 @@ "./eastern-time": "./src/services/eastern-time.ts", "./metrics": "./src/services/metrics.ts", "./portal-context": "./src/types/portal-context.ts", - "./resume-list": "./src/services/resume-list.ts" + "./resume-list": "./src/services/resume-list.ts", + "./panel": "./src/services/panel.ts" }, "scripts": { "lint": "eslint . --max-warnings 0", @@ -28,6 +29,8 @@ "dependencies": { "@query/auth": "workspace:*", "@query/db": "workspace:*", + "@query/judging-db": "workspace:*", + "@query/judging-server": "workspace:*", "@tanstack/react-query": "5.103.2", "@trpc/client": "11.19.0", "@trpc/next": "11.19.0", @@ -42,6 +45,7 @@ "zod": "4.6.5" }, "devDependencies": { + "@query/judging-core": "workspace:*", "@query/tsconfig": "workspace:*", "@types/sanitize-html": "^2.16.0", "typescript": "7.0.2", diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index bfa2e79d..c0b65d45 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -21,6 +21,7 @@ import { import { CacheKeys, invalidatePortalContext } from "../../middleware/cache"; import type { DrizzleDB } from "@query/db"; import { isLive, loadGroups, loadPool } from "./dispatch"; +import { syncJudgeToPanel, syncProjectsToPanel } from "../../services/panel-sync"; // Judges draw tables from a shared pool (dispatch.ts) instead of holding a // list built in advance. Clears rows an earlier version built that the judge @@ -263,7 +264,7 @@ export const judgeAdminRouter = createTRPCRouter({ promoteSubmissions: isAdmin .input(z.object({ hackathonId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { - return await (ctx.db as DrizzleDB).transaction(async (tx) => { + const promoted = await (ctx.db as DrizzleDB).transaction(async (tx) => { // Serializes concurrent promotions, so two organisers pressing the button // together cannot both read the same max table number and duplicate it. await tx @@ -350,6 +351,8 @@ export const judgeAdminRouter = createTRPCRouter({ total: submissions.length, }; }); + await syncProjectsToPanel(ctx.db as DrizzleDB, input.hackathonId); + return promoted; }), setActive: isAdmin @@ -467,6 +470,11 @@ export const judgeAdminRouter = createTRPCRouter({ } } + await syncJudgeToPanel(ctx.db as DrizzleDB, { + hackathonId: updated.hackathonId, + email: updated.email, + isActive: input.isActive, + }); return { success: true, isActive: input.isActive, queuedProjects }; }), diff --git a/packages/api/src/routers/judge/portal.ts b/packages/api/src/routers/judge/portal.ts index b0afda0a..fabca918 100644 --- a/packages/api/src/routers/judge/portal.ts +++ b/packages/api/src/routers/judge/portal.ts @@ -21,6 +21,7 @@ import { lockDispatch, } from "./dispatch"; import type { DrizzleDB } from "@query/db"; +import { panelConsoleUrl, panelDeskUrl, setJudgingBackend } from "../../services/panel-sync"; export const judgePortalRouter = createTRPCRouter({ @@ -68,6 +69,31 @@ export const judgePortalRouter = createTRPCRouter({ return result; }), + /** The panel judge desk in the portal, only for an edition that opted in. */ + panelDesk: protectedProcedure.query(async ({ ctx }) => { + return panelDeskUrl(ctx.db as DrizzleDB, ctx.userId as string); + }), + + /** The panel organizer console in the portal when this edition uses panel. */ + panelConsole: isAdmin + .input(z.object({ hackathonId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + return panelConsoleUrl(ctx.db as DrizzleDB, input.hackathonId); + }), + + /** Moves an edition between classic judging and panel. Panel gets its event and a first sync. */ + setJudgingBackend: isAdmin + .input( + z.object({ + hackathonId: z.string().uuid(), + backend: z.enum(["legacy", "panel"]), + }), + ) + .mutation(async ({ ctx, input }) => { + await setJudgingBackend(ctx.db as DrizzleDB, input.hackathonId, input.backend); + return panelConsoleUrl(ctx.db as DrizzleDB, input.hackathonId); + }), + getMyAssignments: protectedProcedure.query(async ({ ctx }) => { const db = ctx.db as DrizzleDB; diff --git a/packages/api/src/routers/judge/rankings.ts b/packages/api/src/routers/judge/rankings.ts index 69f72da8..05957556 100644 --- a/packages/api/src/routers/judge/rankings.ts +++ b/packages/api/src/routers/judge/rankings.ts @@ -6,7 +6,8 @@ import { and, eq, isNotNull, sql , isNull } from "drizzle-orm"; import { isAdmin } from "../../middleware/procedures"; import { recordAdminAction } from "../../middleware/audit"; import type { DrizzleDB } from "@query/db"; -import { zNormalize } from "./helpers"; +import { weightProjects } from "./weight"; +import { pullPanelResults } from "../../services/panel-sync"; // The whole ranking pipeline in one place, so the live view and the frozen // snapshot cannot drift: two implementations of a scoring formula are two @@ -34,75 +35,26 @@ async function computeRanking(db: DrizzleDB, hackathonId: string) { }); const round2 = (n: number) => Math.round(n * 100) / 100; - - // Step 1: raw scores grouped by judge. Per-judge distributions are what - // Z-score normalization needs to cancel harsh-judge / lenient-judge bias. type VoteWithJudge = (typeof projects)[number]["votes"][number]; - const scoresByJudge = new Map(); - for (const project of projects) { - for (const v of project.votes) { - const existing = scoresByJudge.get(v.judgeId) ?? []; - existing.push(v.score); - scoresByJudge.set(v.judgeId, existing); - } - } - - // ─── Step 2: Compute global score distribution ───────────────────────── - const allRawScores = [...scoresByJudge.values()].flat(); - const globalMean = - allRawScores.length > 0 - ? allRawScores.reduce((a, b) => a + b, 0) / allRawScores.length - : 0; - const globalVariance = - allRawScores.length > 0 - ? allRawScores.reduce((s, v) => s + (v - globalMean) ** 2, 0) / - allRawScores.length - : 1; - const globalStd = Math.sqrt(globalVariance) || 1; - - // Step 3: per judge, map their raw score index to a Z-normalized score. - const normalizedScoreLookup = new Map>(); - for (const [judgeId, rawScores] of scoresByJudge.entries()) { - const normalized = zNormalize(rawScores, globalMean, globalStd); - // Map raw score value -> normalized value (index-based, preserves order) - const lookup = new Map(); - rawScores.forEach((raw, i) => { - // If same raw score appears multiple times, average the normalized values - const existing = lookup.get(raw); - lookup.set( - raw, - existing !== undefined - ? (existing + normalized[i]!) / 2 - : normalized[i]!, - ); - }); - normalizedScoreLookup.set(judgeId, lookup); - } - - const getNormalized = (judgeId: string, rawScore: number): number => { - const lookup = normalizedScoreLookup.get(judgeId); - return lookup?.get(rawScore) ?? rawScore; - }; - - // ─── Step 4: Build raw + normalized stats per project ───────────────── - const C = 2; // Bayesian confidence weight + const weighted = new Map( + weightProjects( + projects.map((project) => ({ + id: project.id, + votes: project.votes.map((vote) => ({ judgeId: vote.judgeId, score: vote.score })), + })), + ).map((row) => [row.id, row]), + ); const rawRankings = projects.map((project) => { + const scored = weighted.get(project.id); const voteCount = project.votes.length; + const normalizedScores = scored?.normalizedScores ?? []; + const normalizedAvg = scored?.normalizedAvg ?? 0; // Raw scores (unadjusted) const totalScore = project.votes.reduce((sum, v) => sum + v.score, 0); const avgScore = voteCount > 0 ? totalScore / voteCount : 0; - // Z-score normalized scores (bias-corrected) - const normalizedScores = project.votes.map((v) => - getNormalized(v.judgeId, v.score), - ); - const normalizedAvg = - voteCount > 0 - ? round2(normalizedScores.reduce((a, b) => a + b, 0) / voteCount) - : 0; - // Per-category averages (raw) const sumCat = { creativity: 0, @@ -182,26 +134,9 @@ async function computeRanking(db: DrizzleDB, hackathonId: string) { }; }); - // ─── Step 5: Compute global normalized average for Bayesian prior ────── - const votedProjects = rawRankings.filter((r) => r.voteCount > 0); - const globalAvg = - votedProjects.length > 0 - ? round2( - votedProjects.reduce((sum, r) => sum + r.normalizedAvg, 0) / - votedProjects.length, - ) - : 0; - - // Step 6: Bayesian + Z-score final. weightedScore blends the normalized - // average toward the global mean when few judges voted. const rankings = rawRankings.map((r) => { const n = r.voteCount; - const weightedScore = - n > 0 - ? round2( - (n / (n + C)) * r.normalizedAvg + (C / (n + C)) * globalAvg, - ) - : 0; + const weightedScore = weighted.get(r.project.id)?.weightedScore ?? 0; const confidenceLevel: "NONE" | "LOW" | "MEDIUM" | "HIGH" = n === 0 ? "NONE" : n === 1 ? "LOW" : n === 2 ? "MEDIUM" : "HIGH"; const scoreShift = round2(r.normalizedAvg - r.avgScore); // how much bias-correction shifted this project @@ -311,6 +246,12 @@ async function computeRanking(db: DrizzleDB, hackathonId: string) { }); } + const votedRows = [...weighted.values()].filter((row) => row.voteCount > 0); + const globalAvg = + votedRows.length > 0 + ? round2(votedRows.reduce((sum, row) => sum + row.normalizedAvg, 0) / votedRows.length) + : 0; + const result = { rankings, globalAvg, @@ -481,6 +422,7 @@ export const judgeRankingsRouter = createTRPCRouter({ publishResults: isAdmin .input(z.object({ hackathonId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { + await pullPanelResults(ctx.db as DrizzleDB, input.hackathonId); const rows = await (ctx.db as DrizzleDB) .update(hackathonResults) .set({ publishedAt: new Date() }) diff --git a/packages/api/src/routers/judge/replay.test.ts b/packages/api/src/routers/judge/replay.test.ts new file mode 100644 index 00000000..7d844a15 --- /dev/null +++ b/packages/api/src/routers/judge/replay.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { rank } from "@query/judging-core"; +import { weightProjects } from "./weight"; + +const projects = [ + { + id: "A", + votes: [ + { judgeId: "j1", score: 8 }, + { judgeId: "j2", score: 9 }, + ], + }, + { + id: "B", + votes: [ + { judgeId: "j1", score: 5 }, + { judgeId: "j2", score: 9 }, + ], + }, + { + id: "C", + votes: [ + { judgeId: "j1", score: 2 }, + { judgeId: "j2", score: 6 }, + ], + }, +]; + +describe("legacy rankings and panel rank", () => { + it("places the published fixture the same way at pairwise weight 0", () => { + const legacy = weightProjects(projects) + .filter((row) => row.voteCount > 0) + .sort((a, b) => b.weightedScore - a.weightedScore || a.id.localeCompare(b.id)); + const panel = rank( + projects.map((project) => ({ id: project.id, trackIds: ["overall"] })), + projects.flatMap((project) => + project.votes.map((vote) => ({ + judgeId: vote.judgeId, + projectId: project.id, + judgeGroup: "main", + total: vote.score, + })), + ), + [], + { bayesianC: 2, pairwiseWeight: 0, calibrationWeight: 1 }, + ) + .filter((row) => row.placement !== null) + .sort((a, b) => (a.placement ?? 0) - (b.placement ?? 0)); + + expect(panel.map((row) => [row.projectId, row.score])).toEqual( + legacy.map((row) => [row.id, row.weightedScore]), + ); + expect(panel.map((row) => row.score)).toEqual([7.71, 6.94, 4.85]); + }); +}); diff --git a/packages/api/src/routers/judge/weight.ts b/packages/api/src/routers/judge/weight.ts new file mode 100644 index 00000000..3f9ada92 --- /dev/null +++ b/packages/api/src/routers/judge/weight.ts @@ -0,0 +1,84 @@ +import { zNormalize } from "./helpers"; + +const round2 = (n: number) => Math.round(n * 100) / 100; +const C = 2; + +export type WeightVote = { judgeId: string; score: number }; +export type WeightProject = { id: string; votes: readonly WeightVote[] }; + +export type WeightedProject = { + id: string; + voteCount: number; + normalizedAvg: number; + weightedScore: number; + normalizedScores: number[]; +}; + +/** + * The ranking the club site publishes: z-score each judge, average per + * project, shrink toward the mean of those averages with c = 2. Panel's + * `rank()` at pairwise weight 0 is checked against this function. + */ +export function weightProjects(projects: readonly WeightProject[]): WeightedProject[] { + const scoresByJudge = new Map(); + for (const project of projects) { + for (const vote of project.votes) { + const existing = scoresByJudge.get(vote.judgeId) ?? []; + existing.push(vote.score); + scoresByJudge.set(vote.judgeId, existing); + } + } + + const allRawScores = [...scoresByJudge.values()].flat(); + const globalMean = + allRawScores.length > 0 + ? allRawScores.reduce((a, b) => a + b, 0) / allRawScores.length + : 0; + const globalVariance = + allRawScores.length > 0 + ? allRawScores.reduce((sum, value) => sum + (value - globalMean) ** 2, 0) / + allRawScores.length + : 1; + const globalStd = Math.sqrt(globalVariance) || 1; + + const normalizedScoreLookup = new Map>(); + for (const [judgeId, rawScores] of scoresByJudge.entries()) { + const normalized = zNormalize(rawScores, globalMean, globalStd); + const lookup = new Map(); + rawScores.forEach((raw, index) => { + const existing = lookup.get(raw); + const value = normalized[index]!; + lookup.set(raw, existing !== undefined ? (existing + value) / 2 : value); + }); + normalizedScoreLookup.set(judgeId, lookup); + } + + const raw = projects.map((project) => { + const voteCount = project.votes.length; + const normalizedScores = project.votes.map( + (vote) => normalizedScoreLookup.get(vote.judgeId)?.get(vote.score) ?? vote.score, + ); + const normalizedAvg = + voteCount > 0 + ? round2(normalizedScores.reduce((sum, value) => sum + value, 0) / voteCount) + : 0; + return { id: project.id, voteCount, normalizedAvg, normalizedScores }; + }); + + const voted = raw.filter((row) => row.voteCount > 0); + const globalAvg = + voted.length > 0 + ? round2(voted.reduce((sum, row) => sum + row.normalizedAvg, 0) / voted.length) + : 0; + + return raw.map((row) => ({ + ...row, + weightedScore: + row.voteCount > 0 + ? round2( + (row.voteCount / (row.voteCount + C)) * row.normalizedAvg + + (C / (row.voteCount + C)) * globalAvg, + ) + : 0, + })); +} diff --git a/packages/api/src/services/panel-sync.ts b/packages/api/src/services/panel-sync.ts new file mode 100644 index 00000000..99ed01ff --- /dev/null +++ b/packages/api/src/services/panel-sync.ts @@ -0,0 +1,193 @@ +import { and, eq, inArray, sql } from "drizzle-orm"; +import { hackathonProjects, hackathonResults, hackathons, judges, judgingProjects } from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { judge as panelJudge } from "@query/judging-db"; +import { publishedPlacements } from "@query/judging-server"; +import { panel, panelAsPortal, panelEventFor } from "./panel"; + +/** + * Copies an edition into judging when that edition has opted in. + * A failure here does not undo the club write. The portal stays the source + * of registration, and the same action can be run again. + */ +export async function syncProjectsToPanel(db: DrizzleDB, hackathonId: string) { + try { + const target = await panelTarget(db, hackathonId); + if (!target) return; + const rows = await db + .select({ + id: hackathonProjects.id, + name: hackathonProjects.name, + }) + .from(hackathonProjects) + .where( + and( + eq(hackathonProjects.hackathonId, hackathonId), + inArray(hackathonProjects.status, ["submitted", "judging"]), + ), + ); + const caller = panelAsPortal(); + for (const row of rows) { + await caller.project.upsert({ + eventId: target.eventId, + externalId: row.id, + name: row.name, + teamName: null, + tableNumber: null, + }); + } + } catch { + return; + } +} + +/** + * Mirrors a portal judge into the edition's judging event. Approval in the + * portal is approval in judging; deactivating suspends them there. + */ +export async function syncJudgeToPanel( + db: DrizzleDB, + judge: { hackathonId: string; email: string | null; isActive: boolean }, +) { + try { + if (!judge.email) return; + const target = await panelTarget(db, judge.hackathonId); + if (!target) return; + await mirrorJudge(target.eventId, judge.email, judge.isActive); + } catch { + return; + } +} + +async function mirrorJudge(eventId: string, email: string, isActive: boolean) { + const caller = panelAsPortal(); + // Upsert is refused once judging is closed; a judge already there can + // still be suspended or reinstated. + const stored = await caller.judge + .upsert({ eventId, email, name: email, externalId: null }) + .catch(() => undefined); + let judgeId = stored?.id; + if (!judgeId) { + const [row] = await panel() + .db.select({ id: panelJudge.id }) + .from(panelJudge) + .where(and(eq(panelJudge.eventId, eventId), eq(panelJudge.email, email.toLowerCase()))); + judgeId = row?.id; + } + if (!judgeId) return; + await caller.judge.setStatus({ + eventId, + judgeId, + status: isActive ? "approved" : "suspended", + }); +} + +/** + * Moves an edition onto panel judging: creates its event, then copies in the + * submitted projects and the judges the portal has approved. Switching back + * leaves the panel event in place, so switching again resumes it. + */ +export async function setJudgingBackend( + db: DrizzleDB, + hackathonId: string, + backend: "legacy" | "panel", +) { + await db.update(hackathons).set({ judgingBackend: backend }).where(eq(hackathons.id, hackathonId)); + if (backend === "legacy") return; + const target = await panelTarget(db, hackathonId); + if (!target) return; + await syncProjectsToPanel(db, hackathonId); + const rows = await db + .select({ email: judges.email, isActive: judges.isActive }) + .from(judges) + .where(eq(judges.hackathonId, hackathonId)); + for (const row of rows) { + if (row.email) await mirrorJudge(target.eventId, row.email, row.isActive); + } +} + +async function panelTarget(db: DrizzleDB, hackathonId: string) { + const [row] = await db + .select({ id: hackathons.id, name: hackathons.name, backend: hackathons.judgingBackend }) + .from(hackathons) + .where(eq(hackathons.id, hackathonId)); + if (row?.backend !== "panel") return null; + return panelEventFor(row); +} + +export async function pullPanelResults(db: DrizzleDB, hackathonId: string) { + const target = await panelTarget(db, hackathonId); + if (!target) return 0; + const body = await publishedPlacements(panel().db, target.eventId); + if (!body) throw new Error("Panel event not found"); + if (!body.published) throw new Error("Panel results are not published"); + const judging = await db + .select({ id: judgingProjects.id, sourceProjectId: judgingProjects.sourceProjectId }) + .from(judgingProjects) + .where(eq(judgingProjects.hackathonId, hackathonId)); + const bySource = new Map( + judging.flatMap((row) => + row.sourceProjectId ? [[row.sourceProjectId, row.id] as const] : [], + ), + ); + let written = 0; + for (const row of body.rows) { + if (!row.externalId || row.placement === null) continue; + const projectId = bySource.get(row.externalId); + if (!projectId) continue; + await db + .insert(hackathonResults) + .values({ + hackathonId, + projectId, + sourceProjectId: row.externalId, + track: row.track || "overall", + placement: row.placement, + weightedScore: row.score.toFixed(2), + voteCount: row.voteCount, + }) + .onConflictDoUpdate({ + target: [ + hackathonResults.hackathonId, + hackathonResults.projectId, + hackathonResults.track, + ], + set: { + placement: sql`excluded.placement`, + weightedScore: sql`excluded.weighted_score`, + voteCount: sql`excluded.vote_count`, + sourceProjectId: row.externalId, + }, + }); + written += 1; + } + return written; +} + +export async function panelDeskUrl(db: DrizzleDB, userId: string) { + const [row] = await db + .select({ hackathonId: judges.hackathonId }) + .from(judges) + .innerJoin(hackathons, eq(hackathons.id, judges.hackathonId)) + .where( + and( + eq(judges.userId, userId), + eq(judges.isActive, true), + eq(hackathons.judgingBackend, "panel"), + ), + ); + if (!row) return { url: null as string | null }; + return { url: `/judge/panel/${row.hackathonId}` }; +} + +export async function panelConsoleUrl(db: DrizzleDB, hackathonId: string) { + const [row] = await db + .select({ backend: hackathons.judgingBackend }) + .from(hackathons) + .where(eq(hackathons.id, hackathonId)); + const backend = row?.backend ?? "legacy"; + return { + backend, + url: backend === "panel" ? `/admin/judging/panel/${hackathonId}` : null, + }; +} diff --git a/packages/api/src/services/panel.ts b/packages/api/src/services/panel.ts new file mode 100644 index 00000000..f22e18c1 --- /dev/null +++ b/packages/api/src/services/panel.ts @@ -0,0 +1,133 @@ +import { and, eq } from "drizzle-orm"; +import { admins, db as clubDb, hackathons, judges } from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { createDbFromPool } from "@query/judging-db"; +import type { PanelDb } from "@query/judging-db"; +import { + InMemoryBus, + appRouter, + createApp, + createMetrics, + ensureEvent, +} from "@query/judging-server"; +import type { Actor, Role } from "@query/judging-server"; +import { createContext } from "../context"; +import { + isBugTesterRole, + isExpiredAdmin, + isStaffRole, +} from "../types/portal-context"; + +/** Every hackathon edition is one judging event in this organization. */ +export const PANEL_ORG = { slug: "hacklytics", name: "Hacklytics" } as const; + +/** The judging event for an edition, keyed by the hackathon id. Created on first use. */ +export async function panelEventFor(hackathon: { id: string; name: string }) { + return ensureEvent(panel().db, { + orgSlug: PANEL_ORG.slug, + orgName: PANEL_ORG.name, + eventSlug: hackathon.id, + name: hackathon.name, + }); +} + +type Embedded = { + db: PanelDb; + app: ReturnType; + bus: InMemoryBus; + metrics: ReturnType; +}; + +const globalForPanel = globalThis as unknown as { panel?: Embedded }; + +/** + * Judging runs inside the portal: same process, same Postgres, and the portal + * sign-in decides who the caller is. It borrows the club's pool rather than + * opening its own: the club pool is tuned to let Neon scale to zero, and a + * second pool would hold a second set of connections to the same database. + * One instance per process, so the metrics registry is not rebuilt per request. + */ +export function panel(): Embedded { + if (globalForPanel.panel) return globalForPanel.panel; + if (!clubDb) throw new Error("DATABASE_URL is not set"); + const { db } = createDbFromPool(clubDb.$client); + const metrics = createMetrics(); + const bus = new InMemoryBus(); + const app = createApp({ + db, + metrics, + bus, + jwtSecret: process.env.PANEL_JWT_SECRET ?? "", + devAuth: false, + busMode: "memory", + resolveActor: () => portalActor(), + }); + globalForPanel.panel = { db, app, bus, metrics }; + return globalForPanel.panel; +} + +/** The judging procedures, called by the portal itself to sync an edition. */ +export function panelAsPortal() { + const { db, bus, metrics } = panel(); + return appRouter.createCaller({ + db, + bus, + metrics, + now: new Date(), + actor: { + sub: "portal", + email: "portal", + name: "Portal", + org: null, + role: "admin", + judgeExternalId: null, + }, + }); +} + +/** + * The signed-in portal user as a judging actor. Staff organize, a volunteer + * row is a volunteer, and a judge on a panel edition is matched by the email + * on their judge row, which is the one synced into judging. + */ +export async function portalActor(): Promise { + const ctx = await createContext(); + const email = ctx.session?.user?.email; + if (!ctx.db || !ctx.userId || !email) return null; + const db = ctx.db as DrizzleDB; + const [admin, judgeRows] = await Promise.all([ + db.query.admins.findFirst({ + where: and(eq(admins.userId, ctx.userId), eq(admins.isActive, true)), + }), + db + .select({ email: judges.email }) + .from(judges) + .innerJoin(hackathons, eq(hackathons.id, judges.hackathonId)) + .where( + and( + eq(judges.userId, ctx.userId), + eq(judges.isActive, true), + eq(hackathons.judgingBackend, "panel"), + ), + ), + ]); + return { + sub: ctx.userId, + email: judgeRows[0]?.email ?? email, + name: ctx.session?.user?.name ?? email, + org: PANEL_ORG.slug, + role: roleFor(admin ?? null), + judgeExternalId: null, + }; +} + +function roleFor( + admin: { role: string; expiresAt: Date | null } | null, +): Role | null { + if (!admin || isExpiredAdmin(admin) || isBugTesterRole(admin.role)) { + return null; + } + if (admin.role === "super_admin") return "owner"; + if (isStaffRole(admin.role)) return "admin"; + return "volunteer"; +} diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index 140b2ec7..1fa702c4 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "drizzle-kit"; import * as dotenv from "dotenv"; +import fs from "fs"; import path from "path"; // Load .env from root (two levels up) @@ -9,8 +10,29 @@ if (!process.env.DATABASE_URL) { throw new Error("DATABASE_URL is not defined in .env file"); } +// Judging (packages/judging-db) keeps its tables in this database and applies +// them with its own SQL migrations. Left visible, push sees them as tables to +// drop and stops at a prompt; the build has no TTY, so the prompt errors, push +// exits 0, and every club schema change after that is silently skipped. +// The list comes from the migration files, so a new judging table is covered. +const judgingMigrations = path.resolve(__dirname, "../judging-db/migrations"); +const judgingTables = [ + "panel_migration", + ...fs + .readdirSync(judgingMigrations) + .filter((file) => file.endsWith(".sql")) + .flatMap((file) => + [ + ...fs + .readFileSync(path.join(judgingMigrations, file), "utf8") + .matchAll(/^create table (\w+)/gm), + ].map((match) => match[1] as string), + ), +]; + export default defineConfig({ - schema: "./src/schemas/**/*.ts", + schema: ["./src/schemas/**/*.ts", "../judging-db/src/enums.ts"], + tablesFilter: judgingTables.map((table) => `!${table}`), out: "./drizzle", dialect: "postgresql", dbCredentials: { diff --git a/packages/db/src/schemas/hackathons.ts b/packages/db/src/schemas/hackathons.ts index 0ada7f93..1df9860c 100644 --- a/packages/db/src/schemas/hackathons.ts +++ b/packages/db/src/schemas/hackathons.ts @@ -56,6 +56,11 @@ export const hackathons = pgTable( websiteUrl: text("website_url"), isPublic: boolean("is_public").notNull().default(true), judgingActive: boolean("judging_active").notNull().default(false), + // legacy keeps the current judge portal. panel hands the same edition to + // the standalone judging service. Additive: existing rows stay on legacy. + judgingBackend: text("judging_backend", { enum: ["legacy", "panel"] }) + .notNull() + .default("legacy"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, diff --git a/packages/judging-cli/eslint.config.mjs b/packages/judging-cli/eslint.config.mjs new file mode 100644 index 00000000..b1dfd25a --- /dev/null +++ b/packages/judging-cli/eslint.config.mjs @@ -0,0 +1,10 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...config, + { + files: ["src/**/*.ts"], + rules: { "no-console": "off" }, + }, +]; diff --git a/packages/judging-cli/package.json b/packages/judging-cli/package.json new file mode 100644 index 00000000..36268c76 --- /dev/null +++ b/packages/judging-cli/package.json @@ -0,0 +1,24 @@ +{ + "name": "@query/judging-cli", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "panel": "tsx src/main.ts", + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc --noEmit", + "test": "node ../../node_modules/vitest/vitest.mjs run src/csv.test.ts" + }, + "dependencies": { + "@query/judging-db": "workspace:*", + "@query/judging-server": "workspace:*", + "drizzle-orm": "0.45.3", + "tsx": "^4.23.15" + }, + "devDependencies": { + "@query/eslint-config": "workspace:*", + "@query/tsconfig": "workspace:*", + "@types/node": "22.20.4", + "typescript": "7.0.2" + } +} diff --git a/packages/judging-cli/src/csv.test.ts b/packages/judging-cli/src/csv.test.ts new file mode 100644 index 00000000..b451f79c --- /dev/null +++ b/packages/judging-cli/src/csv.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { formatResultsCsv, formatResultsJson, parseProjectCsv } from "./csv"; + +describe("parseProjectCsv", () => { + it("reads a Devpost-style header", () => { + const rows = parseProjectCsv( + "Project Title,Submission Url,Team Name,Table\nAlpha,https://example.com/a,A Team,4\n", + ); + expect(rows).toEqual([ + { + externalId: "https://example.com/a", + name: "Alpha", + teamName: "A Team", + tableNumber: 4, + }, + ]); + }); + + it("uses a column map when the headers are not the defaults", () => { + const rows = parseProjectCsv("Title,Squad\nBeta,B Team\n", { name: "Title", team: "Squad" }); + expect(rows[0]?.name).toBe("Beta"); + expect(rows[0]?.teamName).toBe("B Team"); + }); + + it("rejects a sheet with no name column", () => { + expect(() => parseProjectCsv("notes\nhello\n")).toThrow(/name/); + }); +}); + +describe("formatResultsCsv", () => { + it("quotes a name that contains a comma", () => { + expect( + formatResultsCsv([{ project: "A, B", track: "general", placement: 1, score: 7.71 }]), + ).toBe('project,track,placement,score\n"A, B",general,1,7.71\n'); + }); + + it("writes the same rows as JSON", () => { + const rows = [{ project: "Alpha", track: "general", placement: 1, score: 7.71 }]; + expect(JSON.parse(formatResultsJson(rows))).toEqual(rows); + }); +}); diff --git a/packages/judging-cli/src/csv.ts b/packages/judging-cli/src/csv.ts new file mode 100644 index 00000000..3414472a --- /dev/null +++ b/packages/judging-cli/src/csv.ts @@ -0,0 +1,127 @@ +export type ImportedProject = { + externalId: string; + name: string; + teamName: string | null; + tableNumber: number | null; +}; + +/** Devpost exports and a plain name,team,table sheet. The first row is headers. + * A column map renames headers: `name: Project Title`. + */ +export function parseProjectCsv( + text: string, + columnMap: Record = {}, +): ImportedProject[] { + const rows = parseRows(text); + const header = rows[0]; + if (!header) return []; + const index = new Map(header.map((cell, i) => [cell.trim().toLowerCase(), i])); + const nameAt = column(index, columnMap.name, ["project title", "name"]); + if (nameAt === undefined) { + throw new Error("CSV needs a name or Project Title column"); + } + const teamAt = column(index, columnMap.team, ["team name", "team"]); + const tableAt = column(index, columnMap.table, ["table", "table number"]); + const idAt = column(index, columnMap.externalId, ["submission url", "external id", "id"]); + + return rows.slice(1).flatMap((row) => { + const name = row[nameAt]?.trim(); + if (!name) return []; + const tableRaw = tableAt === undefined ? "" : (row[tableAt] ?? ""); + const tableNumber = tableRaw.trim() === "" ? null : Number(tableRaw); + return [ + { + externalId: (idAt === undefined ? "" : (row[idAt] ?? "")).trim() || name, + name, + teamName: teamAt === undefined ? null : row[teamAt]?.trim() || null, + tableNumber: tableNumber !== null && Number.isFinite(tableNumber) ? tableNumber : null, + }, + ]; + }); +} + +export type ExportedResult = { + project: string; + track: string; + placement: number | null; + score: number; +}; + +export function formatResultsCsv(rows: readonly ExportedResult[]): string { + const lines = ["project,track,placement,score"]; + for (const row of rows) { + lines.push( + [cell(row.project), cell(row.track), row.placement ?? "", row.score].join(","), + ); + } + return `${lines.join("\n")}\n`; +} + +export function formatResultsJson(rows: readonly ExportedResult[]): string { + return `${JSON.stringify(rows, null, 2)}\n`; +} + +export function parseColumnMap(text: string): Record { + const map: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const split = trimmed.indexOf(":"); + if (split === -1) continue; + map[trimmed.slice(0, split).trim()] = trimmed.slice(split + 1).trim(); + } + return map; +} + +function column( + index: Map, + mapped: string | undefined, + fallbacks: string[], +): number | undefined { + const names = mapped ? [mapped.toLowerCase(), ...fallbacks] : fallbacks; + for (const name of names) { + const at = index.get(name); + if (at !== undefined) return at; + } + return undefined; +} + +function cell(value: string): string { + if (/[",\n]/.test(value)) return `"${value.replaceAll('"', '""')}"`; + return value; +} + +function parseRows(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ""; + let quoted = false; + const source = text.replace(/^\uFEFF/, ""); + for (let i = 0; i < source.length; i += 1) { + const char = source[i]; + if (quoted) { + if (char === '"') { + if (source[i + 1] === '"') { + field += '"'; + i += 1; + } else quoted = false; + } else field += char; + continue; + } + if (char === '"') quoted = true; + else if (char === ",") { + row.push(field); + field = ""; + } else if (char === "\n") { + row.push(field); + rows.push(row); + row = []; + field = ""; + } else if (char !== "\r") field += char; + } + if (field.length > 0 || row.length > 0) { + row.push(field); + rows.push(row); + } + return rows.filter((item) => item.some((value) => value.trim() !== "")); +} diff --git a/packages/judging-cli/src/main.ts b/packages/judging-cli/src/main.ts new file mode 100644 index 00000000..69ac7081 --- /dev/null +++ b/packages/judging-cli/src/main.ts @@ -0,0 +1,155 @@ +import { + appliedMigrations, + createDb, + migrate, + migrationFiles, + databaseUrl, + requireDatabaseUrl, + seedDemo, +} from "@query/judging-db"; +import { drainOutbox } from "@query/judging-server/outbox"; +import { exportResults, importProjects } from "./transfer"; + +const [command, arg] = process.argv.slice(2); + +async function main() { + if (command === "import" && arg === "projects") { + const positionals = positionalsAfter(4); + const file = positionals[0]; + const eventId = positionals[1]; + const mapFile = option("--map"); + if (!file || !eventId) { + console.log("usage: panel import projects [--map columns.yaml]"); + process.exit(1); + } + const count = await importProjects(file, eventId, mapFile); + console.log(count); + return; + } + + if (command === "export" && arg === "results") { + const format = option("--format") === "json" ? "json" : "csv"; + const positionals = positionalsAfter(4); + const eventId = positionals[0]; + const file = positionals[1] ?? (format === "json" ? "results.json" : "results.csv"); + if (!eventId) { + console.log("usage: panel export results [file] [--format csv|json]"); + process.exit(1); + } + const count = await exportResults(eventId, file, format); + console.log(count); + return; + } + + if (command === "migrate") { + const { pool } = createDb(requireDatabaseUrl()); + const fresh = await migrate(pool); + console.log(fresh.length > 0 ? fresh.join("\n") : "up to date"); + await pool.end(); + return; + } + + if (command === "seed" && arg === "demo") { + const { db, pool } = createDb(requireDatabaseUrl()); + await migrate(pool); + const seeded = await seedDemo(db); + console.log(seeded.eventId); + await pool.end(); + return; + } + + if (command === "outbox" && arg === "drain") { + const { db, pool } = createDb(requireDatabaseUrl()); + let total = 0; + try { + for (;;) { + const delivered = await drainOutbox(db); + total += delivered; + if (delivered === 0) break; + } + } finally { + await pool.end(); + } + console.log(total); + return; + } + + if (command === "doctor") { + const checks: { name: string; ok: boolean; detail: string }[] = []; + const url = databaseUrl(); + if (!url) { + checks.push({ + name: "postgres", + ok: false, + detail: "DATABASE_URL is not set", + }); + } else { + const { pool } = createDb(url); + try { + await pool.query("select 1"); + checks.push({ name: "postgres", ok: true, detail: "connected" }); + const files = await migrationFiles(); + const applied = await appliedMigrations(pool); + const pending = files.filter((file) => !applied.includes(file)); + checks.push({ + name: "migrations", + ok: pending.length === 0, + detail: pending.length > 0 ? `pending ${pending.join(", ")}` : "up to date", + }); + } catch (error) { + checks.push({ + name: "postgres", + ok: false, + detail: error instanceof Error ? error.message : "connection failed", + }); + } finally { + await pool.end(); + } + } + + // Optional: the portal signs judges in with its own session. The secret + // only matters for callers outside it that send a bearer token. + const secret = process.env.PANEL_JWT_SECRET; + checks.push({ + name: "jwt", + ok: true, + detail: secret ? "PANEL_JWT_SECRET is set" : "not set (portal sign-in only)", + }); + checks.push({ + name: "redis", + ok: true, + detail: process.env.REDIS_URL ? "REDIS_URL is set" : "not configured", + }); + + for (const check of checks) { + console.log(`${check.ok ? "ok" : "fail"} ${check.name} ${check.detail}`); + } + if (checks.some((check) => !check.ok)) process.exit(1); + return; + } + + console.log( + "usage: panel migrate | panel seed demo | panel doctor | panel outbox drain | panel import projects | panel export results [file]", + ); + process.exit(1); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); + +function option(name: string): string | undefined { + const index = process.argv.indexOf(name); + if (index === -1) return undefined; + return process.argv[index + 1]; +} + +function positionalsAfter(start: number): string[] { + const args = process.argv.slice(start); + return args.filter((value, index) => { + if (value.startsWith("--")) return false; + const previous = args[index - 1]; + return previous !== "--format" && previous !== "--map"; + }); +} diff --git a/packages/judging-cli/src/transfer.ts b/packages/judging-cli/src/transfer.ts new file mode 100644 index 00000000..4469539f --- /dev/null +++ b/packages/judging-cli/src/transfer.ts @@ -0,0 +1,67 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { and, eq } from "drizzle-orm"; +import { createDb, project, requireDatabaseUrl, result, resultRun, track } from "@query/judging-db"; +import { formatResultsCsv, formatResultsJson, parseColumnMap, parseProjectCsv } from "./csv"; + +export async function importProjects(file: string, eventId: string, mapFile?: string) { + const columnMap = mapFile ? parseColumnMap(await readFile(mapFile, "utf8")) : {}; + const rows = parseProjectCsv(await readFile(file, "utf8"), columnMap); + const { db, pool } = createDb(requireDatabaseUrl()); + try { + for (const row of rows) { + const [found] = await db + .select({ id: project.id }) + .from(project) + .where(and(eq(project.eventId, eventId), eq(project.externalId, row.externalId))); + if (found) { + await db + .update(project) + .set({ name: row.name, teamName: row.teamName, tableNumber: row.tableNumber }) + .where(eq(project.id, found.id)); + } else { + await db.insert(project).values({ + eventId, + externalId: row.externalId, + name: row.name, + teamName: row.teamName, + tableNumber: row.tableNumber, + }); + } + } + return rows.length; + } finally { + await pool.end(); + } +} + +export async function exportResults( + eventId: string, + file: string, + format: "csv" | "json" = "csv", +) { + const { db, pool } = createDb(requireDatabaseUrl()); + try { + const [run] = await db + .select({ id: resultRun.id }) + .from(resultRun) + .where(eq(resultRun.eventId, eventId)); + if (!run) throw new Error("No result run for that event"); + const rows = await db + .select({ + project: project.name, + track: track.slug, + placement: result.placement, + score: result.score, + }) + .from(result) + .innerJoin(project, eq(project.id, result.projectId)) + .innerJoin(track, eq(track.id, result.trackId)) + .where(eq(result.runId, run.id)); + const body = + format === "json" ? formatResultsJson(rows) : formatResultsCsv(rows); + await writeFile(file, body); + return rows.length; + } finally { + await pool.end(); + } +} diff --git a/packages/judging-cli/tsconfig.json b/packages/judging-cli/tsconfig.json new file mode 100644 index 00000000..f54bde2c --- /dev/null +++ b/packages/judging-cli/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tooling/typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/judging-core/eslint.config.mjs b/packages/judging-core/eslint.config.mjs new file mode 100644 index 00000000..09f98a9a --- /dev/null +++ b/packages/judging-core/eslint.config.mjs @@ -0,0 +1,40 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...config, + { + files: ["src/**/*.ts"], + ignores: ["src/**/*.test.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "process", + importNames: ["env"], + message: + "@query/judging-core takes its inputs as arguments. It does not read the environment.", + }, + ], + patterns: [ + { + group: [ + "node:*", + "fs", + "path", + "crypto", + "node:fs", + "node:path", + "node:crypto", + ], + message: + "@query/judging-core has no runtime dependencies and does not call Node APIs.", + }, + ], + }, + ], + }, + }, +]; diff --git a/packages/judging-core/package.json b/packages/judging-core/package.json new file mode 100644 index 00000000..1e961bdd --- /dev/null +++ b/packages/judging-core/package.json @@ -0,0 +1,33 @@ +{ + "name": "@query/judging-core", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Pure judging decisions: dispatch, rubric, phase, and ranking.", + "exports": { + ".": "./src/index.ts" + }, + "files": ["dist"], + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } + }, + "scripts": { + "lint": "eslint . --max-warnings 0", + "build": "tsc -p tsconfig.build.json && node scripts/emit-extensions.mjs", + "typecheck": "tsc --noEmit", + "test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts" + }, + "devDependencies": { + "@query/eslint-config": "workspace:*", + "@query/tsconfig": "workspace:*", + "@types/node": "22.20.4", + "typescript": "7.0.2", + "vitest": "^5.0.1" + } +} diff --git a/packages/judging-core/scripts/emit-extensions.mjs b/packages/judging-core/scripts/emit-extensions.mjs new file mode 100644 index 00000000..4efdcb3b --- /dev/null +++ b/packages/judging-core/scripts/emit-extensions.mjs @@ -0,0 +1,25 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const dist = join(dirname(fileURLToPath(import.meta.url)), "../dist"); + +await walk(dist); + +async function walk(dir) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(path); + continue; + } + if (!entry.name.endsWith(".js")) continue; + const source = await readFile(path, "utf8"); + const next = source.replaceAll( + /(from\s+["'])(\.\.?\/[^"']+)(["'])/g, + (match, open, spec, close) => + spec.endsWith(".js") ? match : `${open}${spec}.js${close}`, + ); + if (next !== source) await writeFile(path, next); + } +} diff --git a/packages/judging-core/src/aggregate/bayes.test.ts b/packages/judging-core/src/aggregate/bayes.test.ts new file mode 100644 index 00000000..df67d118 --- /dev/null +++ b/packages/judging-core/src/aggregate/bayes.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { bayesianShrink, round2 } from "./bayes"; + +describe("bayesianShrink", () => { + it("is zero when nobody voted", () => { + expect(bayesianShrink(9, 0, 5, 2)).toBe(0); + }); + + it("weighs two votes equally with the prior at c = 2", () => { + expect(bayesianShrink(8, 2, 4, 2)).toBe(6); + }); + + it("approaches the project's own average as votes accumulate", () => { + const few = bayesianShrink(10, 1, 0, 2); + const many = bayesianShrink(10, 100, 0, 2); + expect(many).toBeGreaterThan(few); + expect(many).toBeGreaterThan(9.5); + }); +}); + +describe("round2", () => { + it("rounds half up to the hundredth", () => { + expect(round2(1.234)).toBe(1.23); + expect(round2(1.235)).toBe(1.24); + expect(round2(7.085)).toBe(7.09); + }); +}); diff --git a/packages/judging-core/src/aggregate/bayes.ts b/packages/judging-core/src/aggregate/bayes.ts new file mode 100644 index 00000000..15f764fe --- /dev/null +++ b/packages/judging-core/src/aggregate/bayes.ts @@ -0,0 +1,19 @@ +/** + * Pull a project's normalised average toward the field when few judges have + * seen it. `c` is how many average votes the prior is worth. At `c = 2`, + * two real votes and the prior weigh the same. + */ +export function bayesianShrink( + normalizedAvg: number, + n: number, + globalAvg: number, + c: number, +): number { + if (n <= 0) return 0; + return (n / (n + c)) * normalizedAvg + (c / (n + c)) * globalAvg; +} + +/** Hundredths, same rounding the published placings use. */ +export function round2(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/packages/judging-core/src/aggregate/blend.test.ts b/packages/judging-core/src/aggregate/blend.test.ts new file mode 100644 index 00000000..d40cf033 --- /dev/null +++ b/packages/judging-core/src/aggregate/blend.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { blend } from "./blend"; + +describe("blend", () => { + it("returns the rubric at weight 0 and the pairwise score at weight 1", () => { + expect(blend(4, 10, 0)).toBe(4); + expect(blend(4, 10, 1)).toBe(10); + }); + + it("takes the midpoint at a half", () => { + expect(blend(4, 10, 0.5)).toBe(7); + }); + + it("rejects a weight outside 0 to 1", () => { + expect(() => blend(1, 1, 1.1)).toThrow(/weight/); + expect(() => blend(1, 1, -0.1)).toThrow(/weight/); + }); +}); diff --git a/packages/judging-core/src/aggregate/blend.ts b/packages/judging-core/src/aggregate/blend.ts new file mode 100644 index 00000000..9b3bba95 --- /dev/null +++ b/packages/judging-core/src/aggregate/blend.ts @@ -0,0 +1,11 @@ +/** `weight` is the share of the pairwise component. Zero is rubric only. */ +export function blend( + rubric: number, + pairwise: number, + weight: number, +): number { + if (!Number.isFinite(weight) || weight < 0 || weight > 1) { + throw new Error("pairwise weight must be between 0 and 1"); + } + return (1 - weight) * rubric + weight * pairwise; +} diff --git a/packages/judging-core/src/aggregate/bradleyTerry.test.ts b/packages/judging-core/src/aggregate/bradleyTerry.test.ts new file mode 100644 index 00000000..3a54f9b6 --- /dev/null +++ b/packages/judging-core/src/aggregate/bradleyTerry.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { bradleyTerry } from "./bradleyTerry"; + +describe("bradleyTerry", () => { + it("returns an empty map when there is nobody", () => { + expect(bradleyTerry([], []).size).toBe(0); + }); + + it("leaves everyone equal when there are no comparisons", () => { + const strength = bradleyTerry(["a", "b"], []); + expect(strength.get("a")).toBe(1); + expect(strength.get("b")).toBe(1); + }); + + it("ranks a consistent set in transitive order", () => { + const strength = bradleyTerry( + ["a", "b", "c"], + [ + { a: "a", b: "b", outcome: "a" }, + { a: "b", b: "c", outcome: "a" }, + { a: "a", b: "c", outcome: "a" }, + ], + ); + const a = strength.get("a") ?? 0; + const b = strength.get("b") ?? 0; + const c = strength.get("c") ?? 0; + expect(a).toBeGreaterThan(b); + expect(b).toBeGreaterThan(c); + }); + + it("puts a tie closer together than a sweep", () => { + const tied = bradleyTerry( + ["a", "b"], + [ + { a: "a", b: "b", outcome: "tie" }, + { a: "a", b: "b", outcome: "tie" }, + ], + ); + const swept = bradleyTerry( + ["a", "b"], + [ + { a: "a", b: "b", outcome: "a" }, + { a: "a", b: "b", outcome: "a" }, + ], + ); + const tieGap = Math.abs((tied.get("a") ?? 0) - (tied.get("b") ?? 0)); + const sweepGap = Math.abs((swept.get("a") ?? 0) - (swept.get("b") ?? 0)); + expect(tieGap).toBeLessThan(sweepGap); + }); +}); diff --git a/packages/judging-core/src/aggregate/bradleyTerry.ts b/packages/judging-core/src/aggregate/bradleyTerry.ts new file mode 100644 index 00000000..71132a65 --- /dev/null +++ b/packages/judging-core/src/aggregate/bradleyTerry.ts @@ -0,0 +1,72 @@ +import type { Comparison } from "../comparison"; + +/** + * Bradley-Terry strengths from pairwise outcomes. A tie is half a win each. + * Strengths are scaled so their mean is 1. Projects that never appear in a + * comparison are left at 1. Iteration is the minorization-maximization + * update; `iterations` is a cap, not a clock. + */ +export function bradleyTerry( + projectIds: readonly string[], + comparisons: readonly Comparison[], + iterations = 50, +): Map { + const ids = [...new Set(projectIds)]; + const strength = new Map(ids.map((id) => [id, 1])); + if (ids.length === 0 || comparisons.length === 0) return strength; + + const wins = new Map(ids.map((id) => [id, 0])); + const games = new Map>(); + + const addGame = (left: string, right: string) => { + const row = games.get(left) ?? new Map(); + row.set(right, (row.get(right) ?? 0) + 1); + games.set(left, row); + }; + + for (const comparison of comparisons) { + if ( + comparison.a === comparison.b || + !strength.has(comparison.a) || + !strength.has(comparison.b) + ) { + continue; + } + if (comparison.outcome === "tie") { + wins.set(comparison.a, (wins.get(comparison.a) ?? 0) + 0.5); + wins.set(comparison.b, (wins.get(comparison.b) ?? 0) + 0.5); + } else if (comparison.outcome === "a") { + wins.set(comparison.a, (wins.get(comparison.a) ?? 0) + 1); + } else { + wins.set(comparison.b, (wins.get(comparison.b) ?? 0) + 1); + } + addGame(comparison.a, comparison.b); + addGame(comparison.b, comparison.a); + } + + for (let step = 0; step < iterations; step += 1) { + const next = new Map(); + for (const id of ids) { + const opponents = games.get(id); + if (!opponents || opponents.size === 0) { + next.set(id, strength.get(id) ?? 1); + continue; + } + let denominator = 0; + for (const [other, count] of opponents) { + const pi = strength.get(id) ?? 1; + const pj = strength.get(other) ?? 1; + const sum = pi + pj; + if (sum > 0) denominator += count / sum; + } + const won = wins.get(id) ?? 0; + next.set(id, denominator > 0 ? won / denominator : 0); + } + let total = 0; + for (const value of next.values()) total += value; + const mean = total / ids.length || 1; + for (const id of ids) strength.set(id, (next.get(id) ?? 0) / mean); + } + + return strength; +} diff --git a/packages/judging-core/src/aggregate/rank.test.ts b/packages/judging-core/src/aggregate/rank.test.ts new file mode 100644 index 00000000..800c28d9 --- /dev/null +++ b/packages/judging-core/src/aggregate/rank.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "vitest"; +import { zNormalize } from "./zscore"; +import { bayesianShrink, round2 } from "./bayes"; +import { rank } from "./rank"; +import type { ProjectInput, RankComparison, RankConfig, VoteInput } from "./rank"; + +/** + * The ranking the current event publishes: z-score per judge against the + * population mean and spread, round the project mean, shrink toward the mean + * of those means with c = 2, round again. Same raw score maps to one + * normalised value because the z-score is linear, so a value-keyed lookup + * and an index-aligned one agree. + */ +function legacyRank( + projects: readonly { id: string }[], + votes: readonly { judgeId: string; projectId: string; score: number }[], +) { + const byJudge = new Map(); + for (const vote of votes) { + const list = byJudge.get(vote.judgeId) ?? []; + list.push(vote.score); + byJudge.set(vote.judgeId, list); + } + const raw = [...byJudge.values()].flat(); + const globalMean = raw.length > 0 ? raw.reduce((a, b) => a + b, 0) / raw.length : 0; + const globalVariance = + raw.length > 0 + ? raw.reduce((sum, value) => sum + (value - globalMean) ** 2, 0) / raw.length + : 1; + const globalStd = Math.sqrt(globalVariance) || 1; + + const normalizedByJudge = new Map>(); + for (const [judgeId, scores] of byJudge) { + const normalized = zNormalize(scores, globalMean, globalStd); + const lookup = new Map(); + scores.forEach((score, index) => { + const existing = lookup.get(score); + const value = normalized[index] ?? globalMean; + lookup.set(score, existing !== undefined ? (existing + value) / 2 : value); + }); + normalizedByJudge.set(judgeId, lookup); + } + + const rows = projects.map((project) => { + const mine = votes.filter((vote) => vote.projectId === project.id); + const normalizedScores = mine.map( + (vote) => normalizedByJudge.get(vote.judgeId)?.get(vote.score) ?? vote.score, + ); + const normalizedAvg = + mine.length > 0 + ? round2(normalizedScores.reduce((a, b) => a + b, 0) / mine.length) + : 0; + return { id: project.id, voteCount: mine.length, normalizedAvg }; + }); + + const voted = rows.filter((row) => row.voteCount > 0); + const globalAvg = + voted.length > 0 + ? round2(voted.reduce((sum, row) => sum + row.normalizedAvg, 0) / voted.length) + : 0; + + return rows + .map((row) => ({ + id: row.id, + voteCount: row.voteCount, + score: + row.voteCount > 0 + ? round2(bayesianShrink(row.normalizedAvg, row.voteCount, globalAvg, 2)) + : 0, + })) + .filter((row) => row.voteCount > 0) + .sort((a, b) => b.score - a.score || projects.findIndex((p) => p.id === a.id) - projects.findIndex((p) => p.id === b.id)); +} + +const config: RankConfig = { + bayesianC: 2, + pairwiseWeight: 0, + calibrationWeight: 1, +}; + +const projects: ProjectInput[] = [ + { id: "A", trackIds: ["overall"] }, + { id: "B", trackIds: ["overall"] }, + { id: "C", trackIds: ["overall"] }, +]; + +function vote(judgeId: string, projectId: string, total: number): VoteInput { + return { judgeId, projectId, judgeGroup: "main", total }; +} + +describe("rank matches the current event's pipeline at pairwise weight 0", () => { + const votes = [ + vote("j1", "A", 8), + vote("j1", "B", 5), + vote("j1", "C", 2), + vote("j2", "A", 9), + vote("j2", "B", 9), + vote("j2", "C", 6), + ]; + + it("places the same projects at the same hundredths", () => { + const legacy = legacyRank(projects, votes.map((item) => ({ + judgeId: item.judgeId, + projectId: item.projectId, + score: item.total, + }))); + const ranked = rank(projects, votes, [], config) + .filter((row) => row.placement !== null) + .sort((a, b) => (a.placement ?? 0) - (b.placement ?? 0)); + + expect(ranked.map((row) => [row.projectId, row.score])).toEqual( + legacy.map((row) => [row.id, row.score]), + ); + expect(ranked.map((row) => row.score)).toEqual([7.71, 6.94, 4.85]); + }); + + it("ignores comparisons when the pairwise weight is zero", () => { + const withPairs = rank( + projects, + votes, + [ + { + judgeId: "j1", + judgeGroup: "main", + a: "C", + b: "A", + outcome: "a", + }, + ], + config, + ); + const without = rank(projects, votes, [], config); + expect(withPairs.map((row) => row.score)).toEqual(without.map((row) => row.score)); + }); +}); + +describe("rank", () => { + it("does not place a project nobody scored", () => { + const rows = rank( + [ + { id: "seen", trackIds: ["overall"] }, + { id: "missed", trackIds: ["overall"] }, + ], + [vote("j1", "seen", 8), vote("j2", "seen", 6)], + [], + config, + ); + const seen = rows.find((row) => row.projectId === "seen"); + const missed = rows.find((row) => row.projectId === "missed"); + expect(seen?.placement).toBe(1); + expect(missed?.placement).toBeNull(); + }); + + it("keeps judge groups apart", () => { + const rows = rank( + [{ id: "A", trackIds: ["overall"] }], + [ + { judgeId: "j1", projectId: "A", judgeGroup: "main", total: 10 }, + { judgeId: "j2", projectId: "A", judgeGroup: "sponsor", total: 1 }, + ], + [], + config, + ); + expect(rows).toHaveLength(2); + expect(rows.find((row) => row.judgeGroup === "main")?.voteCount).toBe(1); + expect(rows.find((row) => row.judgeGroup === "sponsor")?.voteCount).toBe(1); + }); + + it("drops a calibration vote whose weight is zero", () => { + const base = rank(projects, [vote("j1", "A", 8), vote("j1", "B", 4)], [], config); + const extra = rank( + projects, + [ + vote("j1", "A", 8), + vote("j1", "B", 4), + { ...vote("j1", "C", 1), isCalibration: true }, + ], + [], + { ...config, calibrationWeight: 0 }, + ); + const score = (rows: typeof base, id: string) => + rows.find((row) => row.projectId === id)?.score; + expect(score(extra, "A")).toBe(score(base, "A")); + expect(score(extra, "B")).toBe(score(base, "B")); + expect(extra.find((row) => row.projectId === "C")?.voteCount).toBe(0); + }); + + it("orders a pairwise-only field by who won, at weight 1", () => { + const rows = rank( + projects, + [], + [ + { judgeId: "j1", judgeGroup: "main", a: "A", b: "B", outcome: "a" }, + { judgeId: "j1", judgeGroup: "main", a: "B", b: "C", outcome: "a" }, + { judgeId: "j1", judgeGroup: "main", a: "A", b: "C", outcome: "a" }, + ], + { ...config, pairwiseWeight: 1 }, + ); + const placed = rows + .filter((row) => row.placement !== null) + .sort((a, b) => (a.placement ?? 0) - (b.placement ?? 0)); + expect(placed.map((row) => row.projectId)).toEqual(["A", "B", "C"]); + }); +}); + +describe("rank at pairwise weight 1", () => { + it("keeps the true order when every comparison agrees", () => { + const ids = ["a", "b", "c", "d", "e"]; + let seed = 11; + const random = () => { + seed = (seed * 1664525 + 1013904223) % 4294967296; + return seed / 4294967296; + }; + for (let trial = 0; trial < 20; trial += 1) { + const truth = [...ids].sort(() => random() - 0.5); + const better = new Map(truth.map((id, index) => [id, index])); + const comparisons: RankComparison[] = []; + for (let left = 0; left < ids.length; left += 1) { + for (let right = left + 1; right < ids.length; right += 1) { + const a = ids[left] ?? ""; + const b = ids[right] ?? ""; + const aWins = (better.get(a) ?? 0) < (better.get(b) ?? 0); + comparisons.push({ + judgeId: "j1", + judgeGroup: "main", + a, + b, + outcome: aWins ? ("a" as const) : ("b" as const), + }); + } + } + const rows = rank( + ids.map((id) => ({ id, trackIds: ["overall"] })), + [], + comparisons, + { ...config, pairwiseWeight: 1 }, + ); + const placed = rows + .filter((row) => row.placement !== null) + .sort((left, right) => (left.placement ?? 0) - (right.placement ?? 0)); + expect(placed.map((row) => row.projectId)).toEqual(truth); + } + }); +}); diff --git a/packages/judging-core/src/aggregate/rank.ts b/packages/judging-core/src/aggregate/rank.ts new file mode 100644 index 00000000..6f1fa9d8 --- /dev/null +++ b/packages/judging-core/src/aggregate/rank.ts @@ -0,0 +1,312 @@ +import type { Comparison } from "../comparison"; +import { bayesianShrink, round2 } from "./bayes"; +import { bradleyTerry } from "./bradleyTerry"; +import { blend } from "./blend"; +import { zNormalize } from "./zscore"; + +export type VoteInput = { + judgeId: string; + projectId: string; + judgeGroup: string; + total: number; + isCalibration?: boolean; +}; + +export type RankComparison = Comparison & { + judgeId: string; + judgeGroup: string; +}; + +export type ProjectInput = { + id: string; + trackIds: readonly string[]; +}; + +export type RankConfig = { + bayesianC: number; + pairwiseWeight: number; + /** How much a calibration vote counts, relative to a normal vote of 1. */ + calibrationWeight: number; +}; + +export type RankedRow = { + projectId: string; + trackId: string; + judgeGroup: string; + placement: number | null; + score: number; + rubricComponent: number; + pairwiseComponent: number; + voteCount: number; + comparisonCount: number; +}; + +/** + * Rank each track and judge group. + * + * Rubric side, at `pairwiseWeight = 0` and `calibrationWeight = 1`: z-score + * each judge onto the group's mean and spread, average per project (rounded + * to hundredths), shrink toward the mean of those averages with `bayesianC`, + * round again, and place by that score. That is the pipeline the existing + * event uses, so a replay of its votes lands on the same numbers. + * + * Pairwise side: Bradley-Terry on comparisons in the group, scaled onto the + * rubric scores, then blended. Weight 0 leaves the rubric number untouched. + * A calibration vote is down-weighted in the average and in the shrink's + * sample size. It still informs that judge's own z-score. + */ +export function rank( + projects: readonly ProjectInput[], + votes: readonly VoteInput[], + comparisons: readonly RankComparison[], + config: RankConfig, +): RankedRow[] { + const known = new Set(projects.map((project) => project.id)); + const tracks = trackOrder(projects); + const groups = groupOrder(votes, comparisons); + const rows: RankedRow[] = []; + + for (const judgeGroup of groups) { + const groupVotes = votes.filter( + (vote) => vote.judgeGroup === judgeGroup && known.has(vote.projectId), + ); + const normalized = normalize(groupVotes, config); + for (const trackId of tracks) { + const onTrack = projects.filter((project) => + project.trackIds.includes(trackId), + ); + const onTrackIds = new Set(onTrack.map((project) => project.id)); + const trackVotes = groupVotes + .map((vote, index) => ({ + vote, + normalized: normalized[index] ?? 0, + })) + .filter(({ vote }) => onTrackIds.has(vote.projectId)); + const trackComparisons = comparisons.filter( + (comparison) => + comparison.judgeGroup === judgeGroup && + onTrackIds.has(comparison.a) && + onTrackIds.has(comparison.b), + ); + rows.push( + ...rankTrack( + onTrack, + trackVotes, + trackComparisons, + trackId, + judgeGroup, + config, + ), + ); + } + } + + return rows; +} + +function weightOf(vote: VoteInput, config: RankConfig): number { + return vote.isCalibration ? config.calibrationWeight : 1; +} + +function spread(scores: readonly number[]): { mean: number; std: number } { + if (scores.length === 0) return { mean: 0, std: 1 }; + const mean = scores.reduce((sum, value) => sum + value, 0) / scores.length; + const variance = + scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / scores.length; + return { mean, std: Math.sqrt(variance) || 1 }; +} + +function normalize( + votes: readonly VoteInput[], + config: RankConfig, +): number[] { + const weights = votes.map((vote) => weightOf(vote, config)); + const raw: number[] = []; + for (let index = 0; index < votes.length; index += 1) { + const vote = votes[index]; + if (vote && (weights[index] ?? 0) > 0) raw.push(vote.total); + } + const { mean, std } = spread(raw); + + const byJudge = new Map(); + votes.forEach((vote, index) => { + if ((weights[index] ?? 0) <= 0) return; + const list = byJudge.get(vote.judgeId) ?? []; + list.push(index); + byJudge.set(vote.judgeId, list); + }); + + const out = votes.map(() => 0); + for (const indexes of byJudge.values()) { + const scores = indexes.map((index) => votes[index]?.total ?? 0); + const normalized = zNormalize(scores, mean, std); + indexes.forEach((index, offset) => { + out[index] = normalized[offset] ?? 0; + }); + } + return out; +} + +type CarriedVote = { vote: VoteInput; normalized: number }; + +function rankTrack( + projects: readonly ProjectInput[], + votes: readonly CarriedVote[], + comparisons: readonly RankComparison[], + trackId: string, + judgeGroup: string, + config: RankConfig, +): RankedRow[] { + const stats = new Map(); + for (const project of projects) { + let weightedSum = 0; + let valueSum = 0; + let count = 0; + for (const item of votes) { + if (item.vote.projectId !== project.id) continue; + const weight = weightOf(item.vote, config); + if (weight <= 0) continue; + weightedSum += weight; + valueSum += item.normalized * weight; + count += 1; + } + stats.set(project.id, { + avg: weightedSum > 0 ? round2(valueSum / weightedSum) : 0, + n: weightedSum, + count, + }); + } + + const votedAverages: number[] = []; + for (const row of stats.values()) { + if (row.n > 0) votedAverages.push(row.avg); + } + const globalAvg = + votedAverages.length > 0 + ? round2( + votedAverages.reduce((sum, value) => sum + value, 0) / + votedAverages.length, + ) + : 0; + + const rubricScore = new Map(); + const anchors: number[] = []; + for (const project of projects) { + const row = stats.get(project.id); + const n = row?.n ?? 0; + const score = + n > 0 + ? round2(bayesianShrink(row?.avg ?? 0, n, globalAvg, config.bayesianC)) + : 0; + rubricScore.set(project.id, score); + if (n > 0) anchors.push(score); + } + + const compared = new Set(); + for (const comparison of comparisons) { + compared.add(comparison.a); + compared.add(comparison.b); + } + const scaled = scaleStrengths( + bradleyTerry([...compared], comparisons), + anchors, + compared, + ); + + const draft: RankedRow[] = projects.map((project) => { + const row = stats.get(project.id); + const rubricComponent = rubricScore.get(project.id) ?? 0; + const comparisonCount = comparisons.filter( + (comparison) => + comparison.a === project.id || comparison.b === project.id, + ).length; + const scaledValue = scaled.get(project.id); + const pairwiseForBlend = scaledValue ?? rubricComponent; + const score = + config.pairwiseWeight === 0 + ? rubricComponent + : round2( + blend(rubricComponent, pairwiseForBlend, config.pairwiseWeight), + ); + return { + projectId: project.id, + trackId, + judgeGroup, + placement: null, + score, + rubricComponent, + pairwiseComponent: scaledValue ?? 0, + voteCount: row?.count ?? 0, + comparisonCount, + }; + }); + + const order = new Map(projects.map((project, index) => [project.id, index])); + const placeable = draft.filter( + (row) => + row.voteCount > 0 || + (config.pairwiseWeight > 0 && row.comparisonCount > 0), + ); + placeable.sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; + // Hundredths can tie two neighbours. The unrounded pairwise strength + // still has the order the comparisons produced. + if ( + config.pairwiseWeight > 0 && + a.pairwiseComponent !== b.pairwiseComponent + ) { + return b.pairwiseComponent - a.pairwiseComponent; + } + return (order.get(a.projectId) ?? 0) - (order.get(b.projectId) ?? 0); + }); + placeable.forEach((row, index) => { + row.placement = index + 1; + }); + return draft; +} + +function scaleStrengths( + strength: ReadonlyMap, + anchors: readonly number[], + compared: ReadonlySet, +): Map { + const out = new Map(); + if (compared.size === 0) return out; + const values = [...compared].map((id) => strength.get(id) ?? 0); + const lowStrength = Math.min(...values); + const highStrength = Math.max(...values); + const low = anchors.length > 0 ? Math.min(...anchors) : lowStrength; + const high = anchors.length > 0 ? Math.max(...anchors) : highStrength; + for (const id of compared) { + const value = strength.get(id) ?? 0; + if (anchors.length === 0) out.set(id, value); + else if (highStrength === lowStrength) out.set(id, (low + high) / 2); + else { + out.set( + id, + low + + ((value - lowStrength) / (highStrength - lowStrength)) * (high - low), + ); + } + } + return out; +} + +function trackOrder(projects: readonly ProjectInput[]): string[] { + const seen = new Set(); + for (const project of projects) { + for (const trackId of project.trackIds) seen.add(trackId); + } + return [...seen]; +} + +function groupOrder( + votes: readonly VoteInput[], + comparisons: readonly RankComparison[], +): string[] { + const seen = new Set(); + for (const vote of votes) seen.add(vote.judgeGroup); + for (const comparison of comparisons) seen.add(comparison.judgeGroup); + if (seen.size === 0) seen.add("main"); + return [...seen]; +} diff --git a/packages/judging-core/src/aggregate/zscore.test.ts b/packages/judging-core/src/aggregate/zscore.test.ts new file mode 100644 index 00000000..29722da2 --- /dev/null +++ b/packages/judging-core/src/aggregate/zscore.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { zNormalize } from "./zscore"; + +const mean = (xs: number[]) => xs.reduce((sum, value) => sum + value, 0) / xs.length; +const stddev = (xs: number[]) => { + const center = mean(xs); + return Math.sqrt(mean(xs.map((value) => (value - center) ** 2))); +}; + +describe("zNormalize", () => { + it("returns the global mean when fewer than two scores", () => { + expect(zNormalize([8], 5, 2)).toEqual([5]); + expect(zNormalize([], 5, 2)).toEqual([]); + }); + + it("returns the global mean when a judge scored everything the same", () => { + expect(zNormalize([7, 7, 7], 5, 2)).toEqual([5, 5, 5]); + }); + + it("spreads scores around the global mean", () => { + const normalized = zNormalize([2, 5, 8], 5, 2); + expect(normalized[0] ?? 0).toBeLessThan(5); + expect(normalized[1] ?? 0).toBeCloseTo(5, 1); + expect(normalized[2] ?? 0).toBeGreaterThan(5); + }); + + it("preserves a judge's own ordering", () => { + const normalized = zNormalize([1, 4, 9, 10], 5, 2); + for (let index = 1; index < normalized.length; index += 1) { + expect(normalized[index] ?? 0).toBeGreaterThan(normalized[index - 1] ?? 0); + } + }); + + it("makes a harsh judge and a lenient judge agree", () => { + const harsh = zNormalize([1, 2, 3], 5, 2); + const lenient = zNormalize([8, 9, 10], 5, 2); + expect(harsh[0] ?? 0).toBeCloseTo(lenient[0] ?? 0, 10); + expect(harsh[1] ?? 0).toBeCloseTo(lenient[1] ?? 0, 10); + expect(harsh[2] ?? 0).toBeCloseTo(lenient[2] ?? 0, 10); + }); + + it("makes a wide-spread judge and a narrow-spread judge agree", () => { + const wide = zNormalize([0, 5, 10], 5, 2); + const narrow = zNormalize([4, 5, 6], 5, 2); + expect(wide[0] ?? 0).toBeCloseTo(narrow[0] ?? 0, 10); + expect(wide[2] ?? 0).toBeCloseTo(narrow[2] ?? 0, 10); + }); + + it("centres the normalized scores on the global mean", () => { + expect(mean(zNormalize([1, 4, 9, 10], 7, 2))).toBeCloseTo(7, 10); + }); + + it("gives the normalized scores the global spread", () => { + expect(stddev(zNormalize([1, 4, 9, 10], 5, 3))).toBeCloseTo(3, 10); + }); + + it("never produces NaN or Infinity for degenerate input", () => { + const cases = [ + zNormalize([7, 7], 5, 2), + zNormalize([0, 0, 0], 5, 2), + zNormalize([5], 5, 0), + zNormalize([1, 2], 0, 0), + ]; + for (const result of cases) { + for (const value of result) expect(Number.isFinite(value)).toBe(true); + } + }); + + it("handles negative scores and keeps equals equal", () => { + const negative = zNormalize([-5, 0, 5], 5, 2); + expect(negative[0] ?? 0).toBeLessThan(negative[2] ?? 0); + const tied = zNormalize([3, 3, 9], 5, 2); + expect(tied[0] ?? 0).toBeCloseTo(tied[1] ?? 0, 10); + }); + + it("collapses to the global mean when the global spread is zero", () => { + expect(zNormalize([1, 5, 9], 6, 0)).toEqual([6, 6, 6]); + }); +}); diff --git a/packages/judging-core/src/aggregate/zscore.ts b/packages/judging-core/src/aggregate/zscore.ts new file mode 100644 index 00000000..e6c96bb4 --- /dev/null +++ b/packages/judging-core/src/aggregate/zscore.ts @@ -0,0 +1,22 @@ +/** + * Z-score a judge's scores against their own mean and spread, then place + * that shape on the global mean and spread. A judge with fewer than two + * scores, or who gave everything the same number, has no spread of their + * own, so every score becomes the global mean. A global spread of zero + * collapses everyone onto the global mean too. + */ +export function zNormalize( + scores: readonly number[], + globalMean: number, + globalStd: number, +): number[] { + if (scores.length < 2) return scores.map(() => globalMean); + const mean = scores.reduce((sum, value) => sum + value, 0) / scores.length; + const variance = + scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / scores.length; + const std = Math.sqrt(variance); + if (std === 0 || globalStd === 0) return scores.map(() => globalMean); + return scores.map( + (value) => globalMean + ((value - mean) / std) * globalStd, + ); +} diff --git a/packages/judging-core/src/comparison.ts b/packages/judging-core/src/comparison.ts new file mode 100644 index 00000000..7937600b --- /dev/null +++ b/packages/judging-core/src/comparison.ts @@ -0,0 +1,5 @@ +export type Comparison = { + a: string; + b: string; + outcome: "a" | "b" | "tie"; +}; diff --git a/packages/judging-core/src/dispatch.sim.test.ts b/packages/judging-core/src/dispatch.sim.test.ts new file mode 100644 index 00000000..fa2f6396 --- /dev/null +++ b/packages/judging-core/src/dispatch.sim.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { pickNext } from "./dispatch"; +import type { Candidate, DispatchStrategy } from "./dispatch"; +import { DEFAULT_TIMERS } from "./timers"; +import { scoreUncertainty } from "./uncertainty"; + +/** + * Twenty judges, two hundred projects, three hours at the table. The same + * noise is available to both strategies; only the choice of who is seen + * again changes. The ten truly strongest projects should end up in an order + * closer to the truth when extra looks go to the unsettled ones. + */ +describe("uncertainty dispatch", () => { + it("ranks the true top 10 better than even coverage at the same number of looks", () => { + const projects = 200; + const judges = 20; + const hours = 3; + const budget = judges * Math.floor((hours * 3600) / DEFAULT_TIMERS.targetSeconds); + const truth = Array.from({ length: projects }, (_, index) => index); + const noise = truth.map((_, project) => + Array.from({ length: 40 }, (_, look) => + gaussian(mulberry32(project * 1000 + look + 1)) * noiseScale(project), + ), + ); + + const coverage = simulate("coverage", { projects, judges, budget, truth, noise }); + const uncertainty = simulate("uncertainty", { projects, judges, budget, truth, noise }); + const top = truth.map((_, index) => index).slice(-10); + + expect(kendall(top, uncertainty, truth)).toBeGreaterThan(kendall(top, coverage, truth)); + }); +}); + +function noiseScale(project: number): number { + return project >= 185 ? 18 : 3; +} + +function simulate( + strategy: DispatchStrategy, + input: { + projects: number; + judges: number; + budget: number; + truth: number[]; + noise: number[][]; + }, +): number[] { + const scores = Array.from({ length: input.projects }, () => [] as number[]); + const seen = Array.from({ length: input.judges }, () => new Set()); + const last: (number | null)[] = Array.from({ length: input.judges }, () => null); + let used = 0; + let guard = 0; + while (used < input.budget && guard < input.budget) { + for (let judge = 0; judge < input.judges && used < input.budget; judge += 1) { + const candidates: Candidate[] = []; + for (let project = 0; project < input.projects; project += 1) { + if (seen[judge]?.has(project)) continue; + candidates.push({ + id: String(project), + tableNumber: project, + coverage: scores[project]?.length ?? 0, + claimedAt: null, + uncertainty: scoreUncertainty(scores[project] ?? []), + }); + } + const pick = pickNext( + candidates, + { lastTable: last[judge] ?? null }, + { strategy, minLooksPerProject: 2 }, + () => 0, + ); + if (!pick) continue; + const project = Number(pick.id); + const look = scores[project]?.length ?? 0; + scores[project]?.push((input.truth[project] ?? 0) + (input.noise[project]?.[look] ?? 0)); + seen[judge]?.add(project); + last[judge] = project; + used += 1; + } + guard += 1; + } + return scores.map((row, index) => + row.length === 0 + ? (input.truth[index] ?? 0) + : row.reduce((sum, value) => sum + value, 0) / row.length, + ); +} + +function kendall(ids: number[], estimate: number[], truth: number[]): number { + let concordant = 0; + let discordant = 0; + for (let left = 0; left < ids.length; left += 1) { + for (let right = left + 1; right < ids.length; right += 1) { + const a = ids[left] ?? 0; + const b = ids[right] ?? 0; + const truthSign = Math.sign((truth[a] ?? 0) - (truth[b] ?? 0)); + const estimateSign = Math.sign((estimate[a] ?? 0) - (estimate[b] ?? 0)); + if (truthSign === 0 || estimateSign === 0) continue; + if (truthSign === estimateSign) concordant += 1; + else discordant += 1; + } + } + return (concordant - discordant) / (concordant + discordant); +} + +function gaussian(rng: () => number): number { + const u = Math.max(rng(), 1e-9); + const v = rng(); + return Math.sqrt(-2 * Math.log(u)) * Math.cos(Math.PI * 2 * v); +} + +function mulberry32(seed: number): () => number { + let state = seed; + return () => { + state = (state + 0x6d2b79f5) | 0; + let value = Math.imul(state ^ (state >>> 15), 1 | state); + value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value; + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +} diff --git a/packages/judging-core/src/dispatch.test.ts b/packages/judging-core/src/dispatch.test.ts new file mode 100644 index 00000000..9c2f0ec5 --- /dev/null +++ b/packages/judging-core/src/dispatch.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { pickNext } from "./dispatch"; +import type { Candidate, DispatchConfig } from "./dispatch"; + +const t0 = new Date("2027-02-28T13:00:00Z"); +const at = (seconds: number) => new Date(t0.getTime() + seconds * 1000); +const first = () => 0; +const coverage: DispatchConfig = { + strategy: "coverage", + minLooksPerProject: 1, +}; + +const c = ( + id: string, + tableNumber: number, + looks = 0, + claimedAt: number | null = null, + extra: Partial = {}, +): Candidate => ({ + id, + tableNumber, + coverage: looks, + claimedAt, + ...extra, +}); + +describe("pickNext coverage", () => { + it("returns nothing when there is nothing left", () => { + expect(pickNext([], { lastTable: null }, coverage, first)).toBeNull(); + }); + + it("takes the least-covered project", () => { + expect( + pickNext( + [c("a", 1, 2), c("b", 2, 0), c("c", 3, 1)], + { lastTable: null }, + coverage, + first, + )?.id, + ).toBe("b"); + }); + + it("breaks a coverage tie by the table nearest the judge's last one", () => { + const pick = pickNext( + [c("far", 90, 1), c("near", 12, 1), c("mid", 40, 1)], + { lastTable: 10 }, + coverage, + first, + ); + expect(pick?.id).toBe("near"); + }); + + it("never trades coverage for distance", () => { + expect( + pickNext( + [c("next-door", 11, 2), c("across", 200, 1)], + { lastTable: 10 }, + coverage, + first, + )?.id, + ).toBe("across"); + }); + + it("passes over a table someone is at while a free one exists", () => { + expect( + pickNext( + [c("taken", 1, 0, at(0).getTime()), c("free", 2, 3)], + { lastTable: null }, + coverage, + first, + )?.id, + ).toBe("free"); + }); + + it("sends the judge to the longest-held table when every one is taken", () => { + const pick = pickNext( + [ + c("recent", 1, 0, at(100).getTime()), + c("oldest", 2, 0, at(0).getTime()), + ], + { lastTable: null }, + coverage, + first, + ); + expect(pick?.id).toBe("oldest"); + }); + + it("chooses among equals at random", () => { + const tier = [c("a", 5, 0), c("b", 15, 0)]; + expect(pickNext(tier, { lastTable: 10 }, coverage, () => 0)?.id).toBe("a"); + expect(pickNext(tier, { lastTable: 10 }, coverage, () => 1)?.id).toBe("b"); + }); + + it("prefers the same zone over a closer table number in another zone", () => { + const pick = pickNext( + [ + c("other-room", 2, 0, null, { zoneId: "atrium" }), + c("same-room", 30, 0, null, { zoneId: "hall" }), + ], + { lastTable: 4, zoneId: "hall" }, + coverage, + first, + ); + expect(pick?.id).toBe("same-room"); + }); +}); + +describe("pickNext uncertainty", () => { + const uncertainty: DispatchConfig = { + strategy: "uncertainty", + minLooksPerProject: 2, + }; + + it("keeps covering while any project is under the minimum", () => { + const pick = pickNext( + [ + c("thin", 1, 0, null, { uncertainty: 0 }), + c("shaky", 2, 5, null, { uncertainty: 9 }), + ], + { lastTable: null }, + uncertainty, + first, + ); + expect(pick?.id).toBe("thin"); + }); + + it("takes the least settled project once coverage is met", () => { + const pick = pickNext( + [ + c("settled", 1, 2, null, { uncertainty: 0.1 }), + c("open", 9, 2, null, { uncertainty: 4 }), + ], + { lastTable: 1 }, + uncertainty, + first, + ); + expect(pick?.id).toBe("open"); + }); +}); diff --git a/packages/judging-core/src/dispatch.ts b/packages/judging-core/src/dispatch.ts new file mode 100644 index 00000000..aa453f98 --- /dev/null +++ b/packages/judging-core/src/dispatch.ts @@ -0,0 +1,128 @@ +import { distance } from "./distance"; +import type { TablePoint } from "./distance"; + +export type Candidate = { + id: string; + tableNumber: number; + zoneId?: string | null; + x?: number | null; + y?: number | null; + /** Scores plus live claims from judges in the same group. */ + coverage: number; + /** Most recent live claim by another judge, if any (ms epoch). */ + claimedAt: number | null; + /** Used by the uncertainty strategy once every project has its minimum looks. */ + uncertainty?: number; +}; + +export type JudgeSpot = { + lastTable: number | null; + zoneId?: string | null; + x?: number | null; + y?: number | null; +}; + +export type DispatchStrategy = "coverage" | "uncertainty"; + +export type DispatchConfig = { + strategy: DispatchStrategy; + /** Uncertainty waits until every candidate has at least this many looks. */ + minLooksPerProject: number; +}; + +/** + * The next project for one judge, from the projects they are eligible for + * and have not had. + * + * Coverage: fewest looks, then the nearest table, then `random`. A table + * someone is already at is passed over while any other exists. When every + * remaining one is taken, the one claimed longest ago, because an idle + * judge is worse than two at a table. + * + * Uncertainty: the same coverage rule until `minLooksPerProject` is met + * everywhere in `candidates`, then the highest uncertainty, then nearest. + */ +export function pickNext( + candidates: readonly Candidate[], + judge: JudgeSpot, + config: DispatchConfig, + random: (n: number) => number, +): Candidate | null { + if (candidates.length === 0) return null; + + const free = candidates.filter((candidate) => candidate.claimedAt === null); + if (free.length === 0) return oldestClaim(candidates); + + const stillCovering = + config.strategy === "coverage" || + candidates.some( + (candidate) => candidate.coverage < config.minLooksPerProject, + ); + + const tier = stillCovering ? fewestLooks(free) : highestUncertainty(free); + return narrow(tier, judge, random); +} + +function fewestLooks(free: readonly Candidate[]): Candidate[] { + const fewest = Math.min(...free.map((candidate) => candidate.coverage)); + return free.filter((candidate) => candidate.coverage === fewest); +} + +function highestUncertainty(free: readonly Candidate[]): Candidate[] { + const highest = Math.max( + ...free.map((candidate) => candidate.uncertainty ?? 0), + ); + return free.filter( + (candidate) => (candidate.uncertainty ?? 0) === highest, + ); +} + +function oldestClaim(candidates: readonly Candidate[]): Candidate | null { + let best: Candidate | null = null; + for (const candidate of candidates) { + if (!best || (candidate.claimedAt ?? 0) < (best.claimedAt ?? 0)) { + best = candidate; + } + } + return best; +} + +function narrow( + tier: readonly Candidate[], + judge: JudgeSpot, + random: (n: number) => number, +): Candidate | null { + if (tier.length === 0) return null; + if (judge.lastTable === null) return choose(tier, random); + + const here: TablePoint = { + number: judge.lastTable, + zoneId: judge.zoneId ?? null, + x: judge.x ?? null, + y: judge.y ?? null, + }; + const nearest = Math.min( + ...tier.map((candidate) => distance(here, candidatePoint(candidate))), + ); + const closest = tier.filter( + (candidate) => distance(here, candidatePoint(candidate)) === nearest, + ); + return choose(closest, random); +} + +function choose( + tier: readonly Candidate[], + random: (n: number) => number, +): Candidate | null { + if (tier.length === 0) return null; + return tier[random(tier.length)] ?? null; +} + +function candidatePoint(candidate: Candidate): TablePoint { + return { + number: candidate.tableNumber, + zoneId: candidate.zoneId ?? null, + x: candidate.x ?? null, + y: candidate.y ?? null, + }; +} diff --git a/packages/judging-core/src/distance.test.ts b/packages/judging-core/src/distance.test.ts new file mode 100644 index 00000000..3bd7ee03 --- /dev/null +++ b/packages/judging-core/src/distance.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { distance, ZONE_HOP } from "./distance"; + +const table = ( + number: number, + zoneId: string | null = null, + x: number | null = null, + y: number | null = null, +) => ({ number, zoneId, x, y }); + +describe("distance", () => { + it("is the table-number gap inside one zone", () => { + expect(distance(table(4, "hall"), table(11, "hall"))).toBe(7); + }); + + it("is the same gap when neither table has a zone", () => { + expect(distance(table(4), table(11))).toBe(7); + }); + + it("adds a zone hop when the rooms differ", () => { + expect(distance(table(4, "hall"), table(6, "atrium"))).toBe(ZONE_HOP + 2); + }); + + it("uses the floor plan when both tables have coordinates", () => { + expect(distance(table(1, "hall", 0, 0), table(9, "atrium", 3, 4))).toBe(5); + }); + + it("falls back to numbers when only one table is placed", () => { + expect(distance(table(1, "hall", 0, 0), table(4, "hall"))).toBe(3); + }); +}); diff --git a/packages/judging-core/src/distance.ts b/packages/judging-core/src/distance.ts new file mode 100644 index 00000000..6601a79c --- /dev/null +++ b/packages/judging-core/src/distance.ts @@ -0,0 +1,30 @@ +/** + * How far one table is from another. + * + * Same zone: the difference in table numbers, which is the walk. + * Another zone: a hop larger than any table gap, then the number difference, + * so a same-zone walk always beats changing rooms. + * When both tables have coordinates, the floor plan wins and the distance + * is Euclidean. + */ + +export type TablePoint = { + number: number; + zoneId: string | null; + x: number | null; + y: number | null; +}; + +/** Larger than any table-number gap, so a zone change never sorts as "near". */ +export const ZONE_HOP = 1_000_000; + +export function distance(a: TablePoint, b: TablePoint): number { + if (a.x !== null && a.y !== null && b.x !== null && b.y !== null) { + return Math.hypot(a.x - b.x, a.y - b.y); + } + const diff = Math.abs(a.number - b.number); + if (a.zoneId !== null && b.zoneId !== null && a.zoneId !== b.zoneId) { + return ZONE_HOP + diff; + } + return diff; +} diff --git a/packages/judging-core/src/events.test.ts b/packages/judging-core/src/events.test.ts new file mode 100644 index 00000000..72a63800 --- /dev/null +++ b/packages/judging-core/src/events.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + boardChannel, + isOutboxTopic, + judgeChannel, + leaderboardChannel, + LOG_KINDS, + OUTBOX_TOPICS, +} from "./events"; +import type { LogKind } from "./events"; + +describe("events", () => { + it("only queues topics the log already knows", () => { + for (const topic of OUTBOX_TOPICS) { + expect(LOG_KINDS).toContain(topic); + expect(isOutboxTopic(topic)).toBe(true); + } + }); + + it("does not queue a log row that nobody subscribes to", () => { + expect(isOutboxTopic("project.upserted")).toBe(false); + }); + + it("names the channels the board, the leaderboard, and a judge listen on", () => { + expect(boardChannel("evt")).toBe("event:evt:board"); + expect(leaderboardChannel("evt")).toBe("event:evt:leaderboard"); + expect(judgeChannel("j1")).toBe("judge:j1"); + }); + + it("accepts every log kind as a log kind", () => { + const kind: LogKind = "vote.cast"; + expect(LOG_KINDS).toContain(kind); + }); +}); diff --git a/packages/judging-core/src/events.ts b/packages/judging-core/src/events.ts new file mode 100644 index 00000000..63c04d3e --- /dev/null +++ b/packages/judging-core/src/events.ts @@ -0,0 +1,66 @@ +/** + * One vocabulary for the append-only log, the outbox, and the websocket. + * The server and the web both import this so a topic cannot drift. + */ + +export const LOG_KINDS = [ + "phase.changed", + "project.upserted", + "project.withdrawn", + "project.table_assigned", + "project.qr_rotated", + "track.saved", + "prize.saved", + "zone.saved", + "rubric.saved", + "table.placed", + "webhook.saved", + "api_key.created", + "config.saved", + "judge.upserted", + "judge.approved", + "judge.suspended", + "visit.handed_out", + "visit.arrived", + "visit.voided", + "visit.skipped", + "vote.cast", + "comparison.cast", + "results.computed", + "results.published", + "results.unpublished", + "judge.recalled", +] as const; + +export type LogKind = (typeof LOG_KINDS)[number]; + +export const OUTBOX_TOPICS = [ + "visit.handed_out", + "visit.arrived", + "visit.voided", + "vote.cast", + "phase.changed", + "results.published", + "results.unpublished", + "judge.recalled", +] as const; + +export type OutboxTopic = (typeof OUTBOX_TOPICS)[number]; + +const OUTBOX: ReadonlySet = new Set(OUTBOX_TOPICS); + +export function isOutboxTopic(kind: LogKind): kind is OutboxTopic { + return OUTBOX.has(kind); +} + +export function boardChannel(eventId: string): string { + return `event:${eventId}:board`; +} + +export function leaderboardChannel(eventId: string): string { + return `event:${eventId}:leaderboard`; +} + +export function judgeChannel(judgeId: string): string { + return `judge:${judgeId}`; +} diff --git a/packages/judging-core/src/index.ts b/packages/judging-core/src/index.ts new file mode 100644 index 00000000..a65497e1 --- /dev/null +++ b/packages/judging-core/src/index.ts @@ -0,0 +1,56 @@ +export { bayesianShrink, round2 } from "./aggregate/bayes"; +export { bradleyTerry } from "./aggregate/bradleyTerry"; +export { blend } from "./aggregate/blend"; +export { rank } from "./aggregate/rank"; +export { zNormalize } from "./aggregate/zscore"; +export type { + ProjectInput, + RankComparison, + RankConfig, + RankedRow, + VoteInput, +} from "./aggregate/rank"; +export type { Comparison } from "./comparison"; +export { pickNext } from "./dispatch"; +export { assignTables } from "./tables"; +export type { Assignment, Seat, SeatedProject } from "./tables"; +export type { + Candidate, + DispatchConfig, + DispatchStrategy, + JudgeSpot, +} from "./dispatch"; +export { distance, ZONE_HOP } from "./distance"; +export type { TablePoint } from "./distance"; +export { + boardChannel, + isOutboxTopic, + judgeChannel, + leaderboardChannel, + LOG_KINDS, + OUTBOX_TOPICS, +} from "./events"; +export type { LogKind, OutboxTopic } from "./events"; +export { projectMatchesTrack } from "./match"; +export { + assertPhaseAllows, + assertTransition, + canTransition, + phaseAllows, + PHASES, +} from "./phase"; +export type { Phase, PhaseAction } from "./phase"; +export { validateScores, weightedTotal } from "./rubric"; +export type { Criterion, ScoreCheck, ScoreInput } from "./rubric"; +export { + DEFAULT_TIMERS, + isLive, + isOverTarget, + isPastCutoff, +} from "./timers"; +export type { TimerConfig, VisitClock } from "./timers"; +export { + contestBonus, + projectUncertainty, + scoreUncertainty, +} from "./uncertainty"; diff --git a/packages/judging-core/src/match.test.ts b/packages/judging-core/src/match.test.ts new file mode 100644 index 00000000..9127ca98 --- /dev/null +++ b/packages/judging-core/src/match.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { projectMatchesTrack } from "./match"; + +describe("projectMatchesTrack", () => { + const project = { + tracks: ["Finance"], + challenges: ["Sponsor"], + isCreateX: true, + }; + + it("matches every project when the judge has no track", () => { + expect(projectMatchesTrack(project, null)).toBe(true); + }); + + it("matches by track, challenge, and the createx flag", () => { + expect(projectMatchesTrack(project, "Finance")).toBe(true); + expect(projectMatchesTrack(project, "Sponsor")).toBe(true); + expect(projectMatchesTrack(project, "createX")).toBe(true); + expect(projectMatchesTrack(project, "Health")).toBe(false); + }); +}); diff --git a/packages/judging-core/src/match.ts b/packages/judging-core/src/match.ts new file mode 100644 index 00000000..e2abcea4 --- /dev/null +++ b/packages/judging-core/src/match.ts @@ -0,0 +1,20 @@ +/** + * Label match for an import that still carries track strings on the project. + * The product path is a project_track row. This remains so those strings + * route the same way they did before tracks were rows. + */ +export function projectMatchesTrack( + project: { + tracks: string[] | null; + challenges: string[] | null; + isCreateX?: boolean | null; + }, + track: string | null, +): boolean { + if (!track) return true; + const inTracks = project.tracks?.includes(track) ?? false; + const inChallenges = project.challenges?.includes(track) ?? false; + const matchCreateX = + track.toLowerCase() === "createx" && !!project.isCreateX; + return inTracks || inChallenges || matchCreateX; +} diff --git a/packages/judging-core/src/package.test.ts b/packages/judging-core/src/package.test.ts new file mode 100644 index 00000000..85a15172 --- /dev/null +++ b/packages/judging-core/src/package.test.ts @@ -0,0 +1,14 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +describe("@query/judging-core package", () => { + it("has no runtime dependencies", () => { + const packageJson = join(dirname(fileURLToPath(import.meta.url)), "../package.json"); + const pkg = JSON.parse(readFileSync(packageJson, "utf8")) as { + dependencies?: Record; + }; + expect(pkg.dependencies ?? {}).toEqual({}); + }); +}); diff --git a/packages/judging-core/src/phase.test.ts b/packages/judging-core/src/phase.test.ts new file mode 100644 index 00000000..a4f885ba --- /dev/null +++ b/packages/judging-core/src/phase.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + assertPhaseAllows, + assertTransition, + canTransition, + phaseAllows, +} from "./phase"; + +describe("phase", () => { + it("walks an event from setup through archive", () => { + const path = [ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", + ] as const; + for (let index = 1; index < path.length; index += 1) { + const from = path[index - 1]; + const to = path[index]; + if (!from || !to) throw new Error("path"); + expect(canTransition(from, to)).toBe(true); + } + }); + + it("allows the steps an organizer actually takes backwards", () => { + expect(canTransition("submissions_open", "setup")).toBe(true); + expect(canTransition("submissions_closed", "submissions_open")).toBe(true); + expect(canTransition("judging_closed", "judging_live")).toBe(true); + expect(canTransition("published", "judging_closed")).toBe(true); + }); + + it("refuses a jump and a move out of the archive", () => { + expect(canTransition("setup", "judging_live")).toBe(false); + expect(canTransition("archived", "setup")).toBe(false); + expect(() => assertTransition("setup", "published")).toThrow(/setup/); + }); + + it("permits a vote only while judging is live", () => { + expect(phaseAllows("judging_live", "vote")).toBe(true); + expect(phaseAllows("judging_closed", "vote")).toBe(false); + expect(phaseAllows("submissions_open", "vote")).toBe(false); + expect(() => assertPhaseAllows("setup", "vote")).toThrow(/vote/); + }); + + it("permits dispatch only while judging is live, and publish only after it closes", () => { + expect(phaseAllows("judging_live", "dispatch")).toBe(true); + expect(phaseAllows("judging_closed", "dispatch")).toBe(false); + expect(phaseAllows("judging_closed", "publish")).toBe(true); + expect(phaseAllows("judging_live", "publish")).toBe(false); + expect(phaseAllows("judging_live", "compute")).toBe(true); + expect(phaseAllows("submissions_open", "submit")).toBe(true); + expect(phaseAllows("judging_live", "submit")).toBe(false); + expect(phaseAllows("setup", "apply")).toBe(true); + expect(phaseAllows("judging_live", "apply")).toBe(false); + }); +}); diff --git a/packages/judging-core/src/phase.ts b/packages/judging-core/src/phase.ts new file mode 100644 index 00000000..7a947a65 --- /dev/null +++ b/packages/judging-core/src/phase.ts @@ -0,0 +1,63 @@ +/** + * What an event is allowed to do, and which phase it may move to next. + * The server checks this on every mutation. The core does not persist it. + */ + +export const PHASES = [ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", +] as const; + +export type Phase = (typeof PHASES)[number]; + +export type PhaseAction = + | "submit" + | "apply" + | "dispatch" + | "vote" + | "compute" + | "publish"; + +const TRANSITIONS: Record = { + setup: ["submissions_open"], + submissions_open: ["submissions_closed", "setup"], + submissions_closed: ["judging_live", "submissions_open"], + judging_live: ["judging_closed"], + judging_closed: ["published", "judging_live"], + published: ["archived", "judging_closed"], + archived: [], +}; + +const PERMISSIONS: Record = { + submit: ["submissions_open"], + apply: ["setup", "submissions_open", "submissions_closed"], + dispatch: ["judging_live"], + vote: ["judging_live"], + compute: ["judging_live", "judging_closed"], + publish: ["judging_closed"], +}; + +export function canTransition(from: Phase, to: Phase): boolean { + return TRANSITIONS[from].includes(to); +} + +export function assertTransition(from: Phase, to: Phase): void { + if (!canTransition(from, to)) { + throw new Error(`Cannot move an event from ${from} to ${to}`); + } +} + +export function phaseAllows(phase: Phase, action: PhaseAction): boolean { + return PERMISSIONS[action].includes(phase); +} + +export function assertPhaseAllows(phase: Phase, action: PhaseAction): void { + if (!phaseAllows(phase, action)) { + throw new Error(`${action} is not allowed while the event is ${phase}`); + } +} diff --git a/packages/judging-core/src/rubric.test.ts b/packages/judging-core/src/rubric.test.ts new file mode 100644 index 00000000..589db6f1 --- /dev/null +++ b/packages/judging-core/src/rubric.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { validateScores, weightedTotal } from "./rubric"; +import type { Criterion } from "./rubric"; + +const criteria: Criterion[] = [ + { id: "creativity", min: 0, max: 10, weight: 1 }, + { id: "impact", min: 0, max: 10, weight: 3 }, +]; + +describe("validateScores", () => { + it("accepts one in-range value per criterion", () => { + expect( + validateScores(criteria, [ + { criterionId: "creativity", value: 0 }, + { criterionId: "impact", value: 10 }, + ]), + ).toEqual({ ok: true }); + }); + + it("rejects a missing, duplicate, unknown, or out-of-range score", () => { + expect(validateScores(criteria, []).ok).toBe(false); + expect( + validateScores(criteria, [ + { criterionId: "creativity", value: 1 }, + { criterionId: "creativity", value: 2 }, + { criterionId: "impact", value: 3 }, + ]).ok, + ).toBe(false); + expect( + validateScores(criteria, [ + { criterionId: "creativity", value: 1 }, + { criterionId: "nope", value: 1 }, + ]).ok, + ).toBe(false); + expect( + validateScores(criteria, [ + { criterionId: "creativity", value: 11 }, + { criterionId: "impact", value: 1 }, + ]).ok, + ).toBe(false); + }); +}); + +describe("weightedTotal", () => { + it("is the weighted mean", () => { + expect( + weightedTotal(criteria, [ + { criterionId: "creativity", value: 4 }, + { criterionId: "impact", value: 8 }, + ]), + ).toBe(7); + }); + + it("is zero when every weight is zero", () => { + const flat = criteria.map((criterion) => ({ ...criterion, weight: 0 })); + expect( + weightedTotal(flat, [ + { criterionId: "creativity", value: 4 }, + { criterionId: "impact", value: 8 }, + ]), + ).toBe(0); + }); + + it("throws when the scores are not valid", () => { + expect(() => weightedTotal(criteria, [])).toThrow(/missing/); + }); +}); diff --git a/packages/judging-core/src/rubric.ts b/packages/judging-core/src/rubric.ts new file mode 100644 index 00000000..14292b8d --- /dev/null +++ b/packages/judging-core/src/rubric.ts @@ -0,0 +1,85 @@ +/** + * A score is one number per criterion, inside that criterion's range. + * The total is the weighted mean, so it stays on the same scale as the + * criteria. The rows are the truth; the total is derived. + */ + +export type Criterion = { + id: string; + min: number; + max: number; + weight: number; +}; + +export type ScoreInput = { + criterionId: string; + value: number; +}; + +export type ScoreCheck = { ok: true } | { ok: false; reason: string }; + +export function validateScores( + criteria: readonly Criterion[], + input: readonly ScoreInput[], +): ScoreCheck { + for (const criterion of criteria) { + if (!Number.isFinite(criterion.weight) || criterion.weight < 0) { + return { + ok: false, + reason: `weight for ${criterion.id} must be zero or more`, + }; + } + if ( + !Number.isFinite(criterion.min) || + !Number.isFinite(criterion.max) || + criterion.min > criterion.max + ) { + return { ok: false, reason: `range for ${criterion.id} is invalid` }; + } + } + + const seen = new Set(); + for (const score of input) { + if (seen.has(score.criterionId)) { + return { ok: false, reason: `duplicate score for ${score.criterionId}` }; + } + seen.add(score.criterionId); + const criterion = criteria.find((item) => item.id === score.criterionId); + if (!criterion) { + return { ok: false, reason: `unknown criterion ${score.criterionId}` }; + } + if ( + !Number.isFinite(score.value) || + score.value < criterion.min || + score.value > criterion.max + ) { + return { + ok: false, + reason: `${score.criterionId} must be between ${criterion.min} and ${criterion.max}`, + }; + } + } + + for (const criterion of criteria) { + if (!seen.has(criterion.id)) { + return { ok: false, reason: `missing score for ${criterion.id}` }; + } + } + + return { ok: true }; +} + +export function weightedTotal( + criteria: readonly Criterion[], + input: readonly ScoreInput[], +): number { + const check = validateScores(criteria, input); + if (!check.ok) throw new Error(check.reason); + const weight = criteria.reduce((sum, criterion) => sum + criterion.weight, 0); + if (weight === 0) return 0; + const byId = new Map(input.map((score) => [score.criterionId, score.value])); + const sum = criteria.reduce((total, criterion) => { + return total + (byId.get(criterion.id) ?? 0) * criterion.weight; + }, 0); + return sum / weight; +} diff --git a/packages/judging-core/src/tables.test.ts b/packages/judging-core/src/tables.test.ts new file mode 100644 index 00000000..b02827c8 --- /dev/null +++ b/packages/judging-core/src/tables.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { assignTables } from "./tables"; + +describe("assignTables", () => { + it("alternates tracks across table numbers", () => { + const seated = assignTables( + [ + { id: "a1", trackId: "sponsor" }, + { id: "a2", trackId: "sponsor" }, + { id: "b1", trackId: "general" }, + { id: "b2", trackId: "general" }, + ], + [ + { number: 1, zoneId: "hall" }, + { number: 2, zoneId: "hall" }, + { number: 3, zoneId: "hall" }, + { number: 4, zoneId: "hall" }, + ], + ); + expect(seated.map((row) => row.projectId)).toEqual(["a1", "b1", "a2", "b2"]); + expect(seated.map((row) => row.tableNumber)).toEqual([1, 2, 3, 4]); + }); + + it("leaves extra projects unseated when tables run out", () => { + const seated = assignTables( + [ + { id: "a", trackId: null }, + { id: "b", trackId: null }, + ], + [{ number: 9, zoneId: null }], + ); + expect(seated).toEqual([{ projectId: "a", tableNumber: 9, zoneId: null }]); + }); +}); diff --git a/packages/judging-core/src/tables.ts b/packages/judging-core/src/tables.ts new file mode 100644 index 00000000..af287f1d --- /dev/null +++ b/packages/judging-core/src/tables.ts @@ -0,0 +1,52 @@ +export type Seat = { + number: number; + zoneId: string | null; +}; + +export type SeatedProject = { + id: string; + trackId: string | null; +}; + +export type Assignment = { + projectId: string; + tableNumber: number; + zoneId: string | null; +}; + +/** + * Seats projects so tracks take turns. Two sponsor projects do not land on + * neighbouring tables while another track is still waiting for a seat. + */ +export function assignTables( + projects: readonly SeatedProject[], + tables: readonly Seat[], +): Assignment[] { + const queues = new Map(); + for (const project of projects) { + const key = project.trackId ?? ""; + const queue = queues.get(key) ?? []; + queue.push(project); + queues.set(key, queue); + } + const lanes = [...queues.values()]; + const ordered: SeatedProject[] = []; + while (ordered.length < projects.length) { + for (const lane of lanes) { + const next = lane.shift(); + if (next) ordered.push(next); + } + } + const seats = [...tables].sort((a, b) => a.number - b.number); + return ordered.slice(0, seats.length).flatMap((project, index) => { + const seat = seats[index]; + if (!seat) return []; + return [ + { + projectId: project.id, + tableNumber: seat.number, + zoneId: seat.zoneId, + }, + ]; + }); +} diff --git a/packages/judging-core/src/timers.test.ts b/packages/judging-core/src/timers.test.ts new file mode 100644 index 00000000..24c3a19d --- /dev/null +++ b/packages/judging-core/src/timers.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_TIMERS, isLive, isOverTarget, isPastCutoff } from "./timers"; + +const t0 = new Date("2027-02-28T13:00:00Z"); +const at = (seconds: number) => new Date(t0.getTime() + seconds * 1000); +const timers = DEFAULT_TIMERS; + +describe("isLive", () => { + const walking = { + handedOutAt: t0, + arrivedAt: null, + completedAt: null, + }; + const atTable = { + handedOutAt: t0, + arrivedAt: at(60), + completedAt: null, + }; + + it("holds a table for the walk limit after hand-out", () => { + expect(isLive(walking, at(timers.walkLimitSeconds), timers)).toBe(true); + expect(isLive(walking, at(timers.walkLimitSeconds + 1), timers)).toBe( + false, + ); + }); + + it("holds it through the hard limit and grace after arrival", () => { + const end = + 60 + timers.hardLimitSeconds + timers.submitGraceSeconds; + expect(isLive(atTable, at(end), timers)).toBe(true); + expect(isLive(atTable, at(end + 1), timers)).toBe(false); + }); + + it("measures from arrival, not hand-out, once the judge has tapped in", () => { + expect(isLive(atTable, at(timers.walkLimitSeconds + 30), timers)).toBe( + true, + ); + }); + + it("is never live once completed, or when never handed out", () => { + expect( + isLive({ ...atTable, completedAt: at(61) }, at(61), timers), + ).toBe(false); + expect( + isLive( + { handedOutAt: null, arrivedAt: null, completedAt: null }, + t0, + timers, + ), + ).toBe(false); + }); + + it("honours a shorter limit from config", () => { + const short = { ...timers, walkLimitSeconds: 30 }; + expect(isLive(walking, at(30), short)).toBe(true); + expect(isLive(walking, at(31), short)).toBe(false); + }); +}); + +describe("isPastCutoff", () => { + it("allows a score at the hard limit and within the grace after it", () => { + expect(isPastCutoff(t0, at(timers.hardLimitSeconds), timers)).toBe(false); + expect( + isPastCutoff( + t0, + at(timers.hardLimitSeconds + timers.submitGraceSeconds), + timers, + ), + ).toBe(false); + }); + + it("refuses one after the grace", () => { + expect( + isPastCutoff( + t0, + at(timers.hardLimitSeconds + timers.submitGraceSeconds + 1), + timers, + ), + ).toBe(true); + }); +}); + +describe("isOverTarget", () => { + it("turns over at the target and not before", () => { + expect(isOverTarget(t0, at(timers.targetSeconds), timers)).toBe(false); + expect(isOverTarget(t0, at(timers.targetSeconds + 1), timers)).toBe(true); + }); +}); diff --git a/packages/judging-core/src/timers.ts b/packages/judging-core/src/timers.ts new file mode 100644 index 00000000..d14bdd5b --- /dev/null +++ b/packages/judging-core/src/timers.ts @@ -0,0 +1,74 @@ +/** + * Visit clocks. Every function takes `now` so a replay of an event does not + * depend on when the code happens to run. + * + * The defaults match the timings the first event used: three minutes at a + * table, four minutes hard, four minutes to walk, fifteen seconds of grace + * for a submit that left the phone on the buzzer. + */ + +export type TimerConfig = { + targetSeconds: number; + hardLimitSeconds: number; + walkLimitSeconds: number; + submitGraceSeconds: number; +}; + +export const DEFAULT_TIMERS: TimerConfig = { + targetSeconds: 180, + hardLimitSeconds: 240, + walkLimitSeconds: 240, + submitGraceSeconds: 15, +}; + +export type VisitClock = { + handedOutAt: Date | null; + arrivedAt: Date | null; + completedAt: Date | null; +}; + +/** + * Whether a visit still holds its table. Walking: until the walk limit after + * hand-out. At the table: until the hard limit (plus grace) after arrival. + */ +export function isLive( + slot: VisitClock, + now: Date, + config: TimerConfig, +): boolean { + if (slot.completedAt) return false; + const t = now.getTime(); + if (slot.arrivedAt) { + return ( + t <= + slot.arrivedAt.getTime() + + (config.hardLimitSeconds + config.submitGraceSeconds) * 1000 + ); + } + if (slot.handedOutAt) { + return t <= slot.handedOutAt.getTime() + config.walkLimitSeconds * 1000; + } + return false; +} + +/** Past the hard cutoff, with grace: a score now arrives too late to count. */ +export function isPastCutoff( + arrivedAt: Date, + now: Date, + config: TimerConfig, +): boolean { + return ( + now.getTime() > + arrivedAt.getTime() + + (config.hardLimitSeconds + config.submitGraceSeconds) * 1000 + ); +} + +/** The judge page turns amber here. Measured from arrival, not hand-out. */ +export function isOverTarget( + arrivedAt: Date, + now: Date, + config: TimerConfig, +): boolean { + return now.getTime() > arrivedAt.getTime() + config.targetSeconds * 1000; +} diff --git a/packages/judging-core/src/uncertainty.test.ts b/packages/judging-core/src/uncertainty.test.ts new file mode 100644 index 00000000..d97fc34e --- /dev/null +++ b/packages/judging-core/src/uncertainty.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + contestBonus, + projectUncertainty, + scoreUncertainty, +} from "./uncertainty"; + +describe("scoreUncertainty", () => { + it("is infinite when nobody has scored the project", () => { + expect(scoreUncertainty([])).toBe(Number.POSITIVE_INFINITY); + }); + + it("is zero when every score agrees", () => { + expect(scoreUncertainty([5, 5, 5])).toBe(0); + }); + + it("shrinks as the same spread is seen more times", () => { + const small = scoreUncertainty([0, 10]); + const large = scoreUncertainty([0, 10, 0, 10]); + expect(large).toBeLessThan(small); + }); +}); + +describe("contestBonus", () => { + it("is zero when one project always wins", () => { + expect( + contestBonus( + [ + { a: "a", b: "b", outcome: "a" }, + { a: "a", b: "b", outcome: "a" }, + ], + "a", + ), + ).toBe(0); + }); + + it("is one when the two projects split", () => { + expect( + contestBonus( + [ + { a: "a", b: "b", outcome: "a" }, + { a: "a", b: "b", outcome: "b" }, + ], + "a", + ), + ).toBe(1); + }); + + it("counts a tie as contested", () => { + expect(contestBonus([{ a: "a", b: "b", outcome: "tie" }], "a")).toBe(1); + }); +}); + +describe("projectUncertainty", () => { + it("adds the contest bonus on top of the score spread", () => { + const scores = [0, 10]; + const bonus = contestBonus([{ a: "a", b: "b", outcome: "tie" }], "a"); + expect( + projectUncertainty(scores, [{ a: "a", b: "b", outcome: "tie" }], "a"), + ).toBeCloseTo(scoreUncertainty(scores) + bonus, 10); + }); + + it("stays infinite when there are no scores yet", () => { + expect(projectUncertainty([], [], "a")).toBe(Number.POSITIVE_INFINITY); + }); +}); diff --git a/packages/judging-core/src/uncertainty.ts b/packages/judging-core/src/uncertainty.ts new file mode 100644 index 00000000..07a5b4b2 --- /dev/null +++ b/packages/judging-core/src/uncertainty.ts @@ -0,0 +1,66 @@ +import type { Comparison } from "./comparison"; + +/** + * How unsettled a project's rank is. + * Score spread shrinks as more judges agree and as the sample grows. + * A pairwise neighbour that is split (or tied) adds a bonus, because those + * two projects are the ones another look can still reorder. + */ + +export function scoreUncertainty(scores: readonly number[]): number { + const n = scores.length; + if (n === 0) return Number.POSITIVE_INFINITY; + const mean = scores.reduce((sum, value) => sum + value, 0) / n; + const variance = + scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / n; + return variance / Math.sqrt(n); +} + +export function contestBonus( + comparisons: readonly Comparison[], + projectId: string, +): number { + const against = new Map< + string, + { wins: number; losses: number; ties: number } + >(); + + for (const comparison of comparisons) { + const opponent = + comparison.a === projectId + ? comparison.b + : comparison.b === projectId + ? comparison.a + : null; + if (!opponent) continue; + const row = against.get(opponent) ?? { wins: 0, losses: 0, ties: 0 }; + if (comparison.outcome === "tie") row.ties += 1; + else if ( + (comparison.outcome === "a" && comparison.a === projectId) || + (comparison.outcome === "b" && comparison.b === projectId) + ) { + row.wins += 1; + } else { + row.losses += 1; + } + against.set(opponent, row); + } + + let bonus = 0; + for (const row of against.values()) { + const n = row.wins + row.losses + row.ties; + if (n === 0) continue; + bonus += (n - Math.abs(row.wins - row.losses)) / n; + } + return bonus; +} + +export function projectUncertainty( + scores: readonly number[], + comparisons: readonly Comparison[], + projectId: string, +): number { + const spread = scoreUncertainty(scores); + if (!Number.isFinite(spread)) return spread; + return spread + contestBonus(comparisons, projectId); +} diff --git a/packages/judging-core/tsconfig.build.json b/packages/judging-core/tsconfig.build.json new file mode 100644 index 00000000..2da00172 --- /dev/null +++ b/packages/judging-core/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "node_modules", "dist"] +} diff --git a/packages/judging-core/tsconfig.json b/packages/judging-core/tsconfig.json new file mode 100644 index 00000000..f54bde2c --- /dev/null +++ b/packages/judging-core/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tooling/typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/judging-core/vitest.config.ts b/packages/judging-core/vitest.config.ts new file mode 100644 index 00000000..ae847ff6 --- /dev/null +++ b/packages/judging-core/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/judging-db/eslint.config.mjs b/packages/judging-db/eslint.config.mjs new file mode 100644 index 00000000..780d26b6 --- /dev/null +++ b/packages/judging-db/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [...config]; diff --git a/packages/judging-db/migrations/0001_init.sql b/packages/judging-db/migrations/0001_init.sql new file mode 100644 index 00000000..2037f72f --- /dev/null +++ b/packages/judging-db/migrations/0001_init.sql @@ -0,0 +1,309 @@ +create extension if not exists pgcrypto; + +create type event_phase as enum ( + 'setup', + 'submissions_open', + 'submissions_closed', + 'judging_live', + 'judging_closed', + 'published', + 'archived' +); + +create type track_kind as enum ('main', 'sponsor', 'special'); + +create type dispatch_strategy as enum ('coverage', 'uncertainty'); + +create type judge_status as enum ('invited', 'applied', 'approved', 'suspended'); + +create type comparison_outcome as enum ('a', 'b', 'tie'); + +create type membership_role as enum ('owner', 'admin', 'organizer', 'volunteer'); + +create table organization ( + id uuid primary key default gen_random_uuid(), + slug text not null unique, + name text not null, + branding jsonb not null default '{}'::jsonb +); + +create table panel_user ( + id uuid primary key default gen_random_uuid(), + email text not null unique, + name text not null +); + +create table panel_event ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references organization (id) on delete cascade, + slug text not null, + name text not null, + starts_at timestamptz, + ends_at timestamptz, + phase event_phase not null default 'setup', + unique (org_id, slug) +); + +create table event_config ( + event_id uuid primary key references panel_event (id) on delete cascade, + target_seconds integer not null, + hard_limit_seconds integer not null, + walk_limit_seconds integer not null, + submit_grace_seconds integer not null, + min_looks_per_project integer not null, + dispatch_strategy dispatch_strategy not null, + pairwise_enabled boolean not null default false, + pairwise_weight numeric not null default 0, + bayesian_c numeric not null default 2, + calibration_looks integer not null default 0, + calibration_weight numeric not null default 1, + feedback_cards_enabled boolean not null default false, + leaderboard_public boolean not null default false, + board_poll_seconds integer not null default 5 +); + +create table rubric ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + name text not null, + is_default boolean not null default false +); + +create table criterion ( + id uuid primary key default gen_random_uuid(), + rubric_id uuid not null references rubric (id) on delete cascade, + position integer not null, + key text not null, + label text not null, + description text, + min numeric not null, + max numeric not null, + weight numeric not null, + anchors jsonb not null default '[]'::jsonb, + unique (rubric_id, key) +); + +create table track ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + slug text not null, + name text not null, + kind track_kind not null, + judge_group text not null, + rubric_id uuid references rubric (id), + position integer not null default 0, + is_active boolean not null default true, + unique (event_id, slug) +); + +create table prize ( + id uuid primary key default gen_random_uuid(), + track_id uuid not null references track (id) on delete cascade, + place integer not null, + title text not null, + amount numeric, + description text, + unique (track_id, place) +); + +create table zone ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + name text not null, + position integer not null default 0 +); + +-- `table` is reserved in SQL, so the floor plan lives in floor_table. +create table floor_table ( + event_id uuid not null references panel_event (id) on delete cascade, + number integer not null, + zone_id uuid references zone (id), + x numeric, + y numeric, + primary key (event_id, number) +); + +create table project ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + external_id text, + name text not null, + description text, + team_name text, + members jsonb not null default '[]'::jsonb, + links jsonb not null default '{}'::jsonb, + table_number integer, + zone_id uuid references zone (id), + qr_token uuid not null unique default gen_random_uuid(), + arrived_first_at timestamptz, + withdrawn_at timestamptz +); + +create unique index project_event_external_idx + on project (event_id, external_id) + where external_id is not null; + +create table project_track ( + project_id uuid not null references project (id) on delete cascade, + track_id uuid not null references track (id) on delete cascade, + primary key (project_id, track_id) +); + +create table panel_judge ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + external_id text, + name text not null, + email text not null, + org text, + title text, + status judge_status not null default 'invited', + track_id uuid references track (id), + zone_id uuid references zone (id), + is_lead boolean not null default false, + unique (event_id, email) +); + +create unique index judge_event_external_idx + on panel_judge (event_id, external_id) + where external_id is not null; + +create table visit ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + judge_id uuid not null references panel_judge (id) on delete cascade, + project_id uuid not null references project (id) on delete cascade, + judge_group text not null, + handed_out_at timestamptz not null, + arrived_at timestamptz, + completed_at timestamptz, + voided_at timestamptz, + void_reason text +); + +-- A completed look still counts: the same judge is not handed that project again. +create unique index visit_judge_project_open_idx + on visit (judge_id, project_id) + where voided_at is null; + +-- One table in hand at a time. The dispatch lock is the first guard; this is the second. +create unique index visit_one_open_idx + on visit (judge_id) + where voided_at is null and completed_at is null; + +create table vote ( + id uuid primary key default gen_random_uuid(), + visit_id uuid not null unique references visit (id) on delete cascade, + judge_id uuid not null references panel_judge (id) on delete cascade, + project_id uuid not null references project (id) on delete cascade, + event_id uuid not null references panel_event (id) on delete cascade, + total numeric not null, + comment text, + duration_seconds integer, + is_calibration boolean not null default false +); + +create table vote_score ( + vote_id uuid not null references vote (id) on delete cascade, + criterion_id uuid not null references criterion (id), + value numeric not null, + primary key (vote_id, criterion_id) +); + +create table comparison ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + judge_id uuid not null references panel_judge (id) on delete cascade, + judge_group text not null, + a_project_id uuid not null references project (id), + b_project_id uuid not null references project (id), + outcome comparison_outcome not null, + created_at timestamptz not null default now() +); + +create table result_run ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + computed_at timestamptz not null default now(), + computed_by uuid references panel_user (id), + config_snapshot jsonb not null, + published_at timestamptz, + notes text +); + +create table result ( + run_id uuid not null references result_run (id) on delete cascade, + project_id uuid not null references project (id) on delete cascade, + track_id uuid not null references track (id) on delete cascade, + placement integer, + score numeric not null, + rubric_component numeric not null, + pairwise_component numeric not null, + vote_count integer not null, + comparison_count integer not null, + flags text[] not null default '{}', + primary key (run_id, project_id, track_id) +); + +create table event_log ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + kind text not null, + actor jsonb not null default '{}'::jsonb, + subject jsonb not null default '{}'::jsonb, + payload jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create table outbox ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + topic text not null, + payload jsonb not null, + created_at timestamptz not null default now(), + delivered_at timestamptz, + attempts integer not null default 0 +); + +create table webhook ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references organization (id) on delete cascade, + url text not null, + secret text not null, + topics text[] not null, + is_active boolean not null default true +); + +create table api_key ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references organization (id) on delete cascade, + hashed_key text not null, + scopes text[] not null, + created_at timestamptz not null default now(), + revoked_at timestamptz +); + +create table membership ( + user_id uuid not null references panel_user (id) on delete cascade, + org_id uuid not null references organization (id) on delete cascade, + role membership_role not null, + primary key (user_id, org_id) +); + +create table judge_identity ( + judge_id uuid primary key references panel_judge (id) on delete cascade, + user_id uuid not null references panel_user (id) on delete cascade +); + +create table login_code ( + id uuid primary key default gen_random_uuid(), + email text not null, + code_hash text not null, + expires_at timestamptz not null, + consumed_at timestamptz +); + +create index event_log_event_idx on event_log (event_id, created_at); +create index outbox_pending_idx on outbox (created_at) where delivered_at is null; +create index visit_event_idx on visit (event_id); +create index project_event_idx on project (event_id); diff --git a/packages/judging-db/migrations/0002_feedback_token.sql b/packages/judging-db/migrations/0002_feedback_token.sql new file mode 100644 index 00000000..1fc17747 --- /dev/null +++ b/packages/judging-db/migrations/0002_feedback_token.sql @@ -0,0 +1 @@ +alter table project add column feedback_token uuid unique; diff --git a/packages/judging-db/migrations/0003_shared_database.sql b/packages/judging-db/migrations/0003_shared_database.sql new file mode 100644 index 00000000..6a29b730 --- /dev/null +++ b/packages/judging-db/migrations/0003_shared_database.sql @@ -0,0 +1,37 @@ +-- Panel shares the club Postgres database. user, event, and judge already +-- belong to that app, so panel's copies use a prefix. Rename only when the +-- table is the panel one (uuid user id, event.org_id, judge.event_id). + +do $$ +begin + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'user' + and column_name = 'id' + and data_type = 'uuid' + ) and to_regclass('public.panel_user') is null then + alter table "user" rename to panel_user; + end if; + + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'event' + and column_name = 'org_id' + ) and to_regclass('public.panel_event') is null then + alter table event rename to panel_event; + end if; + + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'judge' + and column_name = 'event_id' + ) and to_regclass('public.panel_judge') is null then + alter table judge rename to panel_judge; + end if; +end $$; diff --git a/packages/judging-db/package.json b/packages/judging-db/package.json new file mode 100644 index 00000000..87a7eee0 --- /dev/null +++ b/packages/judging-db/package.json @@ -0,0 +1,25 @@ +{ + "name": "@query/judging-db", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@query/judging-core": "workspace:*", + "drizzle-orm": "0.45.3", + "pg": "8.23.0" + }, + "devDependencies": { + "@query/eslint-config": "workspace:*", + "@query/tsconfig": "workspace:*", + "@types/node": "22.20.4", + "@types/pg": "^8.23.1", + "typescript": "7.0.2" + } +} diff --git a/packages/judging-db/src/client.ts b/packages/judging-db/src/client.ts new file mode 100644 index 00000000..abb74e26 --- /dev/null +++ b/packages/judging-db/src/client.ts @@ -0,0 +1,21 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import * as schema from "./schema"; + +export function createDb(url: string) { + const pool = new Pool({ connectionString: url, max: 10 }); + return createDbFromPool(pool); +} + +/** Judging tables on a pool the host already owns, so one app keeps one set of connections. */ +export function createDbFromPool(pool: Pool) { + pool.on("error", () => { + // The pool drops a dead client on its own. An unhandled "error" event + // would take the process down. + }); + const db = drizzle(pool, { schema }); + return { db, pool }; +} + +export type PanelDb = ReturnType["db"]; +export type PanelTx = Parameters[0]>[0]; diff --git a/packages/judging-db/src/enums.ts b/packages/judging-db/src/enums.ts new file mode 100644 index 00000000..e7bfb4a1 --- /dev/null +++ b/packages/judging-db/src/enums.ts @@ -0,0 +1,39 @@ +import { pgEnum } from "drizzle-orm/pg-core"; + +// Kept apart from the tables because packages/db's drizzle config loads this +// file too. Judging creates these types with its own SQL migrations; push +// must see them as declared, or it tries to drop them from the shared +// database. Its tables stay hidden from push (tablesFilter there). + +export const eventPhase = pgEnum("event_phase", [ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", +]); + +export const trackKind = pgEnum("track_kind", ["main", "sponsor", "special"]); + +export const dispatchStrategy = pgEnum("dispatch_strategy", [ + "coverage", + "uncertainty", +]); + +export const judgeStatus = pgEnum("judge_status", [ + "invited", + "applied", + "approved", + "suspended", +]); + +export const comparisonOutcome = pgEnum("comparison_outcome", ["a", "b", "tie"]); + +export const membershipRole = pgEnum("membership_role", [ + "owner", + "admin", + "organizer", + "volunteer", +]); diff --git a/packages/judging-db/src/env.ts b/packages/judging-db/src/env.ts new file mode 100644 index 00000000..8966aa59 --- /dev/null +++ b/packages/judging-db/src/env.ts @@ -0,0 +1,11 @@ +export function databaseUrl(): string | null { + return process.env.DATABASE_URL ?? process.env.PANEL_DATABASE_URL ?? null; +} + +export function requireDatabaseUrl(): string { + const url = databaseUrl(); + if (!url) { + throw new Error("Set DATABASE_URL to the Postgres database"); + } + return url; +} diff --git a/packages/judging-db/src/index.ts b/packages/judging-db/src/index.ts new file mode 100644 index 00000000..93619fb3 --- /dev/null +++ b/packages/judging-db/src/index.ts @@ -0,0 +1,6 @@ +export { createDb, createDbFromPool } from "./client"; +export type { PanelDb, PanelTx } from "./client"; +export { databaseUrl, requireDatabaseUrl } from "./env"; +export { appliedMigrations, migrate, migrationFiles } from "./migrate"; +export * from "./schema"; +export { seedDemo } from "./seed"; diff --git a/packages/judging-db/src/migrate.ts b/packages/judging-db/src/migrate.ts new file mode 100644 index 00000000..9a5e8c76 --- /dev/null +++ b/packages/judging-db/src/migrate.ts @@ -0,0 +1,69 @@ +import { readdir, readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Pool } from "pg"; + +const migrationsDir = join(dirname(fileURLToPath(import.meta.url)), "../migrations"); + +const MIGRATION_TABLE = ` + create table if not exists panel_migration ( + id text primary key, + applied_at timestamptz not null default now() + ) +`; + +/** Applies committed SQL files in order. Each file runs in one transaction. */ +export async function migrate(pool: Pool): Promise { + const client = await pool.connect(); + try { + await client.query(MIGRATION_TABLE); + const applied = await client.query<{ id: string }>( + "select id from panel_migration", + ); + const done = new Set(applied.rows.map((row) => row.id)); + const files = await migrationFiles(); + const fresh: string[] = []; + + for (const file of files) { + if (done.has(file)) continue; + const body = await readFile(join(migrationsDir, file), "utf8"); + try { + await client.query("begin"); + await client.query(body); + await client.query("insert into panel_migration (id) values ($1)", [ + file, + ]); + await client.query("commit"); + } catch (error) { + await client.query("rollback"); + throw error; + } + fresh.push(file); + } + return fresh; + } finally { + client.release(); + } +} + +export async function appliedMigrations(pool: Pool): Promise { + const client = await pool.connect(); + try { + const exists = await client.query<{ present: boolean }>( + `select to_regclass('panel_migration') is not null as present`, + ); + const row = exists.rows[0]; + if (!row?.present) return []; + const applied = await client.query<{ id: string }>( + "select id from panel_migration order by id", + ); + return applied.rows.map((item) => item.id); + } finally { + client.release(); + } +} + +export async function migrationFiles(): Promise { + const files = await readdir(migrationsDir); + return files.filter((name) => name.endsWith(".sql")).sort(); +} diff --git a/packages/judging-db/src/schema.ts b/packages/judging-db/src/schema.ts new file mode 100644 index 00000000..365d76a0 --- /dev/null +++ b/packages/judging-db/src/schema.ts @@ -0,0 +1,416 @@ +import { + boolean, + integer, + jsonb, + numeric, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { + comparisonOutcome, + dispatchStrategy, + eventPhase, + judgeStatus, + membershipRole, + trackKind, +} from "./enums"; + +export { + comparisonOutcome, + dispatchStrategy, + eventPhase, + judgeStatus, + membershipRole, + trackKind, +}; + +export const organization = pgTable("organization", { + id: uuid("id").defaultRandom().primaryKey(), + slug: text("slug").notNull().unique(), + name: text("name").notNull(), + branding: jsonb("branding").notNull().default({}), +}); + +export const user = pgTable("panel_user", { + id: uuid("id").defaultRandom().primaryKey(), + email: text("email").notNull().unique(), + name: text("name").notNull(), +}); + +export const event = pgTable( + "panel_event", + { + id: uuid("id").defaultRandom().primaryKey(), + orgId: uuid("org_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + slug: text("slug").notNull(), + name: text("name").notNull(), + startsAt: timestamp("starts_at", { withTimezone: true }), + endsAt: timestamp("ends_at", { withTimezone: true }), + phase: eventPhase("phase").notNull().default("setup"), + }, + (table) => [uniqueIndex("event_org_slug_idx").on(table.orgId, table.slug)], +); + +export const eventConfig = pgTable("event_config", { + eventId: uuid("event_id") + .primaryKey() + .references(() => event.id, { onDelete: "cascade" }), + targetSeconds: integer("target_seconds").notNull(), + hardLimitSeconds: integer("hard_limit_seconds").notNull(), + walkLimitSeconds: integer("walk_limit_seconds").notNull(), + submitGraceSeconds: integer("submit_grace_seconds").notNull(), + minLooksPerProject: integer("min_looks_per_project").notNull(), + dispatchStrategy: dispatchStrategy("dispatch_strategy").notNull(), + pairwiseEnabled: boolean("pairwise_enabled").notNull().default(false), + pairwiseWeight: numeric("pairwise_weight", { mode: "number" }).notNull().default(0), + bayesianC: numeric("bayesian_c", { mode: "number" }).notNull().default(2), + calibrationLooks: integer("calibration_looks").notNull().default(0), + calibrationWeight: numeric("calibration_weight", { mode: "number" }) + .notNull() + .default(1), + feedbackCardsEnabled: boolean("feedback_cards_enabled").notNull().default(false), + leaderboardPublic: boolean("leaderboard_public").notNull().default(false), + boardPollSeconds: integer("board_poll_seconds").notNull().default(5), +}); + +export const rubric = pgTable("rubric", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + name: text("name").notNull(), + isDefault: boolean("is_default").notNull().default(false), +}); + +export const criterion = pgTable( + "criterion", + { + id: uuid("id").defaultRandom().primaryKey(), + rubricId: uuid("rubric_id") + .notNull() + .references(() => rubric.id, { onDelete: "cascade" }), + position: integer("position").notNull(), + key: text("key").notNull(), + label: text("label").notNull(), + description: text("description"), + min: numeric("min", { mode: "number" }).notNull(), + max: numeric("max", { mode: "number" }).notNull(), + weight: numeric("weight", { mode: "number" }).notNull(), + anchors: jsonb("anchors").notNull().default([]), + }, + (table) => [uniqueIndex("criterion_rubric_key_idx").on(table.rubricId, table.key)], +); + +export const track = pgTable( + "track", + { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + slug: text("slug").notNull(), + name: text("name").notNull(), + kind: trackKind("kind").notNull(), + judgeGroup: text("judge_group").notNull(), + rubricId: uuid("rubric_id").references(() => rubric.id), + position: integer("position").notNull().default(0), + isActive: boolean("is_active").notNull().default(true), + }, + (table) => [uniqueIndex("track_event_slug_idx").on(table.eventId, table.slug)], +); + +export const prize = pgTable( + "prize", + { + id: uuid("id").defaultRandom().primaryKey(), + trackId: uuid("track_id") + .notNull() + .references(() => track.id, { onDelete: "cascade" }), + place: integer("place").notNull(), + title: text("title").notNull(), + amount: numeric("amount", { mode: "number" }), + description: text("description"), + }, + (table) => [uniqueIndex("prize_track_place_idx").on(table.trackId, table.place)], +); + +export const zone = pgTable("zone", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + name: text("name").notNull(), + position: integer("position").notNull().default(0), +}); + +/** Floor plan. The SQL name is floor_table because `table` is reserved. */ +export const floorTable = pgTable( + "floor_table", + { + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + number: integer("number").notNull(), + zoneId: uuid("zone_id").references(() => zone.id), + x: numeric("x", { mode: "number" }), + y: numeric("y", { mode: "number" }), + }, + (table) => [primaryKey({ columns: [table.eventId, table.number] })], +); + +export const project = pgTable("project", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + externalId: text("external_id"), + name: text("name").notNull(), + description: text("description"), + teamName: text("team_name"), + members: jsonb("members").notNull().default([]), + links: jsonb("links").notNull().default({}), + tableNumber: integer("table_number"), + zoneId: uuid("zone_id").references(() => zone.id), + qrToken: uuid("qr_token").notNull().unique().defaultRandom(), + feedbackToken: uuid("feedback_token").unique(), + arrivedFirstAt: timestamp("arrived_first_at", { withTimezone: true }), + withdrawnAt: timestamp("withdrawn_at", { withTimezone: true }), +}); + +export const projectTrack = pgTable( + "project_track", + { + projectId: uuid("project_id") + .notNull() + .references(() => project.id, { onDelete: "cascade" }), + trackId: uuid("track_id") + .notNull() + .references(() => track.id, { onDelete: "cascade" }), + }, + (table) => [primaryKey({ columns: [table.projectId, table.trackId] })], +); + +export const judge = pgTable( + "panel_judge", + { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + externalId: text("external_id"), + name: text("name").notNull(), + email: text("email").notNull(), + org: text("org"), + title: text("title"), + status: judgeStatus("status").notNull().default("invited"), + trackId: uuid("track_id").references(() => track.id), + zoneId: uuid("zone_id").references(() => zone.id), + isLead: boolean("is_lead").notNull().default(false), + }, + (table) => [uniqueIndex("judge_event_email_idx").on(table.eventId, table.email)], +); + +export const visit = pgTable( + "visit", + { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + judgeId: uuid("judge_id") + .notNull() + .references(() => judge.id, { onDelete: "cascade" }), + projectId: uuid("project_id") + .notNull() + .references(() => project.id, { onDelete: "cascade" }), + judgeGroup: text("judge_group").notNull(), + handedOutAt: timestamp("handed_out_at", { withTimezone: true }).notNull(), + arrivedAt: timestamp("arrived_at", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + voidedAt: timestamp("voided_at", { withTimezone: true }), + voidReason: text("void_reason"), + }, + (table) => [ + uniqueIndex("visit_judge_project_open_idx") + .on(table.judgeId, table.projectId) + .where(sql`voided_at is null`), + uniqueIndex("visit_one_open_idx") + .on(table.judgeId) + .where(sql`voided_at is null and completed_at is null`), + ], +); + +export const vote = pgTable("vote", { + id: uuid("id").defaultRandom().primaryKey(), + visitId: uuid("visit_id") + .notNull() + .unique() + .references(() => visit.id, { onDelete: "cascade" }), + judgeId: uuid("judge_id") + .notNull() + .references(() => judge.id, { onDelete: "cascade" }), + projectId: uuid("project_id") + .notNull() + .references(() => project.id, { onDelete: "cascade" }), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + total: numeric("total", { mode: "number" }).notNull(), + comment: text("comment"), + durationSeconds: integer("duration_seconds"), + isCalibration: boolean("is_calibration").notNull().default(false), +}); + +export const voteScore = pgTable( + "vote_score", + { + voteId: uuid("vote_id") + .notNull() + .references(() => vote.id, { onDelete: "cascade" }), + criterionId: uuid("criterion_id") + .notNull() + .references(() => criterion.id), + value: numeric("value", { mode: "number" }).notNull(), + }, + (table) => [primaryKey({ columns: [table.voteId, table.criterionId] })], +); + +export const comparison = pgTable("comparison", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + judgeId: uuid("judge_id") + .notNull() + .references(() => judge.id, { onDelete: "cascade" }), + judgeGroup: text("judge_group").notNull(), + aProjectId: uuid("a_project_id") + .notNull() + .references(() => project.id), + bProjectId: uuid("b_project_id") + .notNull() + .references(() => project.id), + outcome: comparisonOutcome("outcome").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const resultRun = pgTable("result_run", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + computedAt: timestamp("computed_at", { withTimezone: true }).notNull().defaultNow(), + computedBy: uuid("computed_by").references(() => user.id), + configSnapshot: jsonb("config_snapshot").notNull(), + publishedAt: timestamp("published_at", { withTimezone: true }), + notes: text("notes"), +}); + +export const result = pgTable( + "result", + { + runId: uuid("run_id") + .notNull() + .references(() => resultRun.id, { onDelete: "cascade" }), + projectId: uuid("project_id") + .notNull() + .references(() => project.id, { onDelete: "cascade" }), + trackId: uuid("track_id") + .notNull() + .references(() => track.id, { onDelete: "cascade" }), + placement: integer("placement"), + score: numeric("score", { mode: "number" }).notNull(), + rubricComponent: numeric("rubric_component", { mode: "number" }).notNull(), + pairwiseComponent: numeric("pairwise_component", { mode: "number" }).notNull(), + voteCount: integer("vote_count").notNull(), + comparisonCount: integer("comparison_count").notNull(), + flags: text("flags").array().notNull().default([]), + }, + (table) => [ + primaryKey({ columns: [table.runId, table.projectId, table.trackId] }), + ], +); + +export const eventLog = pgTable("event_log", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + kind: text("kind").notNull(), + actor: jsonb("actor").notNull().default({}), + subject: jsonb("subject").notNull().default({}), + payload: jsonb("payload").notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const outbox = pgTable("outbox", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + topic: text("topic").notNull(), + payload: jsonb("payload").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + deliveredAt: timestamp("delivered_at", { withTimezone: true }), + attempts: integer("attempts").notNull().default(0), +}); + +export const webhook = pgTable("webhook", { + id: uuid("id").defaultRandom().primaryKey(), + orgId: uuid("org_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + url: text("url").notNull(), + secret: text("secret").notNull(), + topics: text("topics").array().notNull(), + isActive: boolean("is_active").notNull().default(true), +}); + +export const apiKey = pgTable("api_key", { + id: uuid("id").defaultRandom().primaryKey(), + orgId: uuid("org_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + hashedKey: text("hashed_key").notNull(), + scopes: text("scopes").array().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), +}); + +export const membership = pgTable( + "membership", + { + userId: uuid("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + orgId: uuid("org_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + role: membershipRole("role").notNull(), + }, + (table) => [primaryKey({ columns: [table.userId, table.orgId] })], +); + +export const judgeIdentity = pgTable("judge_identity", { + judgeId: uuid("judge_id") + .primaryKey() + .references(() => judge.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), +}); + +export const loginCode = pgTable("login_code", { + id: uuid("id").defaultRandom().primaryKey(), + email: text("email").notNull(), + codeHash: text("code_hash").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + consumedAt: timestamp("consumed_at", { withTimezone: true }), +}); diff --git a/packages/judging-db/src/seed.ts b/packages/judging-db/src/seed.ts new file mode 100644 index 00000000..9a11405a --- /dev/null +++ b/packages/judging-db/src/seed.ts @@ -0,0 +1,238 @@ +import { eq, sql } from "drizzle-orm"; +import { DEFAULT_TIMERS } from "@query/judging-core"; +import type { PanelDb, PanelTx } from "./client"; +import { + criterion, + event, + eventConfig, + floorTable, + judge, + membership, + organization, + prize, + project, + projectTrack, + rubric, + track, + user, + zone, +} from "./schema"; + +const CRITERIA = [ + ["creativity", "Creativity"], + ["impact", "Impact"], + ["scope", "Scope"], + ["clarity", "Clarity"], + ["soundness", "Soundness"], +] as const; + +/** + * One event a self-hoster can click through: 40 projects, 6 judges, three + * tracks, and a second rubric on the sponsor track. Re-running replaces the + * demo organization. + */ +export async function seedDemo(db: PanelDb): Promise<{ eventId: string }> { + return db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext('panel:seed:demo'))`); + return insertDemo(tx); + }); +} + +async function insertDemo(db: PanelTx): Promise<{ eventId: string }> { + const existing = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.slug, "demo")); + const previous = existing[0]; + if (previous) { + await db.delete(organization).where(eq(organization.id, previous.id)); + } + + const [org] = await db + .insert(organization) + .values({ + slug: "demo", + name: "Demo", + branding: { tagline: "A practice floor" }, + }) + .returning({ id: organization.id }); + if (!org) throw new Error("demo organization was not created"); + + await db.delete(user).where(eq(user.email, "organizer@demo.panel")); + const [organizer] = await db + .insert(user) + .values({ email: "organizer@demo.panel", name: "Demo organizer" }) + .returning({ id: user.id }); + if (!organizer) throw new Error("demo organizer was not created"); + + await db.insert(membership).values({ + userId: organizer.id, + orgId: org.id, + role: "owner", + }); + + const [demoEvent] = await db + .insert(event) + .values({ + orgId: org.id, + slug: "demo", + name: "Demo", + phase: "judging_live", + }) + .returning({ id: event.id }); + if (!demoEvent) throw new Error("demo event was not created"); + + await db.insert(eventConfig).values({ + eventId: demoEvent.id, + targetSeconds: DEFAULT_TIMERS.targetSeconds, + hardLimitSeconds: DEFAULT_TIMERS.hardLimitSeconds, + walkLimitSeconds: DEFAULT_TIMERS.walkLimitSeconds, + submitGraceSeconds: DEFAULT_TIMERS.submitGraceSeconds, + minLooksPerProject: 1, + dispatchStrategy: "coverage", + pairwiseEnabled: false, + pairwiseWeight: 0, + bayesianC: 2, + calibrationLooks: 0, + calibrationWeight: 1, + feedbackCardsEnabled: false, + leaderboardPublic: false, + boardPollSeconds: 5, + }); + + const [mainRubric] = await db + .insert(rubric) + .values({ eventId: demoEvent.id, name: "Main", isDefault: true }) + .returning({ id: rubric.id }); + const [sponsorRubric] = await db + .insert(rubric) + .values({ eventId: demoEvent.id, name: "Sponsor", isDefault: false }) + .returning({ id: rubric.id }); + if (!mainRubric || !sponsorRubric) throw new Error("demo rubrics were not created"); + + await db.insert(criterion).values( + CRITERIA.map(([key, label], position) => ({ + rubricId: mainRubric.id, + position, + key, + label, + min: 0, + max: 10, + weight: 1, + })), + ); + await db.insert(criterion).values([ + { + rubricId: sponsorRubric.id, + position: 0, + key: "fit", + label: "Fit", + min: 0, + max: 10, + weight: 1, + }, + { + rubricId: sponsorRubric.id, + position: 1, + key: "execution", + label: "Execution", + min: 0, + max: 10, + weight: 1, + }, + ]); + + const tracks = await db + .insert(track) + .values([ + { + eventId: demoEvent.id, + slug: "general", + name: "General", + kind: "main" as const, + judgeGroup: "main", + rubricId: mainRubric.id, + position: 0, + }, + { + eventId: demoEvent.id, + slug: "sponsor", + name: "Sponsor", + kind: "sponsor" as const, + judgeGroup: "sponsor", + rubricId: sponsorRubric.id, + position: 1, + }, + { + eventId: demoEvent.id, + slug: "beginner", + name: "Beginner", + kind: "special" as const, + judgeGroup: "beginner", + rubricId: mainRubric.id, + position: 2, + }, + ]) + .returning({ id: track.id, slug: track.slug }); + + const general = tracks.find((item) => item.slug === "general"); + const sponsor = tracks.find((item) => item.slug === "sponsor"); + const beginner = tracks.find((item) => item.slug === "beginner"); + if (!general || !sponsor || !beginner) throw new Error("demo tracks were not created"); + + await db.insert(prize).values([ + { trackId: general.id, place: 1, title: "First", amount: 1000 }, + { trackId: sponsor.id, place: 1, title: "Sponsor prize", amount: 500 }, + ]); + + const [hall] = await db + .insert(zone) + .values({ eventId: demoEvent.id, name: "Hall", position: 0 }) + .returning({ id: zone.id }); + if (!hall) throw new Error("demo zone was not created"); + + await db.insert(floorTable).values( + Array.from({ length: 40 }, (_, index) => ({ + eventId: demoEvent.id, + number: index + 1, + zoneId: hall.id, + })), + ); + + const projects = await db + .insert(project) + .values( + Array.from({ length: 40 }, (_, index) => ({ + eventId: demoEvent.id, + name: `Project ${index + 1}`, + teamName: `Team ${index + 1}`, + tableNumber: index + 1, + zoneId: hall.id, + })), + ) + .returning({ id: project.id, tableNumber: project.tableNumber }); + + await db.insert(projectTrack).values( + projects.flatMap((item) => { + const links = [{ projectId: item.id, trackId: general.id }]; + const number = item.tableNumber ?? 0; + if (number % 5 === 0) links.push({ projectId: item.id, trackId: sponsor.id }); + if (number % 4 === 0) links.push({ projectId: item.id, trackId: beginner.id }); + return links; + }), + ); + + const sponsorTrack = tracks.find((item) => item.slug === "sponsor"); + await db.insert(judge).values( + Array.from({ length: 6 }, (_, index) => ({ + eventId: demoEvent.id, + name: `Judge ${index + 1}`, + email: `judge${index + 1}@demo.panel`, + status: "approved" as const, + trackId: index === 5 ? sponsorTrack?.id : null, + zoneId: hall.id, + })), + ); + + return { eventId: demoEvent.id }; +} diff --git a/packages/judging-db/tsconfig.json b/packages/judging-db/tsconfig.json new file mode 100644 index 00000000..f54bde2c --- /dev/null +++ b/packages/judging-db/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tooling/typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/judging-server/eslint.config.mjs b/packages/judging-server/eslint.config.mjs new file mode 100644 index 00000000..2d326cc0 --- /dev/null +++ b/packages/judging-server/eslint.config.mjs @@ -0,0 +1,10 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...config, + { + files: ["src/log.ts", "src/index.ts"], + rules: { "no-console": "off" }, + }, +]; diff --git a/packages/judging-server/package.json b/packages/judging-server/package.json new file mode 100644 index 00000000..da7cabd3 --- /dev/null +++ b/packages/judging-server/package.json @@ -0,0 +1,34 @@ +{ + "name": "@query/judging-server", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./outbox": "./src/services/outbox.ts" + }, + "scripts": { + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc --noEmit", + "test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts" + }, + "dependencies": { + "@query/judging-core": "workspace:*", + "@query/judging-db": "workspace:*", + "@trpc/server": "11.19.0", + "drizzle-orm": "0.45.3", + "hono": "^4.9.0", + "ioredis": "^5.8.2", + "jose": "^6.1.0", + "nodemailer": "^10.0.10", + "prom-client": "15.1.3", + "zod": "4.6.5" + }, + "devDependencies": { + "@query/eslint-config": "workspace:*", + "@query/tsconfig": "workspace:*", + "@types/node": "22.20.4", + "typescript": "7.0.2", + "vitest": "^5.0.1" + } +} diff --git a/packages/judging-server/src/app.test.ts b/packages/judging-server/src/app.test.ts new file mode 100644 index 00000000..d31f2ae7 --- /dev/null +++ b/packages/judging-server/src/app.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "./app"; +import { createMetrics } from "./metrics"; + +describe("health", () => { + it("answers /healthz without a database", async () => { + const app = createApp({ + db: {} as never, + metrics: createMetrics(), + jwtSecret: "test", + devAuth: false, + busMode: "memory", + }); + const response = await app.request("/healthz"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + }); +}); + +describe("session actor", () => { + it("asks resolveActor when there is no bearer token", async () => { + const seen: string[] = []; + const app = createApp({ + db: {} as never, + metrics: createMetrics(), + jwtSecret: "test", + devAuth: false, + busMode: "memory", + resolveActor: async (request) => { + seen.push(new URL(request.url).pathname); + return null; + }, + }); + const response = await app.request( + "/v1/session/progress?eventId=00000000-0000-4000-8000-000000000000", + ); + expect(response.status).toBe(401); + expect(seen).toEqual(["/v1/session/progress"]); + }); +}); diff --git a/packages/judging-server/src/app.ts b/packages/judging-server/src/app.ts new file mode 100644 index 00000000..b72251ca --- /dev/null +++ b/packages/judging-server/src/app.ts @@ -0,0 +1,385 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { TRPCError } from "@trpc/server"; +import { and, eq, isNull, sql } from "drizzle-orm"; +import { z } from "zod"; +import type { PanelDb } from "@query/judging-db"; +import { event, loginCode, membership, organization, user, visit } from "@query/judging-db"; +import { hashCode, newLoginCode, roleAtLeast, signActor, verifyActor } from "./auth"; +import { sendLoginCode } from "./mail"; +import type { Actor, Role } from "./auth"; +import type { Bus } from "./bus"; +import { InMemoryBus } from "./bus"; +import { feedbackCard, feedbackTokenForExternal, actorForApiKey } from "./services/catalog"; +import { organizerFloor } from "./services/floor"; +import { liveSnapshot } from "./services/live"; +import { inspectRun, publishedPlacements } from "./services/results"; +import type { Metrics } from "./metrics"; +import { appRouter } from "./router"; +import type { Context } from "./router"; + +const openApi = { + openapi: "3.0.3", + info: { + title: "Panel", + version: "0.0.0", + description: + "REST mirror of the tRPC procedures. Send Authorization: Bearer with a JWT or an API key, except for the public routes.", + }, + paths: { + "/healthz": { get: { summary: "Process is up." } }, + "/readyz": { get: { summary: "Postgres answers. The bus is this process unless Redis is set." } }, + "/metrics": { get: { summary: "Prometheus metrics." } }, + "/openapi.json": { get: { summary: "This document." } }, + "/v1/auth/magic-link": { post: { summary: "Email a sign-in code. Dev auth returns the code." } }, + "/v1/auth/verify": { post: { summary: "Exchange a code for a JWT." } }, + "/v1/judges/apply": { post: { summary: "Apply to judge this event with the signed-in email." } }, + "/v1/public/{orgSlug}/{eventSlug}": { get: { summary: "Event name, phase, and branding." } }, + "/v1/session/next": { post: { summary: "Hand the signed-in judge their next table." } }, + "/v1/session/arrive": { post: { summary: "Mark arrival by table number or QR token." } }, + "/v1/session/vote": { post: { summary: "Store the score for the open visit." } }, + "/v1/session/skip": { post: { summary: "Pass on the open table without a score." } }, + "/v1/session/progress": { get: { summary: "How many projects this judge has scored." } }, + "/v1/session/status": { get: { summary: "Whether the open visit was voided or the judge recalled." } }, + "/v1/session/compare": { post: { summary: "Record which of the last two tables was better." } }, + "/v1/session/rubric": { get: { summary: "Criteria for the event's default rubric." } }, + "/v1/catalog/projects": { get: { summary: "Projects for table cards. Organizer." } }, + "/v1/catalog/logs": { get: { summary: "Recent event log rows. Organizer." } }, + "/v1/catalog/tables": { get: { summary: "Floor tables and coordinates. Organizer." } }, + "/v1/catalog/judges": { get: { summary: "Judges and their status. Organizer." } }, + "/v1/live/{orgSlug}/{eventSlug}": { get: { summary: "Board snapshot: coverage, or placements after publish." } }, + "/v1/floor/{eventId}": { get: { summary: "Organizer floor: looks per table, and idle, walking, or overtime judges." } }, + "/v1/results/run/{runId}": { get: { summary: "One run, with rubric and pairwise components. Organizer." } }, + "/v1/results/{eventId}": { get: { summary: "Published placements. 403 before publish." } }, + "/v1/feedback/{token}": { get: { summary: "A team's card. 403 for an unknown token or before publish." } }, + }, +}; + +export function createApp(options: { + db: PanelDb; + metrics: Metrics; + jwtSecret: string; + jwksUrl?: string; + devAuth: boolean; + smtpUrl?: string; + busMode: "memory" | "redis"; + bus?: Bus; + /** Who is signed in when the request carries no bearer token, e.g. a portal session cookie. */ + resolveActor?: (request: Request) => Promise; +}) { + const bus = options.bus ?? new InMemoryBus(); + const app = new Hono(); + app.use( + "*", + cors({ + origin: "*", + allowHeaders: ["Authorization", "Content-Type"], + allowMethods: ["GET", "POST", "OPTIONS"], + }), + ); + + app.get("/healthz", (c) => c.json({ ok: true })); + + app.get("/readyz", async (c) => { + try { + await options.db.execute(sql`select 1`); + return c.json({ ok: true, bus: options.busMode }); + } catch (error) { + const message = error instanceof Error ? error.message : "database"; + return c.json({ ok: false, message }, 503); + } + }); + + app.get("/metrics", async (c) => { + const [open] = await options.db + .select({ n: sql`count(distinct ${visit.judgeId})::int` }) + .from(visit) + .where(and(isNull(visit.completedAt), isNull(visit.voidedAt))); + options.metrics.liveJudges.set(open?.n ?? 0); + c.header("content-type", options.metrics.register.contentType); + return c.body(await options.metrics.register.metrics()); + }); + + app.get("/openapi.json", (c) => c.json(openApi)); + + app.post("/v1/auth/magic-link", async (c) => { + const body = z.object({ email: z.string().email() }).parse(await c.req.json()); + if (!options.devAuth && !options.smtpUrl) { + return c.json({ ok: false, message: "SMTP is not configured" }, 503); + } + const code = newLoginCode(); + const email = body.email.toLowerCase(); + await options.db.insert(loginCode).values({ + email, + codeHash: hashCode(code), + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + }); + if (options.smtpUrl) { + await sendLoginCode(options.smtpUrl, email, code); + return c.json({ ok: true }); + } + return c.json({ ok: true, code }); + }); + + app.post("/v1/auth/verify", async (c) => { + const body = z + .object({ + email: z.string().email(), + code: z.string().length(6), + org: z.string().optional(), + }) + .parse(await c.req.json()); + const email = body.email.toLowerCase(); + const [row] = await options.db + .select() + .from(loginCode) + .where( + and( + eq(loginCode.email, email), + eq(loginCode.codeHash, hashCode(body.code)), + isNull(loginCode.consumedAt), + ), + ); + if (!row || row.expiresAt.getTime() < Date.now()) { + return c.json({ ok: false, message: "Code is not valid" }, 401); + } + await options.db + .update(loginCode) + .set({ consumedAt: new Date() }) + .where(eq(loginCode.id, row.id)); + + const [person] = await options.db + .select() + .from(user) + .where(eq(user.email, email)); + let role: Role | null = null; + let orgSlug: string | null = body.org ?? null; + if (person) { + const rows = await options.db + .select({ role: membership.role, slug: organization.slug }) + .from(membership) + .innerJoin(organization, eq(organization.id, membership.orgId)) + .where(eq(membership.userId, person.id)); + const chosen = orgSlug + ? rows.find((item) => item.slug === orgSlug) + : rows.length === 1 + ? rows[0] + : undefined; + if (chosen) { + role = chosen.role; + orgSlug = chosen.slug; + } + } + + if (!options.jwtSecret) { + return c.json({ ok: false, message: "PANEL_JWT_SECRET is not set" }, 503); + } + const token = await signActor( + { + sub: person?.id ?? email, + email, + name: person?.name ?? email, + org: orgSlug, + role, + judgeExternalId: null, + }, + options.jwtSecret, + 60 * 60 * 12, + ); + return c.json({ ok: true, token }); + }); + + app.post("/v1/judges/apply", async (c) => { + const body = z + .object({ eventId: z.string().uuid(), name: z.string().min(1) }) + .parse(await c.req.json()); + return rest((caller) => caller.judge.applyToEvent(body))(c); + }); + + app.get("/v1/public/:orgSlug/:eventSlug", async (c) => { + const [row] = await options.db + .select({ + eventId: event.id, + name: event.name, + phase: event.phase, + branding: organization.branding, + orgName: organization.name, + }) + .from(event) + .innerJoin(organization, eq(organization.id, event.orgId)) + .where( + and( + eq(organization.slug, c.req.param("orgSlug")), + eq(event.slug, c.req.param("eventSlug")), + ), + ); + if (!row) return c.json({ ok: false }, 404); + return c.json(row); + }); + + const contextFor = async (c: { + req: { header: (name: string) => string | undefined; raw: Request }; + }): Promise => { + const header = c.req.header("authorization"); + let actor: Context["actor"] = null; + if (header?.startsWith("Bearer ")) { + const token = header.slice("Bearer ".length); + try { + actor = await verifyActor(token, { + secret: options.jwtSecret, + jwksUrl: options.jwksUrl, + }); + } catch { + actor = await actorForApiKey(options.db, token); + } + } else if (options.resolveActor) { + actor = await options.resolveActor(c.req.raw); + } + return { db: options.db, actor, now: new Date(), bus, metrics: options.metrics }; + }; + + const rest = (run: (caller: ReturnType) => Promise) => { + return async (c: Parameters[0] & { json: (body: unknown, status?: number) => Response }) => { + try { + return c.json(await run(appRouter.createCaller(await contextFor(c)))); + } catch (error) { + const message = error instanceof TRPCError ? error.message : "Request failed"; + const status = error instanceof TRPCError && error.code === "UNAUTHORIZED" ? 401 : 400; + return c.json({ message }, status); + } + }; + }; + + app.post("/v1/session/next", async (c) => { + const body = z.object({ eventId: z.string().uuid() }).parse(await c.req.json()); + return rest((caller) => caller.session.next(body))(c); + }); + app.post("/v1/session/arrive", async (c) => { + const body = z + .object({ + eventId: z.string().uuid(), + qrToken: z.string().uuid().optional(), + tableNumber: z.number().int().optional(), + }) + .parse(await c.req.json()); + return rest((caller) => caller.session.arrive(body))(c); + }); + app.post("/v1/session/vote", async (c) => { + const body = z + .object({ + eventId: z.string().uuid(), + visitId: z.string().uuid(), + comment: z.string().nullable().optional(), + scores: z.array(z.object({ criterionId: z.string().uuid(), value: z.number() })), + }) + .parse(await c.req.json()); + return rest((caller) => caller.session.vote({ ...body, comment: body.comment ?? null }))(c); + }); + app.get("/v1/session/rubric", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.session.rubric({ eventId }))(c); + }); + app.post("/v1/session/compare", async (c) => { + const body = z + .object({ eventId: z.string().uuid(), outcome: z.enum(["a", "b", "tie"]) }) + .parse(await c.req.json()); + return rest((caller) => caller.session.compare(body))(c); + }); + app.post("/v1/session/skip", async (c) => { + const body = z + .object({ eventId: z.string().uuid(), visitId: z.string().uuid() }) + .parse(await c.req.json()); + return rest((caller) => caller.session.skip(body))(c); + }); + app.get("/v1/session/status", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + const visitId = z.string().uuid().parse(c.req.query("visitId")); + return rest((caller) => caller.session.status({ eventId, visitId }))(c); + }); + app.get("/v1/session/progress", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.session.progress({ eventId }))(c); + }); + + app.get("/v1/catalog/projects", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.catalog.projects({ eventId }))(c); + }); + app.get("/v1/catalog/logs", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.catalog.logs({ eventId }))(c); + }); + app.get("/v1/catalog/tables", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.catalog.tables({ eventId }))(c); + }); + app.get("/v1/catalog/judges", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.catalog.judges({ eventId }))(c); + }); + + app.get("/v1/floor/:eventId", async (c) => { + const ctx = await contextFor(c); + if (!ctx.actor || !roleAtLeast(ctx.actor.role, "organizer")) { + return c.json({ message: "Unauthorized" }, 401); + } + const eventId = z.string().uuid().parse(c.req.param("eventId")); + const floor = await organizerFloor(options.db, eventId, ctx.now); + if (!floor) return c.json({ message: "Not found" }, 404); + return c.json(floor); + }); + + app.get("/v1/live/:orgSlug/:eventSlug", async (c) => { + const snapshot = await liveSnapshot( + options.db, + c.req.param("orgSlug"), + c.req.param("eventSlug"), + ); + if (!snapshot) return c.json({ ok: false }, 404); + return c.json(snapshot); + }); + + app.get("/v1/results/run/:runId", async (c) => { + if (!(await contextFor(c)).actor) return c.json({ message: "Unauthorized" }, 401); + const runId = z.string().uuid().parse(c.req.param("runId")); + return c.json(await inspectRun(options.db, runId)); + }); + + app.get("/v1/results/:eventId", async (c) => { + if (!(await contextFor(c)).actor) return c.json({ message: "Unauthorized" }, 401); + const eventId = z.string().uuid().parse(c.req.param("eventId")); + const placements = await publishedPlacements(options.db, eventId); + if (!placements) return c.json({ message: "Not found" }, 404); + if (!placements.published) return c.json({ message: "Forbidden" }, 403); + return c.json(placements); + }); + + app.get("/v1/feedback/external/:eventId/:externalId", async (c) => { + const ctx = await contextFor(c); + if (!ctx.actor || !roleAtLeast(ctx.actor.role, "organizer")) { + return c.json({ message: "Unauthorized" }, 401); + } + const eventId = z.string().uuid().parse(c.req.param("eventId")); + const token = await feedbackTokenForExternal(options.db, eventId, c.req.param("externalId")); + if (!token) return c.json({ message: "Forbidden" }, 403); + return c.json({ token }); + }); + + app.get("/v1/feedback/:token", async (c) => { + const card = await feedbackCard(options.db, c.req.param("token")); + if (card.status === "forbidden") return c.json({ message: "Forbidden" }, 403); + return c.json(card); + }); + + app.all("/trpc/*", (c) => + fetchRequestHandler({ + endpoint: "/trpc", + req: c.req.raw, + router: appRouter, + createContext: () => contextFor(c), + }), + ); + + return app; +} diff --git a/packages/judging-server/src/auth.test.ts b/packages/judging-server/src/auth.test.ts new file mode 100644 index 00000000..33babd47 --- /dev/null +++ b/packages/judging-server/src/auth.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { hashApiKey, roleForScopes } from "./auth"; + +describe("api keys", () => { + it("hashes a token the same way every time", () => { + expect(hashApiKey("panel_live_key")).toBe(hashApiKey("panel_live_key")); + expect(hashApiKey("panel_live_key")).not.toBe(hashApiKey("panel_live_other")); + }); + + it("uses the strongest scope as the role", () => { + expect(roleForScopes(["volunteer", "organizer"])).toBe("organizer"); + expect(roleForScopes(["admin"])).toBe("admin"); + expect(roleForScopes(["import"])).toBe("organizer"); + expect(roleForScopes(["read"])).toBe(null); + }); +}); diff --git a/packages/judging-server/src/auth.ts b/packages/judging-server/src/auth.ts new file mode 100644 index 00000000..8a51e635 --- /dev/null +++ b/packages/judging-server/src/auth.ts @@ -0,0 +1,100 @@ +import { createHash, randomInt } from "node:crypto"; +import { SignJWT, createRemoteJWKSet, jwtVerify } from "jose"; + +export const ROLES = ["volunteer", "organizer", "admin", "owner"] as const; +export type Role = (typeof ROLES)[number]; + +export type Actor = { + sub: string; + email: string; + name: string; + org: string | null; + role: Role | null; + judgeExternalId: string | null; +}; + +const ROLE_RANK: Record = { + volunteer: 0, + organizer: 1, + admin: 2, + owner: 3, +}; + +export function roleAtLeast(actual: Role | null, minimum: Role): boolean { + if (!actual) return false; + return ROLE_RANK[actual] >= ROLE_RANK[minimum]; +} + +export function hashCode(code: string): string { + return createHash("sha256").update(code).digest("hex"); +} + +export function newLoginCode(): string { + return String(randomInt(0, 1_000_000)).padStart(6, "0"); +} + +export async function signActor( + actor: Actor, + secret: string, + ttlSeconds: number, +): Promise { + return new SignJWT({ + email: actor.email, + name: actor.name, + org: actor.org, + role: actor.role, + judge_external_id: actor.judgeExternalId, + }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(actor.sub) + .setIssuedAt() + .setExpirationTime(`${ttlSeconds}s`) + .sign(new TextEncoder().encode(secret)); +} + +export async function verifyActor( + token: string, + options: { secret?: string; jwksUrl?: string }, +): Promise { + const verified = options.jwksUrl + ? await jwtVerify(token, createRemoteJWKSet(new URL(options.jwksUrl))) + : await jwtVerify(token, new TextEncoder().encode(options.secret ?? "")); + const payload = verified.payload; + const email = payload.email; + const name = payload.name; + if (typeof email !== "string" || typeof payload.sub !== "string") { + throw new Error("Token is missing sub or email"); + } + const role = ROLES.find((item) => item === payload.role) ?? null; + return { + sub: payload.sub, + email, + name: typeof name === "string" ? name : email, + org: typeof payload.org === "string" ? payload.org : null, + role, + judgeExternalId: + typeof payload.judge_external_id === "string" + ? payload.judge_external_id + : null, + }; +} + +export function hashApiKey(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +/** The strongest role named in the key's scopes. Import and export are organizer work. */ +export function roleForScopes(scopes: readonly string[]): Role | null { + if (scopes.includes("owner")) return "owner"; + if (scopes.includes("admin")) return "admin"; + if ( + scopes.includes("organizer") || + scopes.includes("import") || + scopes.includes("export") || + scopes.includes("webhooks") + ) { + return "organizer"; + } + if (scopes.includes("volunteer")) return "volunteer"; + return null; +} diff --git a/packages/judging-server/src/bus.test.ts b/packages/judging-server/src/bus.test.ts new file mode 100644 index 00000000..b35b71f4 --- /dev/null +++ b/packages/judging-server/src/bus.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { FallbackBus, InMemoryBus } from "./bus"; +import type { Bus } from "./bus"; + +describe("InMemoryBus", () => { + it("delivers a message only to subscribers of that channel", () => { + const bus = new InMemoryBus(); + const seen: unknown[] = []; + const stop = bus.subscribe("event:1:board", (message) => seen.push(message)); + bus.publish("event:1:board", { kind: "vote.cast" }); + bus.publish("event:2:board", { kind: "other" }); + stop(); + bus.publish("event:1:board", { kind: "again" }); + expect(seen).toEqual([{ kind: "vote.cast" }]); + }); +}); + +class DownableBus implements Bus { + readonly seen: unknown[] = []; + private readonly down: (() => void)[] = []; + + publish(_channel: string, message: unknown) { + this.seen.push(message); + } + + subscribe() { + return () => undefined; + } + + onDown(listener: () => void) { + this.down.push(listener); + } + + stop() { + for (const listener of this.down) listener(); + } +} + +describe("FallbackBus", () => { + it("keeps delivering on this process after Redis reports down", () => { + const redis = new DownableBus(); + const bus = new FallbackBus(redis); + const seen: unknown[] = []; + bus.subscribe("event:1:board", (message) => seen.push(message)); + bus.publish("event:1:board", { kind: "before" }); + redis.stop(); + bus.publish("event:1:board", { kind: "after" }); + expect(redis.seen).toEqual([{ kind: "before" }]); + expect(seen).toEqual([{ kind: "after" }]); + }); +}); diff --git a/packages/judging-server/src/bus.ts b/packages/judging-server/src/bus.ts new file mode 100644 index 00000000..1cc6fe2b --- /dev/null +++ b/packages/judging-server/src/bus.ts @@ -0,0 +1,131 @@ +import { Redis } from "ioredis"; +import { log } from "./log"; + +type Listener = (message: unknown) => void; + +export type Bus = { + publish(channel: string, message: unknown): void; + subscribe(channel: string, listener: Listener): () => void; + onDown(listener: () => void): void; +}; + +/** In-process fan-out. Used when REDIS_URL is unset. */ +export class InMemoryBus implements Bus { + private readonly channels = new Map>(); + + publish(channel: string, message: unknown) { + for (const listener of this.channels.get(channel) ?? []) listener(message); + } + + subscribe(channel: string, listener: Listener) { + const listeners = this.channels.get(channel) ?? new Set(); + listeners.add(listener); + this.channels.set(channel, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) this.channels.delete(channel); + }; + } + + onDown() { + // This process is the bus. + } +} + +export function createRedisBus(url: string): Bus { + const options = { maxRetriesPerRequest: null }; + const pub = new Redis(url, options); + const sub = new Redis(url, options); + const listeners = new Map>(); + const downListeners = new Set<() => void>(); + let down = false; + const fail = () => { + if (down) return; + down = true; + for (const listener of downListeners) listener(); + }; + pub.on("error", fail); + sub.on("error", fail); + sub.on("message", (channel: string, raw: string) => { + const message = JSON.parse(raw) as unknown; + for (const listener of listeners.get(channel) ?? []) listener(message); + }); + return { + publish(channel, message) { + void pub.publish(channel, JSON.stringify(message)); + }, + subscribe(channel, listener) { + const set = listeners.get(channel) ?? new Set(); + const first = set.size === 0; + set.add(listener); + listeners.set(channel, set); + if (first) void sub.subscribe(channel); + return () => { + set.delete(listener); + if (set.size === 0) void sub.unsubscribe(channel); + }; + }, + onDown(listener) { + downListeners.add(listener); + }, + }; +} + +/** Redis when REDIS_URL is set, otherwise the in-process bus. */ +export function createBus(redisUrl: string | undefined): Bus { + const url = redisUrl?.trim(); + if (!url) return new InMemoryBus(); + return new FallbackBus(createRedisBus(url)); +} + +/** + * Uses Redis until the connection errors, then this process fans out in memory. + * Clients still reload state from the live snapshot, so a second server does + * not need Redis for correctness. + */ +export class FallbackBus implements Bus { + private readonly memory = new InMemoryBus(); + private redis: Bus | null; + private failed = false; + private readonly subs: { channel: string; listener: Listener; unsubscribe: () => void }[] = []; + + constructor(redis: Bus) { + this.redis = redis; + redis.onDown(() => this.fail()); + } + + private fail() { + if (this.failed) return; + this.failed = true; + for (const sub of this.subs) { + sub.unsubscribe(); + sub.unsubscribe = this.memory.subscribe(sub.channel, sub.listener); + } + this.redis = null; + log({ + level: "warn", + message: "Redis is unavailable. This process is using the in-process bus.", + }); + } + + publish(channel: string, message: unknown) { + const target = this.failed ? this.memory : this.redis; + target?.publish(channel, message); + } + + subscribe(channel: string, listener: Listener) { + const target = this.failed ? this.memory : this.redis; + const unsubscribe = target?.subscribe(channel, listener) ?? (() => undefined); + const sub = { channel, listener, unsubscribe }; + this.subs.push(sub); + return () => { + sub.unsubscribe(); + const index = this.subs.indexOf(sub); + if (index >= 0) this.subs.splice(index, 1); + }; + } + + onDown() { + // Callers keep their sockets. This process still delivers messages. + } +} diff --git a/packages/judging-server/src/index.ts b/packages/judging-server/src/index.ts new file mode 100644 index 00000000..669d6c96 --- /dev/null +++ b/packages/judging-server/src/index.ts @@ -0,0 +1,9 @@ +export { createApp } from "./app"; +export { appRouter } from "./router"; +export { drainOutbox } from "./services/outbox"; +export type { Context } from "./router"; +export type { Actor, Role } from "./auth"; +export { InMemoryBus } from "./bus"; +export { createMetrics } from "./metrics"; +export { publishedPlacements } from "./services/results"; +export { ensureEvent } from "./services/event"; diff --git a/packages/judging-server/src/log.ts b/packages/judging-server/src/log.ts new file mode 100644 index 00000000..7f80e178 --- /dev/null +++ b/packages/judging-server/src/log.ts @@ -0,0 +1,3 @@ +export function log(fields: Record) { + console.log(JSON.stringify({ time: new Date().toISOString(), ...fields })); +} diff --git a/packages/judging-server/src/mail.ts b/packages/judging-server/src/mail.ts new file mode 100644 index 00000000..89d84954 --- /dev/null +++ b/packages/judging-server/src/mail.ts @@ -0,0 +1,12 @@ +import nodemailer from "nodemailer"; + +/** Sends the sign-in code when PANEL_SMTP_URL is set. */ +export async function sendLoginCode(smtpUrl: string, to: string, code: string) { + const transport = nodemailer.createTransport(smtpUrl); + await transport.sendMail({ + from: process.env.PANEL_SMTP_FROM ?? "panel@localhost", + to, + subject: "Your panel sign-in code", + text: `Your sign-in code is ${code}. It expires in 10 minutes.`, + }); +} diff --git a/packages/judging-server/src/metrics.ts b/packages/judging-server/src/metrics.ts new file mode 100644 index 00000000..5089489a --- /dev/null +++ b/packages/judging-server/src/metrics.ts @@ -0,0 +1,40 @@ +import { Counter, Gauge, Histogram, Registry } from "prom-client"; + +export function createMetrics() { + const register = new Registry(); + const dispatchSeconds = new Histogram({ + name: "panel_dispatch_seconds", + help: "Time to choose and record the next table", + registers: [register], + }); + const votes = new Counter({ + name: "panel_votes_total", + help: "Votes stored", + registers: [register], + }); + const outboxPending = new Gauge({ + name: "panel_outbox_pending", + help: "Outbox rows not yet delivered", + registers: [register], + }); + const wsConnections = new Gauge({ + name: "panel_ws_connections", + help: "Open websocket clients", + registers: [register], + }); + const liveJudges = new Gauge({ + name: "panel_live_judges", + help: "Judges with a visit that is still open", + registers: [register], + }); + const coverage = new Histogram({ + name: "panel_project_looks", + help: "How many scores a project had when the latest one landed", + buckets: [1, 2, 3, 4, 5, 8, 13], + labelNames: ["event_id"], + registers: [register], + }); + return { register, dispatchSeconds, votes, outboxPending, wsConnections, liveJudges, coverage }; +} + +export type Metrics = ReturnType; diff --git a/packages/judging-server/src/router.ts b/packages/judging-server/src/router.ts new file mode 100644 index 00000000..90e44911 --- /dev/null +++ b/packages/judging-server/src/router.ts @@ -0,0 +1,674 @@ +import { initTRPC, TRPCError } from "@trpc/server"; +import { and, eq, sql } from "drizzle-orm"; +import { z } from "zod"; +import { assertTransition, boardChannel, judgeChannel, leaderboardChannel } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; +import { event, eventLog, judge, criterion, rubric, visit, vote } from "@query/judging-db"; +import type { Actor, Role } from "./auth"; +import { roleAtLeast } from "./auth"; +import type { Bus } from "./bus"; +import type { Metrics } from "./metrics"; +import { log } from "./log"; +import { arrive } from "./services/arrive"; +import { dispatchNext, judgeProgress, skipVisit, visitStatus } from "./services/dispatch"; +import { + assignJudge, + applyAsJudge, + recallJudge, + setJudgeStatus, + upsertJudge, + upsertProject, + voidVisit, + withdrawProject, + rotateQr, + assignEventTables, +} from "./services/records"; +import { + computeResults, + diffPlacements, + inspectRun, + placementsForRun, + publishedPlacements, + publishResults, + unpublishResults, +} from "./services/results"; +import { castComparison } from "./services/compare"; +import { + issueApiKey, + listProjects, + listLogs, + listJudges, + listTables, + listTracks, + placeTable, + savePrize, + saveRubric, + saveTrack, + saveWebhook, + saveZone, + saveConfig, +} from "./services/catalog"; +import { castVote } from "./services/vote"; +import { drainOutbox } from "./services/outbox"; + +export type Context = { + db: PanelDb; + actor: Actor | null; + now: Date; + bus: Bus; + metrics: Metrics; +}; + +function tell( + ctx: Context, + eventId: string, + judgeId: string | null, + kind: string, + visitId?: string, +) { + log({ + level: "info", + message: kind, + event_id: eventId, + judge_id: judgeId, + visit_id: visitId ?? null, + }); + const message = { kind, eventId }; + ctx.bus.publish(boardChannel(eventId), message); + ctx.bus.publish(leaderboardChannel(eventId), message); + if (judgeId) ctx.bus.publish(judgeChannel(judgeId), message); +} + +const t = initTRPC.context().create(); + +function asTrpc(error: unknown): never { + const message = error instanceof Error ? error.message : "Request failed"; + const code = message.includes("not allowed") + ? "PRECONDITION_FAILED" + : message.includes("not found") || message.includes("not approved") + ? "NOT_FOUND" + : message.includes("hard limit") + ? "CONFLICT" + : "BAD_REQUEST"; + throw new TRPCError({ code, message }); +} + +const authed = t.procedure.use(({ ctx, next }) => { + if (!ctx.actor) throw new TRPCError({ code: "UNAUTHORIZED" }); + return next({ ctx: { ...ctx, actor: ctx.actor } }); +}); + +function atLeast(role: Role) { + return authed.use(({ ctx, next }) => { + if (!roleAtLeast(ctx.actor.role, role)) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + return next(); + }); +} + +async function requireJudge(ctx: Context, eventId: string) { + if (!ctx.actor) throw new TRPCError({ code: "UNAUTHORIZED" }); + const rows = await ctx.db + .select() + .from(judge) + .where(eq(judge.eventId, eventId)); + const match = rows.find( + (row) => + (ctx.actor?.judgeExternalId && + row.externalId === ctx.actor.judgeExternalId) || + row.email.toLowerCase() === ctx.actor?.email.toLowerCase(), + ); + if (!match || match.status !== "approved") { + throw new TRPCError({ code: "FORBIDDEN", message: "This judge is not approved" }); + } + return match; +} + +const uuid = z.string().uuid(); + +export const appRouter = t.router({ + session: t.router({ + next: authed + .input(z.object({ eventId: uuid })) + .mutation(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + const stop = ctx.metrics.dispatchSeconds.startTimer(); + try { + const outcome = await dispatchNext(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + now: ctx.now, + }); + tell( + ctx, + input.eventId, + row.id, + "visit.handed_out", + outcome.done ? undefined : outcome.visitId, + ); + return outcome; + } catch (error) { + asTrpc(error); + } finally { + stop(); + } + }), + arrive: authed + .input( + z.object({ + eventId: uuid, + qrToken: uuid.optional(), + tableNumber: z.number().int().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + const outcome = await arrive(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + qrToken: input.qrToken, + tableNumber: input.tableNumber, + now: ctx.now, + }); + tell(ctx, input.eventId, row.id, "visit.arrived", outcome.visitId); + return outcome; + } catch (error) { + asTrpc(error); + } + }), + vote: authed + .input( + z.object({ + eventId: uuid, + visitId: uuid, + comment: z.string().nullable().default(null), + scores: z.array( + z.object({ criterionId: uuid, value: z.number() }), + ), + }), + ) + .mutation(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + const outcome = await castVote(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + visitId: input.visitId, + scores: input.scores, + comment: input.comment, + now: ctx.now, + }); + tell(ctx, input.eventId, row.id, "vote.cast", input.visitId); + ctx.metrics.votes.inc(); + const [current] = await ctx.db + .select({ projectId: visit.projectId }) + .from(visit) + .where(eq(visit.id, input.visitId)); + if (current) { + const [looks] = await ctx.db + .select({ n: sql`count(*)::int` }) + .from(vote) + .where( + and(eq(vote.eventId, input.eventId), eq(vote.projectId, current.projectId)), + ); + ctx.metrics.coverage.observe({ event_id: input.eventId }, looks?.n ?? 1); + } + return outcome; + } catch (error) { + asTrpc(error); + } + }), + rubric: authed + .input(z.object({ eventId: uuid })) + .query(async ({ ctx, input }) => { + await requireJudge(ctx, input.eventId); + const [rub] = await ctx.db + .select({ id: rubric.id }) + .from(rubric) + .where(and(eq(rubric.eventId, input.eventId), eq(rubric.isDefault, true))); + if (!rub) return []; + return ctx.db + .select({ + id: criterion.id, + label: criterion.label, + min: criterion.min, + max: criterion.max, + anchors: criterion.anchors, + }) + .from(criterion) + .where(eq(criterion.rubricId, rub.id)); + }), + compare: authed + .input(z.object({ eventId: uuid, outcome: z.enum(["a", "b", "tie"]) })) + .mutation(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + const outcome = await castComparison(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + outcome: input.outcome, + now: ctx.now, + }); + tell(ctx, input.eventId, row.id, "comparison.cast"); + return outcome; + } catch (error) { + asTrpc(error); + } + }), + skip: authed + .input(z.object({ eventId: uuid, visitId: uuid })) + .mutation(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + return await skipVisit(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + visitId: input.visitId, + now: ctx.now, + }); + } catch (error) { + asTrpc(error); + } + }), + progress: authed + .input(z.object({ eventId: uuid })) + .query(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + return await judgeProgress(ctx.db, { eventId: input.eventId, judgeId: row.id }); + } catch (error) { + asTrpc(error); + } + }), + status: authed + .input(z.object({ eventId: uuid, visitId: uuid })) + .query(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + return await visitStatus(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + visitId: input.visitId, + }); + } catch (error) { + asTrpc(error); + } + }), + }), + event: t.router({ + setPhase: atLeast("organizer") + .input(z.object({ eventId: uuid, phase: z.enum([ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", + ]) })) + .mutation(async ({ ctx, input }) => { + const [current] = await ctx.db + .select({ phase: event.phase, id: event.id }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!current) throw new TRPCError({ code: "NOT_FOUND" }); + try { + assertTransition(current.phase, input.phase); + } catch (error) { + asTrpc(error); + } + await ctx.db.transaction(async (tx) => { + await tx + .update(event) + .set({ phase: input.phase }) + .where(eq(event.id, input.eventId)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "phase.changed", + actor: { email: ctx.actor.email }, + subject: { eventId: input.eventId }, + payload: { from: current.phase, to: input.phase }, + }); + }); + tell(ctx, input.eventId, null, "phase.changed"); + return { phase: input.phase }; + }), + }), + project: t.router({ + upsert: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + externalId: z.string().min(1), + name: z.string().min(1), + teamName: z.string().nullable().default(null), + tableNumber: z.number().int().nullable().default(null), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await upsertProject(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + withdraw: atLeast("organizer") + .input(z.object({ eventId: uuid, projectId: uuid })) + .mutation(async ({ ctx, input }) => { + try { + await withdrawProject(ctx.db, { ...input, now: ctx.now, actorEmail: ctx.actor.email }); + return { ok: true }; + } catch (error) { + asTrpc(error); + } + }), + rotateQr: atLeast("organizer") + .input(z.object({ eventId: uuid, projectId: uuid })) + .mutation(async ({ ctx, input }) => { + try { + return await rotateQr(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + assignTables: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .mutation(async ({ ctx, input }) => { + return assignEventTables(ctx.db, { + eventId: input.eventId, + actorEmail: ctx.actor.email, + }); + }), + }), + judge: t.router({ + applyToEvent: authed + .input(z.object({ eventId: uuid, name: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + try { + return await applyAsJudge(ctx.db, { + eventId: input.eventId, + email: ctx.actor.email, + name: input.name, + }); + } catch (error) { + asTrpc(error); + } + }), + upsert: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + email: z.string().email(), + name: z.string().min(1), + externalId: z.string().nullable().default(null), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await upsertJudge(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + assign: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + judgeId: uuid, + trackId: uuid.nullable(), + zoneId: uuid.nullable(), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await assignJudge(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + setStatus: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + judgeId: uuid, + status: z.enum(["approved", "suspended"]), + }), + ) + .mutation(async ({ ctx, input }) => { + await setJudgeStatus(ctx.db, { ...input, actorEmail: ctx.actor.email }); + return { ok: true }; + }), + recall: atLeast("organizer") + .input(z.object({ eventId: uuid, judgeId: uuid })) + .mutation(async ({ ctx, input }) => { + await recallJudge(ctx.db, { ...input, actorEmail: ctx.actor.email }); + tell(ctx, input.eventId, input.judgeId, "judge.recalled"); + return { ok: true }; + }), + voidVisit: atLeast("organizer") + .input(z.object({ eventId: uuid, visitId: uuid, reason: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + try { + const voided = await voidVisit(ctx.db, { ...input, now: ctx.now, actorEmail: ctx.actor.email }); + tell(ctx, input.eventId, voided.judgeId, "visit.voided", input.visitId); + return { ok: true }; + } catch (error) { + asTrpc(error); + } + }), + }), + results: t.router({ + compute: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .mutation(async ({ ctx, input }) => { + try { + return await computeResults(ctx.db, { + eventId: input.eventId, + actorEmail: ctx.actor.email, + now: ctx.now, + }); + } catch (error) { + asTrpc(error); + } + }), + publish: atLeast("organizer") + .input(z.object({ eventId: uuid, runId: uuid })) + .mutation(async ({ ctx, input }) => { + try { + const outcome = await publishResults(ctx.db, { + ...input, + actorEmail: ctx.actor.email, + now: ctx.now, + }); + tell(ctx, input.eventId, null, "results.published"); + // Nothing drains the queue on a timer, and publish is the one step + // every event takes. Webhooks go out here; rows nobody listens for + // are cleared. A failed delivery stays queued for `panel outbox drain`. + for (let batch = 0; batch < 20; batch += 1) { + const delivered = await drainOutbox(ctx.db).catch(() => 0); + if (delivered === 0) break; + } + return outcome; + } catch (error) { + asTrpc(error); + } + }), + inspect: atLeast("organizer") + .input(z.object({ runId: uuid })) + .query(({ ctx, input }) => inspectRun(ctx.db, input.runId)), + diff: atLeast("organizer") + .input(z.object({ runA: uuid, runB: uuid })) + .query(async ({ ctx, input }) => { + const [left, right] = await Promise.all([ + placementsForRun(ctx.db, input.runA), + placementsForRun(ctx.db, input.runB), + ]); + return diffPlacements(left, right); + }), + unpublish: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .mutation(async ({ ctx, input }) => { + try { + const outcome = await unpublishResults(ctx.db, { + eventId: input.eventId, + actorEmail: ctx.actor.email, + }); + tell(ctx, input.eventId, null, "results.unpublished"); + return outcome; + } catch (error) { + asTrpc(error); + } + }), + export: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => publishedPlacements(ctx.db, input.eventId)), + }), + catalog: t.router({ + tracks: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => listTracks(ctx.db, input.eventId)), + projects: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => listProjects(ctx.db, input.eventId)), + logs: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => listLogs(ctx.db, input.eventId)), + tables: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => listTables(ctx.db, input.eventId)), + judges: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => listJudges(ctx.db, input.eventId)), + saveTrack: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + slug: z.string().min(1), + name: z.string().min(1), + kind: z.enum(["main", "sponsor", "special"]), + judgeGroup: z.string().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await saveTrack(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + savePrize: atLeast("organizer") + .input( + z.object({ + trackId: uuid, + place: z.number().int(), + title: z.string().min(1), + amount: z.number().nullable().default(null), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await savePrize(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + saveZone: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + name: z.string().min(1), + position: z.number().int().default(0), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await saveZone(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + placeTable: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + number: z.number().int(), + zoneId: uuid.nullable().default(null), + x: z.number().nullable().default(null), + y: z.number().nullable().default(null), + }), + ) + .mutation(async ({ ctx, input }) => { + await placeTable(ctx.db, { ...input, actorEmail: ctx.actor.email }); + return { ok: true }; + }), + saveRubric: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + name: z.string().min(1), + isDefault: z.boolean().default(false), + criteria: z.array( + z.object({ + key: z.string().min(1), + label: z.string().min(1), + min: z.number(), + max: z.number(), + weight: z.number(), + }), + ), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await saveRubric(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + saveWebhook: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + url: z.string().url(), + secret: z.string().min(8), + topics: z.array(z.string()).min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await saveWebhook(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + issueApiKey: atLeast("admin") + .input(z.object({ eventId: uuid, scopes: z.array(z.string()).min(1) })) + .mutation(async ({ ctx, input }) => { + try { + return await issueApiKey(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + saveConfig: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + hardLimitSeconds: z.number().int().positive(), + dispatchStrategy: z.enum(["coverage", "uncertainty"]), + pairwiseEnabled: z.boolean(), + pairwiseWeight: z.number().min(0).max(1), + leaderboardPublic: z.boolean(), + boardPollSeconds: z.number().int().positive(), + }), + ) + .mutation(async ({ ctx, input }) => { + await saveConfig(ctx.db, { ...input, actorEmail: ctx.actor.email }); + return { ok: true }; + }), + }), +}); + +export type AppRouter = typeof appRouter; diff --git a/packages/judging-server/src/services/arrive.ts b/packages/judging-server/src/services/arrive.ts new file mode 100644 index 00000000..616e98f7 --- /dev/null +++ b/packages/judging-server/src/services/arrive.ts @@ -0,0 +1,76 @@ +import { and, eq, isNull } from "drizzle-orm"; +import type { PanelDb } from "@query/judging-db"; +import { event, eventLog, outbox, project, visit } from "@query/judging-db"; +import { phaseAllows } from "@query/judging-core"; + +export async function arrive( + db: PanelDb, + input: { + eventId: string; + judgeId: string; + qrToken?: string; + tableNumber?: number; + now: Date; + }, +): Promise<{ visitId: string; projectId: string }> { + return db.transaction(async (tx) => { + const [evt] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + if (!phaseAllows(evt.phase, "dispatch")) { + throw new Error(`dispatch is not allowed while the event is ${evt.phase}`); + } + + const [row] = await tx + .select() + .from(visit) + .where( + and( + eq(visit.judgeId, input.judgeId), + eq(visit.eventId, input.eventId), + isNull(visit.completedAt), + isNull(visit.voidedAt), + ), + ); + if (!row) throw new Error("No table is in hand"); + + const [current] = await tx + .select() + .from(project) + .where(eq(project.id, row.projectId)); + if (!current) throw new Error("Project not found"); + + const qrOk = input.qrToken !== undefined && input.qrToken === current.qrToken; + const tableOk = + input.tableNumber !== undefined && input.tableNumber === current.tableNumber; + if (!qrOk && !tableOk) throw new Error("That is not the table you were sent to"); + + if (row.arrivedAt) return { visitId: row.id, projectId: row.projectId }; + + await tx + .update(visit) + .set({ arrivedAt: input.now }) + .where(eq(visit.id, row.id)); + if (!current.arrivedFirstAt) { + await tx + .update(project) + .set({ arrivedFirstAt: input.now }) + .where(eq(project.id, current.id)); + } + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "visit.arrived", + actor: { judgeId: input.judgeId }, + subject: { visitId: row.id, projectId: row.projectId }, + payload: {}, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "visit.arrived", + payload: { visitId: row.id, judgeId: input.judgeId }, + }); + return { visitId: row.id, projectId: row.projectId }; + }); +} diff --git a/packages/judging-server/src/services/catalog.ts b/packages/judging-server/src/services/catalog.ts new file mode 100644 index 00000000..8f1e3996 --- /dev/null +++ b/packages/judging-server/src/services/catalog.ts @@ -0,0 +1,454 @@ +import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; +import type { PanelDb } from "@query/judging-db"; +import { + apiKey, + criterion, + event, + eventConfig, + eventLog, + floorTable, + judge, + organization, + prize, + project, + projectTrack, + result, + resultRun, + rubric, + track, + vote, + voteScore, + webhook, + zone, +} from "@query/judging-db"; +import { randomBytes } from "node:crypto"; +import type { Actor } from "../auth"; +import { hashApiKey, roleForScopes } from "../auth"; +import { feedbackStatus, median } from "./feedback"; + +async function placementsForProject(db: PanelDb, projectId: string) { + const links = await db + .select({ trackId: track.id, track: track.name }) + .from(projectTrack) + .innerJoin(track, eq(track.id, projectTrack.trackId)) + .where(eq(projectTrack.projectId, projectId)); + const placed = await db + .select({ trackId: result.trackId, placement: result.placement }) + .from(result) + .innerJoin(resultRun, eq(resultRun.id, result.runId)) + .where(and(eq(result.projectId, projectId), isNotNull(resultRun.publishedAt))); + const placeOf = new Map(placed.map((row) => [row.trackId, row.placement])); + return links.map((link) => ({ + track: link.track, + placement: placeOf.get(link.trackId) ?? null, + })); +} + +/** The published card token for a project the club already knows by id. */ +export async function feedbackTokenForExternal(db: PanelDb, eventId: string, externalId: string) { + const [row] = await db + .select({ token: project.feedbackToken, phase: event.phase }) + .from(project) + .innerJoin(event, eq(event.id, project.eventId)) + .where(and(eq(project.eventId, eventId), eq(project.externalId, externalId))); + if (!row?.token || row.phase !== "published") return null; + return row.token; +} + +export async function feedbackCard(db: PanelDb, token: string) { + const [row] = await db + .select({ + projectId: project.id, + name: project.name, + eventId: project.eventId, + phase: event.phase, + stored: project.feedbackToken, + }) + .from(project) + .innerJoin(event, eq(event.id, project.eventId)) + .where(eq(project.feedbackToken, token)); + if ( + feedbackStatus({ + published: row?.phase === "published", + tokenMatches: row?.stored === token, + }) === "forbidden" || + !row + ) { + return { status: "forbidden" as const }; + } + + const scores = await db + .select({ + projectId: vote.projectId, + criterionId: voteScore.criterionId, + label: criterion.label, + value: voteScore.value, + }) + .from(voteScore) + .innerJoin(vote, eq(vote.id, voteScore.voteId)) + .innerJoin(criterion, eq(criterion.id, voteScore.criterionId)) + .where(eq(vote.eventId, row.eventId)); + + const byCriterion = new Map(); + for (const score of scores) { + const bucket = byCriterion.get(score.criterionId) ?? { + label: score.label, + mine: [], + all: [], + }; + bucket.all.push(score.value); + if (score.projectId === row.projectId) bucket.mine.push(score.value); + byCriterion.set(score.criterionId, bucket); + } + + return { + status: "ok" as const, + name: row.name, + criteria: [...byCriterion.values()].map((bucket) => ({ + label: bucket.label, + mean: + bucket.mine.length === 0 + ? 0 + : bucket.mine.reduce((sum, value) => sum + value, 0) / bucket.mine.length, + median: median(bucket.all), + })), + places: await placementsForProject(db, row.projectId), + comments: ( + await db + .select({ comment: vote.comment }) + .from(vote) + .where(and(eq(vote.projectId, row.projectId), isNotNull(vote.comment))) + ) + .map((item) => item.comment) + .filter((comment): comment is string => Boolean(comment)), + }; +} + +export async function saveTrack( + db: PanelDb, + input: { + eventId: string; + slug: string; + name: string; + kind: "main" | "sponsor" | "special"; + judgeGroup: string; + actorEmail: string; + }, +) { + const [created] = await db + .insert(track) + .values(input) + .returning({ id: track.id }); + if (!created) throw new Error("Track was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "track.saved", + actor: { email: input.actorEmail }, + subject: { trackId: created.id }, + payload: { slug: input.slug }, + }); + return created; +} + +export async function listTracks(db: PanelDb, eventId: string) { + return db.select().from(track).where(eq(track.eventId, eventId)); +} + +export async function savePrize( + db: PanelDb, + input: { + trackId: string; + place: number; + title: string; + amount: number | null; + actorEmail: string; + }, +) { + const [created] = await db + .insert(prize) + .values({ + trackId: input.trackId, + place: input.place, + title: input.title, + amount: input.amount, + }) + .returning({ id: prize.id }); + if (!created) throw new Error("Prize was not stored"); + const [owner] = await db + .select({ eventId: track.eventId }) + .from(track) + .where(eq(track.id, input.trackId)); + if (owner) { + await db.insert(eventLog).values({ + eventId: owner.eventId, + kind: "prize.saved", + actor: { email: input.actorEmail }, + subject: { prizeId: created.id, trackId: input.trackId }, + payload: { title: input.title, place: input.place }, + }); + } + return created; +} + +export async function saveZone( + db: PanelDb, + input: { eventId: string; name: string; position: number; actorEmail: string }, +) { + const [created] = await db + .insert(zone) + .values({ eventId: input.eventId, name: input.name, position: input.position }) + .returning({ id: zone.id }); + if (!created) throw new Error("Zone was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "zone.saved", + actor: { email: input.actorEmail }, + subject: { zoneId: created.id }, + payload: { name: input.name }, + }); + return created; +} + +export async function placeTable( + db: PanelDb, + input: { + eventId: string; + number: number; + zoneId: string | null; + x: number | null; + y: number | null; + actorEmail: string; + }, +) { + await db + .insert(floorTable) + .values(input) + .onConflictDoUpdate({ + target: [floorTable.eventId, floorTable.number], + set: { zoneId: input.zoneId, x: input.x, y: input.y }, + }); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "table.placed", + actor: { email: input.actorEmail }, + subject: { number: input.number }, + payload: { x: input.x, y: input.y }, + }); +} + +export async function saveRubric( + db: PanelDb, + input: { + eventId: string; + name: string; + isDefault: boolean; + criteria: { key: string; label: string; min: number; max: number; weight: number }[]; + actorEmail: string; + }, +) { + const [created] = await db + .insert(rubric) + .values({ + eventId: input.eventId, + name: input.name, + isDefault: input.isDefault, + }) + .returning({ id: rubric.id }); + if (!created) throw new Error("Rubric was not stored"); + if (input.criteria.length > 0) { + await db.insert(criterion).values( + input.criteria.map((item, position) => ({ + rubricId: created.id, + position, + key: item.key, + label: item.label, + min: item.min, + max: item.max, + weight: item.weight, + })), + ); + } + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "rubric.saved", + actor: { email: input.actorEmail }, + subject: { rubricId: created.id }, + payload: {}, + }); + return created; +} + +export async function saveWebhook( + db: PanelDb, + input: { + eventId: string; + url: string; + secret: string; + topics: string[]; + actorEmail: string; + }, +) { + const [evt] = await db + .select({ orgId: event.orgId }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + const [created] = await db + .insert(webhook) + .values({ + orgId: evt.orgId, + url: input.url, + secret: input.secret, + topics: input.topics, + }) + .returning({ id: webhook.id }); + if (!created) throw new Error("Webhook was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "webhook.saved", + actor: { email: input.actorEmail }, + subject: { webhookId: created.id }, + payload: { url: input.url }, + }); + return created; +} + +export async function issueApiKey( + db: PanelDb, + input: { eventId: string; scopes: string[]; actorEmail: string }, +) { + const [evt] = await db + .select({ orgId: organization.id }) + .from(event) + .innerJoin(organization, eq(organization.id, event.orgId)) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + const token = randomBytes(24).toString("hex"); + const [created] = await db + .insert(apiKey) + .values({ + orgId: evt.orgId, + hashedKey: hashApiKey(token), + scopes: input.scopes, + }) + .returning({ id: apiKey.id }); + if (!created) throw new Error("API key was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "api_key.created", + actor: { email: input.actorEmail }, + subject: { apiKeyId: created.id }, + payload: {}, + }); + return { id: created.id, token }; +} + +export async function actorForApiKey(db: PanelDb, token: string): Promise { + const [row] = await db + .select() + .from(apiKey) + .where(and(eq(apiKey.hashedKey, hashApiKey(token)), isNull(apiKey.revokedAt))); + if (!row) return null; + const granted = roleForScopes(row.scopes); + if (!granted) return null; + const [org] = await db + .select({ slug: organization.slug }) + .from(organization) + .where(eq(organization.id, row.orgId)); + if (!org) return null; + return { + sub: row.id, + email: `key:${row.id}`, + name: "API key", + org: org.slug, + role: granted, + judgeExternalId: null, + }; +} + +export async function listTables(db: PanelDb, eventId: string) { + return db + .select({ + number: floorTable.number, + zoneId: floorTable.zoneId, + x: floorTable.x, + y: floorTable.y, + }) + .from(floorTable) + .where(eq(floorTable.eventId, eventId)); +} + +export async function listJudges(db: PanelDb, eventId: string) { + return db + .select({ + id: judge.id, + name: judge.name, + email: judge.email, + status: judge.status, + }) + .from(judge) + .where(eq(judge.eventId, eventId)); +} + +export async function listProjects(db: PanelDb, eventId: string) { + return db + .select({ + id: project.id, + name: project.name, + tableNumber: project.tableNumber, + zoneId: project.zoneId, + zoneName: zone.name, + }) + .from(project) + .leftJoin(zone, eq(zone.id, project.zoneId)) + .where(eq(project.eventId, eventId)); +} + +export async function listLogs(db: PanelDb, eventId: string) { + return db + .select({ + id: eventLog.id, + kind: eventLog.kind, + createdAt: eventLog.createdAt, + }) + .from(eventLog) + .where(eq(eventLog.eventId, eventId)) + .orderBy(desc(eventLog.createdAt)) + .limit(40); +} + +export async function saveConfig( + db: PanelDb, + input: { + eventId: string; + hardLimitSeconds: number; + dispatchStrategy: "coverage" | "uncertainty"; + pairwiseEnabled: boolean; + pairwiseWeight: number; + leaderboardPublic: boolean; + boardPollSeconds: number; + actorEmail: string; + }, +) { + await db + .update(eventConfig) + .set({ + hardLimitSeconds: input.hardLimitSeconds, + dispatchStrategy: input.dispatchStrategy, + pairwiseEnabled: input.pairwiseEnabled, + pairwiseWeight: input.pairwiseWeight, + leaderboardPublic: input.leaderboardPublic, + boardPollSeconds: input.boardPollSeconds, + }) + .where(eq(eventConfig.eventId, input.eventId)); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "config.saved", + actor: { email: input.actorEmail }, + subject: { eventId: input.eventId }, + payload: { + dispatchStrategy: input.dispatchStrategy, + hardLimitSeconds: input.hardLimitSeconds, + }, + }); +} diff --git a/packages/judging-server/src/services/compare.ts b/packages/judging-server/src/services/compare.ts new file mode 100644 index 00000000..f1220ce7 --- /dev/null +++ b/packages/judging-server/src/services/compare.ts @@ -0,0 +1,63 @@ +import { and, desc, eq, isNull } from "drizzle-orm"; +import { phaseAllows } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; +import { comparison, event, eventLog, visit } from "@query/judging-db"; + +/** The judge's previous table against the one they are at. */ +export async function castComparison( + db: PanelDb, + input: { + eventId: string; + judgeId: string; + outcome: "a" | "b" | "tie"; + now: Date; + }, +) { + const [evt] = await db + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + if (!phaseAllows(evt.phase, "vote")) { + throw new Error(`vote is not allowed while the event is ${evt.phase}`); + } + + const visits = await db + .select() + .from(visit) + .where( + and( + eq(visit.eventId, input.eventId), + eq(visit.judgeId, input.judgeId), + isNull(visit.voidedAt), + ), + ) + .orderBy(desc(visit.handedOutAt)); + const current = visits.find((row) => !row.completedAt); + const earlier = visits.find((row) => row.completedAt); + if (!current) throw new Error("No table is in hand"); + if (!earlier) throw new Error("Compare after a finished table"); + + const [row] = await db + .insert(comparison) + .values({ + eventId: input.eventId, + judgeId: input.judgeId, + judgeGroup: current.judgeGroup, + aProjectId: earlier.projectId, + bProjectId: current.projectId, + outcome: input.outcome, + createdAt: input.now, + }) + .returning({ id: comparison.id }); + if (!row) throw new Error("Comparison was not stored"); + + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "comparison.cast", + actor: { judgeId: input.judgeId }, + subject: { comparisonId: row.id }, + payload: { outcome: input.outcome }, + }); + return { id: row.id }; +} diff --git a/packages/judging-server/src/services/dispatch.concurrent.test.ts b/packages/judging-server/src/services/dispatch.concurrent.test.ts new file mode 100644 index 00000000..e39c0584 --- /dev/null +++ b/packages/judging-server/src/services/dispatch.concurrent.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { dispatchNext } from "./dispatch"; +import { createDb, databaseUrl, migrate } from "@query/judging-db"; + +const url = databaseUrl(); + +describe.skipIf(!url)("concurrent dispatch", () => { + it("does not hand two judges the same project", async () => { + const { db, pool } = createDb(url as string); + try { + await migrate(pool); + const { seedDemo, judge, project } = await import("@query/judging-db"); + const { eq } = await import("drizzle-orm"); + const { eventId } = await seedDemo(db); + const judges = await db.select().from(judge).where(eq(judge.eventId, eventId)); + const first = judges[0]; + const second = judges[1]; + if (!first || !second) throw new Error("demo judges missing"); + const now = new Date(); + const [a, b] = await Promise.all([ + dispatchNext(db, { eventId, judgeId: first.id, now }), + dispatchNext(db, { eventId, judgeId: second.id, now }), + ]); + expect(a.done).toBe(false); + expect(b.done).toBe(false); + if (!a.done && !b.done) expect(a.projectId).not.toBe(b.projectId); + const rows = await db.select().from(project).where(eq(project.eventId, eventId)); + expect(rows.length).toBeGreaterThan(1); + } finally { + await pool.end(); + } + }); +}); diff --git a/packages/judging-server/src/services/dispatch.ts b/packages/judging-server/src/services/dispatch.ts new file mode 100644 index 00000000..97d21031 --- /dev/null +++ b/packages/judging-server/src/services/dispatch.ts @@ -0,0 +1,455 @@ +import { randomInt } from "node:crypto"; +import { and, desc, eq, gte, isNull, sql } from "drizzle-orm"; +import { isLive, pickNext } from "@query/judging-core"; +import type { TimerConfig } from "@query/judging-core"; +import type { PanelDb, PanelTx } from "@query/judging-db"; +import { + event, + eventConfig, + eventLog, + judge, + outbox, + project, + projectTrack, + track, + visit, + vote, +} from "@query/judging-db"; +import { isDeliveredTopic, logsForHandout } from "./logs"; + +export type DispatchOutcome = + | { done: true } + | { + done: false; + reused: boolean; + visitId: string; + projectId: string; + projectName: string; + tableNumber: number | null; + zoneId: string | null; + handedOutAt: Date; + arrivedAt: Date | null; + }; + +type VisitRow = typeof visit.$inferSelect; +type ProjectRow = typeof project.$inferSelect; + +export async function dispatchNext( + db: PanelDb, + input: { eventId: string; judgeId: string; now: Date }, +): Promise { + return db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${`dispatch:${input.eventId}`}))`, + ); + + const phase = await loadPhase(tx, input.eventId); + if (phase !== "judging_live") { + throw new Error(`dispatch is not allowed while the event is ${phase}`); + } + const config = await loadConfig(tx, input.eventId); + const judgeRow = await loadJudge(tx, input.judgeId, input.eventId); + if (judgeRow.status !== "approved") { + throw new Error("This judge is not approved"); + } + + const projects = await loadPool(tx, input.eventId, judgeRow.trackId); + const poolIds = new Set(projects.map((item) => item.id)); + const mine = await tx + .select() + .from(visit) + .where( + and(eq(visit.judgeId, input.judgeId), eq(visit.eventId, input.eventId)), + ); + + const lapsed = mine.filter( + (row) => + !row.completedAt && + !row.voidedAt && + (row.handedOutAt || row.arrivedAt) && + (!isLive(clock(row), input.now, config) || !poolIds.has(row.projectId)), + ); + if (lapsed.length > 0) { + for (const row of lapsed) { + await tx + .update(visit) + .set({ + voidedAt: input.now, + voidReason: poolIds.has(row.projectId) ? "lapsed" : "withdrawn", + }) + .where(eq(visit.id, row.id)); + } + } + + const open = mine.find( + (row) => + !lapsed.includes(row) && + !row.completedAt && + !row.voidedAt && + isLive(clock(row), input.now, config), + ); + if (open) { + const current = projects.find((item) => item.id === open.projectId); + return { + done: false as const, + reused: true, + visitId: open.id, + projectId: open.projectId, + projectName: current?.name ?? "", + tableNumber: current?.tableNumber ?? null, + zoneId: current?.zoneId ?? null, + handedOutAt: open.handedOutAt, + arrivedAt: open.arrivedAt, + }; + } + + const seen = new Set( + mine.filter((row) => !row.voidedAt).map((row) => row.projectId), + ); + for (const row of lapsed) seen.add(row.projectId); + const unseen = projects.filter((item) => !seen.has(item.id)); + const myGroup = await groupOf(tx, judgeRow.trackId); + if (unseen.length === 0) { + await record( + tx, + input, + lapsed.map((row) => row.id), + null, + ); + return { done: true as const }; + } + + const picked = await choose( + tx, + input, + unseen, + mine, + projects, + myGroup, + config, + ); + const choice = picked ? unseen.find((item) => item.id === picked.id) : undefined; + if (!choice) { + await record( + tx, + input, + lapsed.map((row) => row.id), + null, + ); + return { done: true as const }; + } + + const [created] = await tx + .insert(visit) + .values({ + eventId: input.eventId, + judgeId: input.judgeId, + projectId: choice.id, + judgeGroup: myGroup, + handedOutAt: input.now, + }) + .returning(); + if (!created) throw new Error("visit was not created"); + + await record(tx, input, lapsed.map((row) => row.id), { + visitId: created.id, + projectId: choice.id, + }); + + return { + done: false as const, + reused: false, + visitId: created.id, + projectId: choice.id, + projectName: choice.name, + tableNumber: choice.tableNumber, + zoneId: choice.zoneId, + handedOutAt: created.handedOutAt, + arrivedAt: null, + }; + }); +} + +async function record( + tx: PanelTx, + input: { eventId: string; judgeId: string }, + voidedVisitIds: readonly string[], + handout: { visitId: string; projectId: string } | null, +) { + const drafts = handout + ? logsForHandout(voidedVisitIds) + : voidedVisitIds.map((visitId) => ({ + kind: "visit.voided" as const, + subject: { visitId } as Record, + payload: {} as Record, + })); + if (handout) { + const handed = drafts[drafts.length - 1]; + if (handed) handed.subject = handout; + } + if (drafts.length === 0) return; + + await tx.insert(eventLog).values( + drafts.map((draft) => ({ + eventId: input.eventId, + kind: draft.kind, + actor: { judgeId: input.judgeId }, + subject: draft.subject, + payload: draft.payload, + })), + ); + const topics = drafts.filter((draft) => isDeliveredTopic(draft.kind)); + if (topics.length === 0) return; + await tx.insert(outbox).values( + topics.map((draft) => ({ + eventId: input.eventId, + topic: draft.kind, + payload: { ...draft.subject, judgeId: input.judgeId }, + })), + ); +} + +function clock(row: VisitRow) { + return { + handedOutAt: row.handedOutAt, + arrivedAt: row.arrivedAt, + completedAt: row.completedAt ?? row.voidedAt, + }; +} + +async function loadPhase(tx: PanelDb | PanelTx, eventId: string) { + const [row] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, eventId)); + if (!row) throw new Error("Event not found"); + return row.phase; +} + +async function loadConfig(tx: PanelTx, eventId: string): Promise { + const [row] = await tx + .select() + .from(eventConfig) + .where(eq(eventConfig.eventId, eventId)); + if (!row) throw new Error("Event config not found"); + return { + targetSeconds: row.targetSeconds, + hardLimitSeconds: row.hardLimitSeconds, + walkLimitSeconds: row.walkLimitSeconds, + submitGraceSeconds: row.submitGraceSeconds, + strategy: row.dispatchStrategy, + minLooksPerProject: row.minLooksPerProject, + }; +} + +async function loadJudge(tx: PanelDb | PanelTx, judgeId: string, eventId: string) { + const [row] = await tx + .select() + .from(judge) + .where(and(eq(judge.id, judgeId), eq(judge.eventId, eventId))); + if (!row) throw new Error("Judge not found"); + return row; +} + +async function loadPool(tx: PanelDb | PanelTx, eventId: string, trackId: string | null) { + const projects = await tx + .select() + .from(project) + .where(and(eq(project.eventId, eventId), isNull(project.withdrawnAt))); + if (!trackId) return projects; + const links = await tx + .select({ projectId: projectTrack.projectId }) + .from(projectTrack) + .where(eq(projectTrack.trackId, trackId)); + const allowed = new Set(links.map((link) => link.projectId)); + return projects.filter((item) => allowed.has(item.id)); +} + +async function groupOf(tx: PanelTx, trackId: string | null) { + if (!trackId) return "main"; + const [row] = await tx + .select({ judgeGroup: track.judgeGroup }) + .from(track) + .where(eq(track.id, trackId)); + return row?.judgeGroup ?? "main"; +} + +async function choose( + tx: PanelTx, + input: { eventId: string; judgeId: string; now: Date }, + unseen: ProjectRow[], + mine: VisitRow[], + projects: ProjectRow[], + myGroup: string, + config: TimerConfig & { + strategy: "coverage" | "uncertainty"; + minLooksPerProject: number; + }, +) { + const judges = await tx + .select({ id: judge.id, trackId: judge.trackId }) + .from(judge) + .where(eq(judge.eventId, input.eventId)); + const tracks = await tx + .select({ id: track.id, judgeGroup: track.judgeGroup }) + .from(track) + .where(eq(track.eventId, input.eventId)); + const groupByJudge = new Map( + judges.map((item) => [ + item.id, + tracks.find((entry) => entry.id === item.trackId)?.judgeGroup ?? "main", + ]), + ); + + const coverage = new Map(); + const bump = (projectId: string) => + coverage.set(projectId, (coverage.get(projectId) ?? 0) + 1); + + const votes = await tx + .select({ judgeId: vote.judgeId, projectId: vote.projectId }) + .from(vote) + .where(eq(vote.eventId, input.eventId)); + for (const row of votes) { + if (groupByJudge.get(row.judgeId) === myGroup) bump(row.projectId); + } + + const open = await tx + .select() + .from(visit) + .where(and(eq(visit.eventId, input.eventId), isNull(visit.completedAt), isNull(visit.voidedAt))); + const claimedAt = new Map(); + for (const row of open) { + if (row.judgeId === input.judgeId) continue; + if (!isLive(clock(row), input.now, config)) continue; + if (groupByJudge.get(row.judgeId) !== myGroup) continue; + bump(row.projectId); + const at = (row.arrivedAt ?? row.handedOutAt).getTime(); + claimedAt.set(row.projectId, Math.max(claimedAt.get(row.projectId) ?? 0, at)); + } + + const byId = new Map(projects.map((item) => [item.id, item])); + const last = [...mine].sort( + (a, b) => b.handedOutAt.getTime() - a.handedOutAt.getTime(), + )[0]; + const lastProject = last ? byId.get(last.projectId) : undefined; + + return pickNext( + unseen.map((item) => ({ + id: item.id, + tableNumber: item.tableNumber ?? 0, + zoneId: item.zoneId, + coverage: coverage.get(item.id) ?? 0, + claimedAt: claimedAt.get(item.id) ?? null, + })), + { + lastTable: lastProject?.tableNumber ?? null, + zoneId: lastProject?.zoneId ?? null, + }, + { + strategy: config.strategy, + minLooksPerProject: config.minLooksPerProject, + }, + (n) => randomInt(0, n), + ); +} + +/** Pass on the open table without a score. It stays seen, so this judge is not sent back. */ +export async function skipVisit( + db: PanelDb, + input: { eventId: string; judgeId: string; visitId: string; now: Date }, +) { + return db.transaction(async (tx) => { + const phase = await loadPhase(tx, input.eventId); + if (phase !== "judging_live") { + throw new Error(`skip is not allowed while the event is ${phase}`); + } + const [row] = await tx + .select() + .from(visit) + .where( + and( + eq(visit.id, input.visitId), + eq(visit.judgeId, input.judgeId), + eq(visit.eventId, input.eventId), + ), + ); + if (!row || row.voidedAt || row.completedAt) { + throw new Error("This visit cannot be skipped"); + } + await tx.update(visit).set({ completedAt: input.now }).where(eq(visit.id, row.id)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "visit.skipped", + actor: { judgeId: input.judgeId }, + subject: { visitId: row.id, projectId: row.projectId }, + payload: {}, + }); + return { projectId: row.projectId }; + }); +} + +/** + * Whether the judge's open visit still stands: an organizer may have voided + * it, or called the judge back to the desk since it was handed out. The + * recall lookup reads only log rows written since the hand-out + * (event_log_event_idx), so a phone can ask while it walks. + */ +export async function visitStatus( + db: PanelDb, + input: { eventId: string; judgeId: string; visitId: string }, +) { + const [row] = await db + .select({ handedOutAt: visit.handedOutAt, voidedAt: visit.voidedAt }) + .from(visit) + .where( + and( + eq(visit.id, input.visitId), + eq(visit.eventId, input.eventId), + eq(visit.judgeId, input.judgeId), + ), + ); + if (!row) throw new Error("Visit not found"); + const [recall] = await db + .select({ at: eventLog.createdAt }) + .from(eventLog) + .where( + and( + eq(eventLog.eventId, input.eventId), + gte(eventLog.createdAt, row.handedOutAt), + eq(eventLog.kind, "judge.recalled"), + sql`${eventLog.subject}->>'judgeId' = ${input.judgeId}`, + ), + ) + .orderBy(desc(eventLog.createdAt)) + .limit(1); + // A recall does not void the visit, so the same visit can come back from + // dispatch. The time lets the phone act once per recall, not once per poll. + return { + voided: row.voidedAt !== null, + recalledAt: recall ? recall.at.toISOString() : null, + }; +} + +export async function judgeProgress( + db: PanelDb, + input: { eventId: string; judgeId: string }, +) { + const person = await loadJudge(db, input.judgeId, input.eventId); + if (person.status !== "approved") throw new Error("This judge is not approved"); + const pool = await loadPool(db, input.eventId, person.trackId); + const [scored] = await db + .select({ n: sql`count(*)::int` }) + .from(vote) + .where(and(eq(vote.judgeId, input.judgeId), eq(vote.eventId, input.eventId))); + const total = pool.length; + const completed = scored?.n ?? 0; + return { + judgeId: input.judgeId, + total, + completed, + percentage: total > 0 ? Math.round((completed / total) * 100) : 0, + }; +} diff --git a/packages/judging-server/src/services/event.integration.test.ts b/packages/judging-server/src/services/event.integration.test.ts new file mode 100644 index 00000000..46c2aea4 --- /dev/null +++ b/packages/judging-server/src/services/event.integration.test.ts @@ -0,0 +1,38 @@ +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { createDb, criterion, databaseUrl, event, migrate, rubric } from "@query/judging-db"; +import { ensureEvent } from "./event"; + +const url = databaseUrl(); + +describe.skipIf(!url)("ensureEvent", () => { + it("creates an edition's event once, with the default rubric", async () => { + const { db, pool } = createDb(url as string); + try { + await migrate(pool); + const input = { + orgSlug: "test-org", + orgName: "Test", + eventSlug: randomUUID(), + name: "Test edition", + }; + const first = await ensureEvent(db, input); + const again = await ensureEvent(db, input); + expect(again.eventId).toBe(first.eventId); + + const [row] = await db.select().from(event).where(eq(event.id, first.eventId)); + expect(row?.phase).toBe("setup"); + const criteria = await db + .select({ key: criterion.key }) + .from(criterion) + .innerJoin(rubric, eq(rubric.id, criterion.rubricId)) + .where(and(eq(rubric.eventId, first.eventId), eq(rubric.isDefault, true))); + expect(criteria.map((item) => item.key).sort()).toEqual( + ["clarity", "creativity", "impact", "scope", "soundness"], + ); + } finally { + await pool.end(); + } + }); +}); diff --git a/packages/judging-server/src/services/event.ts b/packages/judging-server/src/services/event.ts new file mode 100644 index 00000000..30fae295 --- /dev/null +++ b/packages/judging-server/src/services/event.ts @@ -0,0 +1,78 @@ +import { and, eq, sql } from "drizzle-orm"; +import { DEFAULT_TIMERS } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; +import { criterion, event, eventConfig, organization, rubric } from "@query/judging-db"; + +/** The five scores the club's judges have always given, each out of ten. */ +const DEFAULT_CRITERIA = [ + ["creativity", "Creativity & Originality"], + ["impact", "Impact & Relevance"], + ["scope", "Scope & Technical Depth"], + ["clarity", "Clarity & Engagement"], + ["soundness", "Soundness & Accuracy"], +] as const; + +/** + * The event for one hackathon edition, created the first time it is asked + * for: organization, event in setup, default config, and the default rubric. + * Safe to call on every sync; an existing event is returned untouched. + */ +export async function ensureEvent( + db: PanelDb, + input: { orgSlug: string; orgName: string; eventSlug: string; name: string }, +): Promise<{ eventId: string }> { + return db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${`panel:event:${input.orgSlug}:${input.eventSlug}`}))`, + ); + await tx + .insert(organization) + .values({ slug: input.orgSlug, name: input.orgName }) + .onConflictDoNothing({ target: organization.slug }); + const [org] = await tx + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.slug, input.orgSlug)); + if (!org) throw new Error("Organization was not stored"); + + const [existing] = await tx + .select({ id: event.id }) + .from(event) + .where(and(eq(event.orgId, org.id), eq(event.slug, input.eventSlug))); + if (existing) return { eventId: existing.id }; + + const [created] = await tx + .insert(event) + .values({ orgId: org.id, slug: input.eventSlug, name: input.name, phase: "setup" }) + .returning({ id: event.id }); + if (!created) throw new Error("Event was not stored"); + + await tx.insert(eventConfig).values({ + eventId: created.id, + targetSeconds: DEFAULT_TIMERS.targetSeconds, + hardLimitSeconds: DEFAULT_TIMERS.hardLimitSeconds, + walkLimitSeconds: DEFAULT_TIMERS.walkLimitSeconds, + submitGraceSeconds: DEFAULT_TIMERS.submitGraceSeconds, + minLooksPerProject: 1, + dispatchStrategy: "coverage", + }); + + const [main] = await tx + .insert(rubric) + .values({ eventId: created.id, name: "Main", isDefault: true }) + .returning({ id: rubric.id }); + if (!main) throw new Error("Rubric was not stored"); + await tx.insert(criterion).values( + DEFAULT_CRITERIA.map(([key, label], position) => ({ + rubricId: main.id, + position, + key, + label, + min: 1, + max: 10, + weight: 1, + })), + ); + return { eventId: created.id }; + }); +} diff --git a/packages/judging-server/src/services/feedback.test.ts b/packages/judging-server/src/services/feedback.test.ts new file mode 100644 index 00000000..6f897277 --- /dev/null +++ b/packages/judging-server/src/services/feedback.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { feedbackStatus, median } from "./feedback"; + +describe("feedbackStatus", () => { + it("refuses before publish and for the wrong token", () => { + expect(feedbackStatus({ published: false, tokenMatches: true })).toBe("forbidden"); + expect(feedbackStatus({ published: true, tokenMatches: false })).toBe("forbidden"); + }); + + it("allows the project's token after publish", () => { + expect(feedbackStatus({ published: true, tokenMatches: true })).toBe("ok"); + }); + + it("puts the event median between the middle scores", () => { + expect(median([1, 3, 9])).toBe(3); + expect(median([1, 2, 8, 9])).toBe(5); + }); +}); diff --git a/packages/judging-server/src/services/feedback.ts b/packages/judging-server/src/services/feedback.ts new file mode 100644 index 00000000..d12107fc --- /dev/null +++ b/packages/judging-server/src/services/feedback.ts @@ -0,0 +1,17 @@ +export function feedbackStatus(input: { + published: boolean; + tokenMatches: boolean; +}): "ok" | "forbidden" { + if (!input.published || !input.tokenMatches) return "forbidden"; + return "ok"; +} + +export function median(values: readonly number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const upper = sorted[mid] ?? 0; + if (sorted.length % 2 === 1) return upper; + const lower = sorted[mid - 1] ?? upper; + return (lower + upper) / 2; +} diff --git a/packages/judging-server/src/services/floor.test.ts b/packages/judging-server/src/services/floor.test.ts new file mode 100644 index 00000000..aabba96b --- /dev/null +++ b/packages/judging-server/src/services/floor.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_TIMERS } from "@query/judging-core"; +import { judgeFloorState } from "./floor"; + +const arrived = new Date("2027-02-28T13:00:00Z"); + +describe("judgeFloorState", () => { + it("marks a judge with no table as idle", () => { + expect(judgeFloorState(null, arrived, DEFAULT_TIMERS)).toEqual({ + state: "idle", + overtime: false, + }); + }); + + it("marks a judge who has not arrived as walking", () => { + expect(judgeFloorState({ arrivedAt: null }, arrived, DEFAULT_TIMERS).state).toBe("walking"); + }); + + it("marks overtime only after the target time at the table", () => { + const early = judgeFloorState({ arrivedAt: arrived }, new Date(arrived.getTime() + 1000), DEFAULT_TIMERS); + const late = judgeFloorState( + { arrivedAt: arrived }, + new Date(arrived.getTime() + DEFAULT_TIMERS.targetSeconds * 1000 + 1000), + DEFAULT_TIMERS, + ); + expect(early).toEqual({ state: "at table", overtime: false }); + expect(late).toEqual({ state: "at table", overtime: true }); + }); +}); diff --git a/packages/judging-server/src/services/floor.ts b/packages/judging-server/src/services/floor.ts new file mode 100644 index 00000000..5dfe8c0a --- /dev/null +++ b/packages/judging-server/src/services/floor.ts @@ -0,0 +1,79 @@ +import { and, eq, isNull, sql } from "drizzle-orm"; +import { isOverTarget } from "@query/judging-core"; +import type { TimerConfig } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; +import { eventConfig, judge, project, visit, vote } from "@query/judging-db"; + +export type FloorState = "idle" | "walking" | "at table"; + +export function judgeFloorState( + open: { arrivedAt: Date | null } | null, + now: Date, + config: TimerConfig, +): { state: FloorState; overtime: boolean } { + if (!open) return { state: "idle", overtime: false }; + if (!open.arrivedAt) return { state: "walking", overtime: false }; + return { + state: "at table", + overtime: isOverTarget(open.arrivedAt, now, config), + }; +} + +export async function organizerFloor(db: PanelDb, eventId: string, now: Date) { + const [config] = await db + .select({ + targetSeconds: eventConfig.targetSeconds, + hardLimitSeconds: eventConfig.hardLimitSeconds, + walkLimitSeconds: eventConfig.walkLimitSeconds, + submitGraceSeconds: eventConfig.submitGraceSeconds, + }) + .from(eventConfig) + .where(eq(eventConfig.eventId, eventId)); + if (!config) return null; + + const projects = await db + .select({ id: project.id, tableNumber: project.tableNumber }) + .from(project) + .where(and(eq(project.eventId, eventId), isNull(project.withdrawnAt))); + const looks = await db + .select({ projectId: vote.projectId, count: sql`count(*)::int` }) + .from(vote) + .where(eq(vote.eventId, eventId)) + .groupBy(vote.projectId); + const lookOf = new Map(looks.map((item) => [item.projectId, item.count])); + + const open = await db + .select({ + judgeId: visit.judgeId, + arrivedAt: visit.arrivedAt, + tableNumber: project.tableNumber, + }) + .from(visit) + .innerJoin(project, eq(project.id, visit.projectId)) + .where(and(eq(visit.eventId, eventId), isNull(visit.completedAt), isNull(visit.voidedAt))); + const openOf = new Map(open.map((item) => [item.judgeId, item])); + + const people = await db + .select({ id: judge.id, name: judge.name }) + .from(judge) + .where(and(eq(judge.eventId, eventId), eq(judge.status, "approved"))); + + return { + tables: projects + .map((item) => ({ + tableNumber: item.tableNumber, + looks: lookOf.get(item.id) ?? 0, + })) + .sort((a, b) => (a.tableNumber ?? 0) - (b.tableNumber ?? 0)), + judges: people.map((person) => { + const current = openOf.get(person.id) ?? null; + const state = judgeFloorState(current, now, config); + return { + name: person.name, + state: state.state, + overtime: state.overtime, + tableNumber: current?.tableNumber ?? null, + }; + }), + }; +} diff --git a/packages/judging-server/src/services/flow.integration.test.ts b/packages/judging-server/src/services/flow.integration.test.ts new file mode 100644 index 00000000..58156201 --- /dev/null +++ b/packages/judging-server/src/services/flow.integration.test.ts @@ -0,0 +1,110 @@ +import { and, eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { + createDb, + criterion, + databaseUrl, + event, + eventLog, + migrate, + project, + rubric, + seedDemo, +} from "@query/judging-db"; +import { arrive } from "./arrive"; +import { dispatchNext } from "./dispatch"; +import { feedbackCard } from "./catalog"; +import { computeResults, publishResults } from "./results"; +import { castVote } from "./vote"; + +const url = databaseUrl(); + +describe.skipIf(!url)("a full visit", () => { + it("scores a table, publishes, and opens that project's feedback", async () => { + const { db, pool } = createDb(url as string); + try { + await migrate(pool); + const { eventId } = await seedDemo(db); + const { judge } = await import("@query/judging-db"); + const [person] = await db.select().from(judge).where(eq(judge.eventId, eventId)); + if (!person) throw new Error("demo judge missing"); + const now = new Date("2027-02-28T13:00:00Z"); + + const handed = await dispatchNext(db, { eventId, judgeId: person.id, now }); + expect(handed.done).toBe(false); + if (handed.done) return; + + await arrive(db, { + eventId, + judgeId: person.id, + tableNumber: handed.tableNumber ?? undefined, + now: new Date(now.getTime() + 30_000), + }); + + const [rub] = await db + .select({ id: rubric.id }) + .from(rubric) + .where(and(eq(rubric.eventId, eventId), eq(rubric.isDefault, true))); + if (!rub) throw new Error("demo rubric missing"); + const criteria = await db + .select() + .from(criterion) + .where(eq(criterion.rubricId, rub.id)); + + await castVote(db, { + eventId, + judgeId: person.id, + visitId: handed.visitId, + scores: criteria.map((item) => ({ criterionId: item.id, value: 8 })), + comment: null, + now: new Date(now.getTime() + 60_000), + }); + + await db.update(event).set({ phase: "judging_closed" }).where(eq(event.id, eventId)); + const run = await computeResults(db, { + eventId, + actorEmail: "organizer@demo.panel", + now: new Date(now.getTime() + 120_000), + }); + await publishResults(db, { + eventId, + runId: run.runId, + actorEmail: "organizer@demo.panel", + now: new Date(now.getTime() + 130_000), + }); + + const [scored] = await db + .select({ token: project.feedbackToken }) + .from(project) + .where(eq(project.id, handed.projectId)); + if (!scored?.token) throw new Error("feedback token was not minted"); + + const card = await feedbackCard(db, scored.token); + expect(card.status).toBe("ok"); + if (card.status === "ok") { + expect(card.criteria.length).toBeGreaterThan(0); + expect(card.criteria[0]?.mean).toBe(8); + } + expect((await feedbackCard(db, "00000000-0000-0000-0000-000000000000")).status).toBe( + "forbidden", + ); + + const kinds = [ + "visit.handed_out", + "visit.arrived", + "vote.cast", + "results.computed", + "results.published", + ] as const; + for (const kind of kinds) { + const rows = await db + .select({ id: eventLog.id }) + .from(eventLog) + .where(and(eq(eventLog.eventId, eventId), eq(eventLog.kind, kind))); + expect(rows, kind).toHaveLength(1); + } + } finally { + await pool.end(); + } + }); +}); diff --git a/packages/judging-server/src/services/live.ts b/packages/judging-server/src/services/live.ts new file mode 100644 index 00000000..3e75fbe2 --- /dev/null +++ b/packages/judging-server/src/services/live.ts @@ -0,0 +1,122 @@ +import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"; +import type { PanelDb } from "@query/judging-db"; +import { event, eventConfig, judge, organization, prize, project, result, resultRun, track, visit, vote } from "@query/judging-db"; + +export async function liveSnapshot(db: PanelDb, orgSlug: string, eventSlug: string) { + const [row] = await db + .select({ + eventId: event.id, + phase: event.phase, + name: event.name, + leaderboardPublic: eventConfig.leaderboardPublic, + pollSeconds: eventConfig.boardPollSeconds, + }) + .from(event) + .innerJoin(organization, eq(organization.id, event.orgId)) + .innerJoin(eventConfig, eq(eventConfig.eventId, event.id)) + .where(and(eq(organization.slug, orgSlug), eq(event.slug, eventSlug))); + if (!row) return null; + + const prizes = await db + .select({ + title: prize.title, + place: prize.place, + amount: prize.amount, + track: track.name, + }) + .from(prize) + .innerJoin(track, eq(track.id, prize.trackId)) + .where(eq(track.eventId, row.eventId)) + .orderBy(track.position, prize.place); + + const published = row.phase === "published"; + const showFloor = published || row.leaderboardPublic; + if (!showFloor) { + return { + eventId: row.eventId, + name: row.name, + phase: row.phase, + pollSeconds: row.pollSeconds, + projects: [], + placements: [], + prizes, + }; + } + + const projects = await db + .select({ + id: project.id, + name: project.name, + tableNumber: project.tableNumber, + }) + .from(project) + .where(and(eq(project.eventId, row.eventId), isNull(project.withdrawnAt))); + + const looks = await db + .select({ + projectId: vote.projectId, + count: sql`count(*)::int`, + }) + .from(vote) + .where(eq(vote.eventId, row.eventId)) + .groupBy(vote.projectId); + const lookOf = new Map(looks.map((item) => [item.projectId, item.count])); + + const open = await db + .select({ judgeId: visit.judgeId, projectId: visit.projectId, arrivedAt: visit.arrivedAt }) + .from(visit) + .where( + and(eq(visit.eventId, row.eventId), isNull(visit.completedAt), isNull(visit.voidedAt)), + ); + const judges = await db + .select({ id: judge.id, name: judge.name }) + .from(judge) + .where(eq(judge.eventId, row.eventId)); + const nameOf = new Map(judges.map((item) => [item.id, item.name])); + + let placements: { + projectId: string; + projectName: string; + trackId: string; + placement: number | null; + }[] = []; + if (published) { + const [run] = await db + .select({ id: resultRun.id }) + .from(resultRun) + .where(and(eq(resultRun.eventId, row.eventId), isNotNull(resultRun.publishedAt))); + if (run) { + placements = await db + .select({ + projectId: result.projectId, + projectName: project.name, + trackId: result.trackId, + placement: result.placement, + }) + .from(result) + .innerJoin(project, eq(project.id, result.projectId)) + .where(eq(result.runId, run.id)); + } + } + + return { + eventId: row.eventId, + name: row.name, + phase: row.phase, + pollSeconds: row.pollSeconds, + projects: projects.map((item) => ({ + id: item.id, + name: published ? item.name : null, + tableNumber: item.tableNumber, + looks: lookOf.get(item.id) ?? 0, + })), + floor: open.map((item) => ({ + judgeId: item.judgeId, + judgeName: nameOf.get(item.judgeId) ?? "", + projectId: item.projectId, + arrived: item.arrivedAt !== null, + })), + placements, + prizes, + }; +} diff --git a/packages/judging-server/src/services/logs.test.ts b/packages/judging-server/src/services/logs.test.ts new file mode 100644 index 00000000..2be074f6 --- /dev/null +++ b/packages/judging-server/src/services/logs.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { logsForHandout } from "./logs"; + +describe("logsForHandout", () => { + it("writes exactly one row when nothing has lapsed", () => { + expect(logsForHandout([]).map((row) => row.kind)).toEqual(["visit.handed_out"]); + }); + + it("writes one void row per lapsed visit, then the hand-out", () => { + expect(logsForHandout(["a", "b"]).map((row) => row.kind)).toEqual([ + "visit.voided", + "visit.voided", + "visit.handed_out", + ]); + }); +}); diff --git a/packages/judging-server/src/services/logs.ts b/packages/judging-server/src/services/logs.ts new file mode 100644 index 00000000..b7d54c73 --- /dev/null +++ b/packages/judging-server/src/services/logs.ts @@ -0,0 +1,28 @@ +import { isOutboxTopic } from "@query/judging-core"; +import type { LogKind } from "@query/judging-core"; + +export type LogDraft = { + kind: LogKind; + subject: Record; + payload: Record; +}; + +/** One log row per void, then one for the hand-out. A reload writes none. */ +export function logsForHandout(voidedVisitIds: readonly string[]): LogDraft[] { + return [ + ...voidedVisitIds.map((visitId) => ({ + kind: "visit.voided" as const, + subject: { visitId }, + payload: {}, + })), + { + kind: "visit.handed_out" as const, + subject: {}, + payload: {}, + }, + ]; +} + +export function isDeliveredTopic(kind: LogKind): boolean { + return isOutboxTopic(kind); +} diff --git a/packages/judging-server/src/services/outbox.ts b/packages/judging-server/src/services/outbox.ts new file mode 100644 index 00000000..1c43acc6 --- /dev/null +++ b/packages/judging-server/src/services/outbox.ts @@ -0,0 +1,53 @@ +import { createHmac } from "node:crypto"; +import { eq, isNull } from "drizzle-orm"; +import type { PanelDb } from "@query/judging-db"; +import { outbox, webhook } from "@query/judging-db"; + +/** + * Delivers pending outbox rows. A topic with no active webhook is delivered. + * A delivered row is deleted: event_log already keeps the history, and on a + * 0.5 GB database a second copy of every vote is not worth keeping. + */ +export async function drainOutbox(db: PanelDb): Promise { + const pending = await db + .select() + .from(outbox) + .where(isNull(outbox.deliveredAt)) + .limit(50); + if (pending.length === 0) return 0; + + const hooks = await db.select().from(webhook).where(eq(webhook.isActive, true)); + let delivered = 0; + + for (const row of pending) { + const targets = hooks.filter((hook) => hook.topics?.includes(row.topic)); + let ok = true; + for (const hook of targets) { + const body = JSON.stringify(row.payload); + const signature = createHmac("sha256", hook.secret).update(body).digest("hex"); + try { + const response = await fetch(hook.url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-panel-signature": signature, + }, + body, + }); + if (!response.ok) ok = false; + } catch { + ok = false; + } + } + if (!ok) { + await db + .update(outbox) + .set({ attempts: row.attempts + 1 }) + .where(eq(outbox.id, row.id)); + continue; + } + await db.delete(outbox).where(eq(outbox.id, row.id)); + delivered += 1; + } + return delivered; +} diff --git a/packages/judging-server/src/services/overtime.test.ts b/packages/judging-server/src/services/overtime.test.ts new file mode 100644 index 00000000..35fdb143 --- /dev/null +++ b/packages/judging-server/src/services/overtime.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_TIMERS } from "@query/judging-core"; +import { visitsOverTarget } from "./overtime"; + +const arrived = new Date("2027-02-28T13:00:00Z"); + +describe("visitsOverTarget", () => { + it("includes a judge who has stayed past the target", () => { + const rows = visitsOverTarget( + [ + { + visitId: "late", + eventId: "event", + judgeId: "judge", + arrivedAt: arrived, + config: DEFAULT_TIMERS, + }, + { + visitId: "early", + eventId: "event", + judgeId: "other", + arrivedAt: new Date(arrived.getTime() + 10_000), + config: DEFAULT_TIMERS, + }, + ], + new Date(arrived.getTime() + DEFAULT_TIMERS.targetSeconds * 1000 + 1000), + ); + expect(rows.map((row) => row.visitId)).toEqual(["late"]); + }); +}); diff --git a/packages/judging-server/src/services/overtime.ts b/packages/judging-server/src/services/overtime.ts new file mode 100644 index 00000000..db7fc2a0 --- /dev/null +++ b/packages/judging-server/src/services/overtime.ts @@ -0,0 +1,49 @@ +import { and, eq, isNotNull, isNull } from "drizzle-orm"; +import { isOverTarget } from "@query/judging-core"; +import type { TimerConfig } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; +import { eventConfig, visit } from "@query/judging-db"; + +export type OpenVisit = { + visitId: string; + eventId: string; + judgeId: string; + arrivedAt: Date | null; + config: TimerConfig; +}; + +/** Visits where the judge has arrived and is past the target time. */ +export function visitsOverTarget(rows: readonly OpenVisit[], now: Date): OpenVisit[] { + return rows.filter( + (row) => row.arrivedAt !== null && isOverTarget(row.arrivedAt, now, row.config), + ); +} + +export async function loadOpenVisits(db: PanelDb): Promise { + const rows = await db + .select({ + visitId: visit.id, + eventId: visit.eventId, + judgeId: visit.judgeId, + arrivedAt: visit.arrivedAt, + targetSeconds: eventConfig.targetSeconds, + hardLimitSeconds: eventConfig.hardLimitSeconds, + walkLimitSeconds: eventConfig.walkLimitSeconds, + submitGraceSeconds: eventConfig.submitGraceSeconds, + }) + .from(visit) + .innerJoin(eventConfig, eq(eventConfig.eventId, visit.eventId)) + .where(and(isNull(visit.completedAt), isNull(visit.voidedAt), isNotNull(visit.arrivedAt))); + return rows.map((row) => ({ + visitId: row.visitId, + eventId: row.eventId, + judgeId: row.judgeId, + arrivedAt: row.arrivedAt, + config: { + targetSeconds: row.targetSeconds, + hardLimitSeconds: row.hardLimitSeconds, + walkLimitSeconds: row.walkLimitSeconds, + submitGraceSeconds: row.submitGraceSeconds, + }, + })); +} diff --git a/packages/judging-server/src/services/records.ts b/packages/judging-server/src/services/records.ts new file mode 100644 index 00000000..7000de5b --- /dev/null +++ b/packages/judging-server/src/services/records.ts @@ -0,0 +1,358 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, isNull } from "drizzle-orm"; +import { assignTables, phaseAllows } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; +import { event, eventLog, floorTable, judge, outbox, project, projectTrack, visit } from "@query/judging-db"; + +async function phaseOf(db: PanelDb, eventId: string) { + const [row] = await db + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, eventId)); + if (!row) throw new Error("Event not found"); + return row.phase; +} + +export async function upsertProject( + db: PanelDb, + input: { + eventId: string; + externalId: string; + name: string; + teamName: string | null; + tableNumber: number | null; + actorEmail: string; + }, +) { + const phase = await phaseOf(db, input.eventId); + if (phase === "archived" || phase === "published") { + throw new Error(`submit is not allowed while the event is ${phase}`); + } + const [existing] = await db + .select({ id: project.id }) + .from(project) + .where(and(eq(project.eventId, input.eventId), eq(project.externalId, input.externalId))); + + if (existing) { + await db + .update(project) + .set({ + name: input.name, + teamName: input.teamName, + tableNumber: input.tableNumber, + }) + .where(eq(project.id, existing.id)); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "project.upserted", + actor: { email: input.actorEmail }, + subject: { projectId: existing.id }, + payload: { externalId: input.externalId }, + }); + return { id: existing.id }; + } + + const [created] = await db + .insert(project) + .values({ + eventId: input.eventId, + externalId: input.externalId, + name: input.name, + teamName: input.teamName, + tableNumber: input.tableNumber, + }) + .returning({ id: project.id }); + if (!created) throw new Error("Project was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "project.upserted", + actor: { email: input.actorEmail }, + subject: { projectId: created.id }, + payload: { externalId: input.externalId }, + }); + return { id: created.id }; +} + +export async function withdrawProject( + db: PanelDb, + input: { eventId: string; projectId: string; now: Date; actorEmail: string }, +) { + const phase = await phaseOf(db, input.eventId); + if (!phaseAllows(phase, "submit") && phase !== "judging_live" && phase !== "submissions_closed") { + throw new Error(`submit is not allowed while the event is ${phase}`); + } + await db + .update(project) + .set({ withdrawnAt: input.now }) + .where(and(eq(project.id, input.projectId), eq(project.eventId, input.eventId))); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "project.withdrawn", + actor: { email: input.actorEmail }, + subject: { projectId: input.projectId }, + payload: {}, + }); +} + +export async function upsertJudge( + db: PanelDb, + input: { + eventId: string; + email: string; + name: string; + externalId: string | null; + actorEmail: string; + }, +) { + const phase = await phaseOf(db, input.eventId); + if (!phaseAllows(phase, "apply") && phase !== "judging_live") { + throw new Error(`apply is not allowed while the event is ${phase}`); + } + const email = input.email.toLowerCase(); + const [existing] = await db + .select({ id: judge.id }) + .from(judge) + .where(and(eq(judge.eventId, input.eventId), eq(judge.email, email))); + if (existing) { + await db + .update(judge) + .set({ name: input.name, externalId: input.externalId }) + .where(eq(judge.id, existing.id)); + return { id: existing.id }; + } + const [created] = await db + .insert(judge) + .values({ + eventId: input.eventId, + email, + name: input.name, + externalId: input.externalId, + status: "invited", + }) + .returning({ id: judge.id }); + if (!created) throw new Error("Judge was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "judge.upserted", + actor: { email: input.actorEmail }, + subject: { judgeId: created.id }, + payload: {}, + }); + return { id: created.id }; +} + +export async function applyAsJudge( + db: PanelDb, + input: { eventId: string; email: string; name: string }, +) { + const phase = await phaseOf(db, input.eventId); + if (!phaseAllows(phase, "apply")) { + throw new Error(`apply is not allowed while the event is ${phase}`); + } + const email = input.email.toLowerCase(); + const [existing] = await db + .select({ id: judge.id, status: judge.status }) + .from(judge) + .where(and(eq(judge.eventId, input.eventId), eq(judge.email, email))); + if (existing) { + if (existing.status === "invited") { + await db.update(judge).set({ status: "applied", name: input.name }).where(eq(judge.id, existing.id)); + } + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "judge.upserted", + actor: { email }, + subject: { judgeId: existing.id }, + payload: { status: existing.status === "invited" ? "applied" : existing.status }, + }); + return { id: existing.id, status: existing.status === "invited" ? "applied" : existing.status }; + } + const [created] = await db + .insert(judge) + .values({ + eventId: input.eventId, + email, + name: input.name, + status: "applied", + }) + .returning({ id: judge.id }); + if (!created) throw new Error("Judge was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "judge.upserted", + actor: { email }, + subject: { judgeId: created.id }, + payload: { status: "applied" }, + }); + return { id: created.id, status: "applied" as const }; +} + +export async function setJudgeStatus( + db: PanelDb, + input: { + eventId: string; + judgeId: string; + status: "approved" | "suspended"; + actorEmail: string; + }, +) { + await db + .update(judge) + .set({ status: input.status }) + .where(and(eq(judge.id, input.judgeId), eq(judge.eventId, input.eventId))); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: input.status === "approved" ? "judge.approved" : "judge.suspended", + actor: { email: input.actorEmail }, + subject: { judgeId: input.judgeId }, + payload: { status: input.status }, + }); +} + +export async function voidVisit( + db: PanelDb, + input: { eventId: string; visitId: string; now: Date; actorEmail: string; reason: string }, +) { + return db.transaction(async (tx) => { + const [row] = await tx + .select({ id: visit.id, judgeId: visit.judgeId }) + .from(visit) + .where(and(eq(visit.id, input.visitId), eq(visit.eventId, input.eventId), isNull(visit.voidedAt))); + if (!row) throw new Error("Visit not found"); + await tx + .update(visit) + .set({ voidedAt: input.now, voidReason: input.reason }) + .where(eq(visit.id, row.id)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "visit.voided", + actor: { email: input.actorEmail }, + subject: { visitId: row.id }, + payload: { reason: input.reason }, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "visit.voided", + payload: { visitId: row.id, judgeId: row.judgeId }, + }); + return { judgeId: row.judgeId }; + }); +} + +export async function recallJudge( + db: PanelDb, + input: { eventId: string; judgeId: string; actorEmail: string }, +) { + await db.transaction(async (tx) => { + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "judge.recalled", + actor: { email: input.actorEmail }, + subject: { judgeId: input.judgeId }, + payload: {}, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "judge.recalled", + payload: { judgeId: input.judgeId }, + }); + }); +} + +export async function assignJudge( + db: PanelDb, + input: { + eventId: string; + judgeId: string; + trackId: string | null; + zoneId: string | null; + actorEmail: string; + }, +) { + const [row] = await db + .update(judge) + .set({ trackId: input.trackId, zoneId: input.zoneId }) + .where(and(eq(judge.id, input.judgeId), eq(judge.eventId, input.eventId))) + .returning({ id: judge.id }); + if (!row) throw new Error("Judge not found"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "judge.upserted", + actor: { email: input.actorEmail }, + subject: { judgeId: input.judgeId }, + payload: { trackId: input.trackId, zoneId: input.zoneId }, + }); + return { id: row.id }; +} + +export async function assignProjectTrack( + db: PanelDb, + input: { projectId: string; trackId: string }, +) { + await db + .insert(projectTrack) + .values(input) + .onConflictDoNothing(); +} + +export async function rotateQr( + db: PanelDb, + input: { eventId: string; projectId: string; actorEmail: string }, +) { + const token = randomUUID(); + const [row] = await db + .update(project) + .set({ qrToken: token }) + .where(and(eq(project.id, input.projectId), eq(project.eventId, input.eventId))) + .returning({ qrToken: project.qrToken }); + if (!row) throw new Error("Project not found"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "project.qr_rotated", + actor: { email: input.actorEmail }, + subject: { projectId: input.projectId }, + payload: {}, + }); + return { qrToken: row.qrToken }; +} + +export async function assignEventTables( + db: PanelDb, + input: { eventId: string; actorEmail: string }, +) { + const projects = await db + .select({ id: project.id }) + .from(project) + .where(and(eq(project.eventId, input.eventId), isNull(project.withdrawnAt))); + const links = await db + .select({ projectId: projectTrack.projectId, trackId: projectTrack.trackId }) + .from(projectTrack) + .innerJoin(project, eq(project.id, projectTrack.projectId)) + .where(eq(project.eventId, input.eventId)); + const trackOf = new Map(); + for (const link of links) { + if (!trackOf.has(link.projectId)) trackOf.set(link.projectId, link.trackId); + } + const tables = await db + .select({ number: floorTable.number, zoneId: floorTable.zoneId }) + .from(floorTable) + .where(eq(floorTable.eventId, input.eventId)); + const seated = assignTables( + projects.map((row) => ({ id: row.id, trackId: trackOf.get(row.id) ?? null })), + tables.map((row) => ({ number: row.number, zoneId: row.zoneId })), + ); + for (const seat of seated) { + await db + .update(project) + .set({ tableNumber: seat.tableNumber, zoneId: seat.zoneId }) + .where(eq(project.id, seat.projectId)); + } + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "project.table_assigned", + actor: { email: input.actorEmail }, + subject: { eventId: input.eventId }, + payload: { assigned: seated.length }, + }); + return { assigned: seated.length }; +} diff --git a/packages/judging-server/src/services/redis.integration.test.ts b/packages/judging-server/src/services/redis.integration.test.ts new file mode 100644 index 00000000..561545b7 --- /dev/null +++ b/packages/judging-server/src/services/redis.integration.test.ts @@ -0,0 +1,41 @@ +import { Redis } from "ioredis"; +import { describe, expect, it } from "vitest"; +import { createRedisBus } from "../bus"; + +const url = process.env.REDIS_URL; + +async function redisAnswers(address: string): Promise { + const client = new Redis(address, { + maxRetriesPerRequest: 0, + connectTimeout: 500, + lazyConnect: true, + retryStrategy: () => null, + }); + client.on("error", () => undefined); + try { + await client.connect(); + return (await client.ping()) === "PONG"; + } catch { + return false; + } finally { + client.disconnect(); + } +} + +describe.skipIf(!url)("redis bus", () => { + it("delivers a message from one connection to another", async (ctx) => { + if (!(await redisAnswers(url as string))) ctx.skip(); + const left = createRedisBus(url as string); + const right = createRedisBus(url as string); + const seen = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("no redis message")), 4000); + right.subscribe("event:test:board", (message) => { + clearTimeout(timer); + resolve(message); + }); + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + left.publish("event:test:board", { kind: "vote.cast" }); + await expect(seen).resolves.toEqual({ kind: "vote.cast" }); + }); +}); diff --git a/packages/judging-server/src/services/results.test.ts b/packages/judging-server/src/services/results.test.ts new file mode 100644 index 00000000..552c88d4 --- /dev/null +++ b/packages/judging-server/src/services/results.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { diffPlacements } from "./results"; + +const quiet = { rubricComponent: 8, pairwiseComponent: 0 }; + +describe("diffPlacements", () => { + it("reports a place move with both blend components", () => { + const left = [ + { projectId: "a", trackId: "t", placement: 1, score: 9, ...quiet }, + { projectId: "b", trackId: "t", placement: 2, score: 8, ...quiet }, + ]; + const right = [ + { projectId: "a", trackId: "t", placement: 2, score: 7, rubricComponent: 7, pairwiseComponent: 1 }, + { projectId: "b", trackId: "t", placement: 1, score: 9, ...quiet }, + ]; + expect(diffPlacements(left, right)).toEqual([ + { + projectId: "a", + trackId: "t", + from: 1, + to: 2, + score: 7, + rubricFrom: 8, + rubricTo: 7, + pairwiseFrom: 0, + pairwiseTo: 1, + }, + { + projectId: "b", + trackId: "t", + from: 2, + to: 1, + score: 9, + rubricFrom: 8, + rubricTo: 8, + pairwiseFrom: 0, + pairwiseTo: 0, + }, + ]); + }); + + it("reports a pairwise change when the place stays put", () => { + const left = [{ projectId: "a", trackId: "t", placement: 1, score: 8, ...quiet }]; + const right = [ + { projectId: "a", trackId: "t", placement: 1, score: 8.4, rubricComponent: 8, pairwiseComponent: 2 }, + ]; + expect(diffPlacements(left, right)).toEqual([ + { + projectId: "a", + trackId: "t", + from: 1, + to: 1, + score: 8.4, + rubricFrom: 8, + rubricTo: 8, + pairwiseFrom: 0, + pairwiseTo: 2, + }, + ]); + }); +}); diff --git a/packages/judging-server/src/services/results.ts b/packages/judging-server/src/services/results.ts new file mode 100644 index 00000000..addcd6f1 --- /dev/null +++ b/packages/judging-server/src/services/results.ts @@ -0,0 +1,328 @@ +import { and, desc, eq, isNotNull, isNull, sql } from "drizzle-orm"; +import { phaseAllows, rank } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; +import { + comparison, + event, + eventConfig, + eventLog, + judge, + outbox, + project, + projectTrack, + result, + resultRun, + track, + vote, +} from "@query/judging-db"; + +export type Placement = { + projectId: string; + trackId: string; + placement: number | null; + score: number; + rubricComponent: number; + pairwiseComponent: number; +}; + +/** Projects whose place or blend components changed between two runs. */ +export function diffPlacements(left: readonly Placement[], right: readonly Placement[]) { + const later = new Map(right.map((row) => [`${row.trackId}:${row.projectId}`, row])); + return left.flatMap((row) => { + const other = later.get(`${row.trackId}:${row.projectId}`); + const to = other?.placement ?? null; + const score = other?.score ?? row.score; + const rubricTo = other?.rubricComponent ?? row.rubricComponent; + const pairwiseTo = other?.pairwiseComponent ?? row.pairwiseComponent; + if ( + row.placement === to && + row.score === score && + row.rubricComponent === rubricTo && + row.pairwiseComponent === pairwiseTo + ) { + return []; + } + return [ + { + projectId: row.projectId, + trackId: row.trackId, + from: row.placement, + to, + score, + rubricFrom: row.rubricComponent, + rubricTo, + pairwiseFrom: row.pairwiseComponent, + pairwiseTo, + }, + ]; + }); +} + +export async function computeResults( + db: PanelDb, + input: { eventId: string; actorEmail: string; now: Date }, +) { + return db.transaction(async (tx) => { + const [evt] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + const [config] = await tx + .select() + .from(eventConfig) + .where(eq(eventConfig.eventId, input.eventId)); + if (!evt || !config) throw new Error("Event not found"); + if (!phaseAllows(evt.phase, "compute")) { + throw new Error(`compute is not allowed while the event is ${evt.phase}`); + } + + const projects = await tx + .select() + .from(project) + .where(and(eq(project.eventId, input.eventId), isNull(project.withdrawnAt))); + const links = await tx + .select({ projectId: projectTrack.projectId, trackId: projectTrack.trackId }) + .from(projectTrack) + .innerJoin(project, eq(project.id, projectTrack.projectId)) + .where(eq(project.eventId, input.eventId)); + const tracks = await tx + .select({ id: track.id, judgeGroup: track.judgeGroup }) + .from(track) + .where(eq(track.eventId, input.eventId)); + const judges = await tx + .select({ id: judge.id, trackId: judge.trackId }) + .from(judge) + .where(eq(judge.eventId, input.eventId)); + const groupOf = new Map( + judges.map((row) => [ + row.id, + tracks.find((item) => item.id === row.trackId)?.judgeGroup ?? "main", + ]), + ); + const votes = await tx + .select() + .from(vote) + .where(eq(vote.eventId, input.eventId)); + const comparisons = await tx + .select() + .from(comparison) + .where(eq(comparison.eventId, input.eventId)); + + const byProject = new Map(); + for (const link of links) { + const list = byProject.get(link.projectId) ?? []; + list.push(link.trackId); + byProject.set(link.projectId, list); + } + + const ranked = rank( + projects.map((row) => ({ + id: row.id, + trackIds: byProject.get(row.id) ?? [], + })), + votes.map((row) => ({ + judgeId: row.judgeId, + projectId: row.projectId, + judgeGroup: groupOf.get(row.judgeId) ?? "main", + total: row.total, + isCalibration: row.isCalibration, + })), + comparisons.map((row) => ({ + judgeId: row.judgeId, + judgeGroup: row.judgeGroup, + a: row.aProjectId, + b: row.bProjectId, + outcome: row.outcome, + })), + { + bayesianC: config.bayesianC, + pairwiseWeight: config.pairwiseEnabled ? config.pairwiseWeight : 0, + calibrationWeight: config.calibrationWeight, + }, + ); + + const [run] = await tx + .insert(resultRun) + .values({ + eventId: input.eventId, + computedAt: input.now, + configSnapshot: { + bayesianC: config.bayesianC, + pairwiseWeight: config.pairwiseWeight, + pairwiseEnabled: config.pairwiseEnabled, + calibrationWeight: config.calibrationWeight, + }, + }) + .returning({ id: resultRun.id }); + if (!run) throw new Error("Result run was not stored"); + + const placed = ranked.filter((row) => row.placement !== null); + if (placed.length > 0) { + await tx.insert(result).values( + placed.map((row) => ({ + runId: run.id, + projectId: row.projectId, + trackId: row.trackId, + placement: row.placement, + score: row.score, + rubricComponent: row.rubricComponent, + pairwiseComponent: row.pairwiseComponent, + voteCount: row.voteCount, + comparisonCount: row.comparisonCount, + flags: [], + })), + ); + } + + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "results.computed", + actor: { email: input.actorEmail }, + subject: { runId: run.id }, + payload: { rows: placed.length }, + }); + + return { runId: run.id, rows: placed.length }; + }); +} + +export async function publishResults( + db: PanelDb, + input: { eventId: string; runId: string; actorEmail: string; now: Date }, +) { + return db.transaction(async (tx) => { + const [evt] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + if (!phaseAllows(evt.phase, "publish")) { + throw new Error(`publish is not allowed while the event is ${evt.phase}`); + } + const [run] = await tx + .select({ id: resultRun.id }) + .from(resultRun) + .where(and(eq(resultRun.id, input.runId), eq(resultRun.eventId, input.eventId))); + if (!run) throw new Error("Result run not found"); + + await tx + .update(resultRun) + .set({ publishedAt: input.now }) + .where(eq(resultRun.id, run.id)); + await tx.update(event).set({ phase: "published" }).where(eq(event.id, input.eventId)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "results.published", + actor: { email: input.actorEmail }, + subject: { runId: run.id }, + payload: { from: evt.phase, to: "published" }, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "results.published", + payload: { runId: run.id }, + }); + await tx.execute(sql` + update project + set feedback_token = gen_random_uuid() + where event_id = ${input.eventId} and feedback_token is null + `); + return { runId: run.id }; + }); +} + +export async function unpublishResults( + db: PanelDb, + input: { eventId: string; actorEmail: string }, +) { + return db.transaction(async (tx) => { + const [evt] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + if (evt.phase !== "published") { + throw new Error(`unpublish is not allowed while the event is ${evt.phase}`); + } + const [run] = await tx + .select({ id: resultRun.id }) + .from(resultRun) + .where(and(eq(resultRun.eventId, input.eventId), isNotNull(resultRun.publishedAt))) + .orderBy(desc(resultRun.publishedAt)) + .limit(1); + if (!run) throw new Error("Nothing is published"); + await tx.update(resultRun).set({ publishedAt: null }).where(eq(resultRun.id, run.id)); + await tx.update(event).set({ phase: "judging_closed" }).where(eq(event.id, input.eventId)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "results.unpublished", + actor: { email: input.actorEmail }, + subject: { runId: run.id }, + payload: { from: "published", to: "judging_closed" }, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "results.unpublished", + payload: { runId: run.id }, + }); + return { runId: run.id }; + }); +} + +export async function placementsForRun(db: PanelDb, runId: string): Promise { + const rows = await db + .select({ + projectId: result.projectId, + trackId: result.trackId, + placement: result.placement, + score: result.score, + rubricComponent: result.rubricComponent, + pairwiseComponent: result.pairwiseComponent, + }) + .from(result) + .where(eq(result.runId, runId)) + .orderBy(desc(result.score)); + return rows; +} + +export async function inspectRun(db: PanelDb, runId: string) { + return db + .select({ + projectId: result.projectId, + projectName: project.name, + trackId: result.trackId, + placement: result.placement, + score: result.score, + rubricComponent: result.rubricComponent, + pairwiseComponent: result.pairwiseComponent, + voteCount: result.voteCount, + comparisonCount: result.comparisonCount, + }) + .from(result) + .innerJoin(project, eq(project.id, result.projectId)) + .where(eq(result.runId, runId)) + .orderBy(desc(result.score)); +} + +export async function publishedPlacements(db: PanelDb, eventId: string) { + const [evt] = await db + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, eventId)); + if (!evt) return null; + if (evt.phase !== "published") return { published: false as const, rows: [] }; + const rows = await db + .select({ + externalId: project.externalId, + placement: result.placement, + score: result.score, + track: track.slug, + voteCount: result.voteCount, + }) + .from(result) + .innerJoin(resultRun, eq(resultRun.id, result.runId)) + .innerJoin(project, eq(project.id, result.projectId)) + .innerJoin(track, eq(track.id, result.trackId)) + .where(and(eq(resultRun.eventId, eventId), isNotNull(resultRun.publishedAt))); + return { published: true as const, rows }; +} diff --git a/packages/judging-server/src/services/visit-status.integration.test.ts b/packages/judging-server/src/services/visit-status.integration.test.ts new file mode 100644 index 00000000..6aaf3ea7 --- /dev/null +++ b/packages/judging-server/src/services/visit-status.integration.test.ts @@ -0,0 +1,41 @@ +import { eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { createDb, databaseUrl, judge, migrate, seedDemo } from "@query/judging-db"; +import { dispatchNext, visitStatus } from "./dispatch"; +import { recallJudge, voidVisit } from "./records"; + +const url = databaseUrl(); + +describe.skipIf(!url)("visit status", () => { + it("reports a recall since the hand-out, then a void", async () => { + const { db, pool } = createDb(url as string); + try { + await migrate(pool); + const { eventId } = await seedDemo(db); + const [person] = await db.select().from(judge).where(eq(judge.eventId, eventId)); + if (!person) throw new Error("demo judge missing"); + + const handed = await dispatchNext(db, { eventId, judgeId: person.id, now: new Date() }); + if (handed.done) throw new Error("demo floor is empty"); + const ask = () => visitStatus(db, { eventId, judgeId: person.id, visitId: handed.visitId }); + + expect(await ask()).toEqual({ voided: false, recalledAt: null }); + + await recallJudge(db, { eventId, judgeId: person.id, actorEmail: "organizer@example.com" }); + const recalled = await ask(); + expect(recalled.voided).toBe(false); + expect(recalled.recalledAt).not.toBeNull(); + + await voidVisit(db, { + eventId, + visitId: handed.visitId, + now: new Date(), + actorEmail: "organizer@example.com", + reason: "test", + }); + expect((await ask()).voided).toBe(true); + } finally { + await pool.end(); + } + }); +}); diff --git a/packages/judging-server/src/services/vote.test.ts b/packages/judging-server/src/services/vote.test.ts new file mode 100644 index 00000000..43dd458b --- /dev/null +++ b/packages/judging-server/src/services/vote.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_TIMERS } from "@query/judging-core"; +import { assessVote } from "./vote"; + +const criteria = [{ id: "creativity", min: 0, max: 10, weight: 1 }]; +const arrived = new Date("2027-02-28T13:00:00Z"); +const at = (seconds: number) => new Date(arrived.getTime() + seconds * 1000); + +describe("assessVote", () => { + it("refuses a vote outside judging_live", () => { + const decision = assessVote({ + phase: "setup", + arrivedAt: arrived, + now: at(10), + config: DEFAULT_TIMERS, + criteria, + scores: [{ criterionId: "creativity", value: 5 }], + }); + expect(decision.ok).toBe(false); + if (!decision.ok) expect(decision.message).toMatch(/setup/); + }); + + it("names the configured hard limit when the score is late", () => { + const config = { ...DEFAULT_TIMERS, hardLimitSeconds: 90, submitGraceSeconds: 0 }; + const decision = assessVote({ + phase: "judging_live", + arrivedAt: arrived, + now: at(91), + config, + criteria, + scores: [{ criterionId: "creativity", value: 5 }], + }); + expect(decision.ok).toBe(false); + if (!decision.ok) expect(decision.message).toContain("90s"); + }); + + it("accepts a score inside the window and derives the total", () => { + const decision = assessVote({ + phase: "judging_live", + arrivedAt: arrived, + now: at(10), + config: DEFAULT_TIMERS, + criteria, + scores: [{ criterionId: "creativity", value: 8 }], + }); + expect(decision).toEqual({ ok: true, total: 8 }); + }); +}); diff --git a/packages/judging-server/src/services/vote.ts b/packages/judging-server/src/services/vote.ts new file mode 100644 index 00000000..bb1d8f0d --- /dev/null +++ b/packages/judging-server/src/services/vote.ts @@ -0,0 +1,175 @@ +import { and, eq } from "drizzle-orm"; +import { isPastCutoff, phaseAllows, validateScores, weightedTotal } from "@query/judging-core"; +import type { Criterion, Phase, ScoreInput, TimerConfig } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; +import { + criterion, + event, + eventConfig, + eventLog, + outbox, + rubric, + track, + visit, + vote, + voteScore, +} from "@query/judging-db"; + +export type VoteDecision = + | { ok: true; total: number } + | { ok: false; message: string }; + +/** + * Whether this score can be stored. The cutoff message names the configured + * hard limit so the phone can show the number the event actually used. + */ +export function assessVote(input: { + phase: Phase; + arrivedAt: Date | null; + now: Date; + config: TimerConfig; + criteria: readonly Criterion[]; + scores: readonly ScoreInput[]; +}): VoteDecision { + if (!phaseAllows(input.phase, "vote")) { + return { + ok: false, + message: `vote is not allowed while the event is ${input.phase}`, + }; + } + if (!input.arrivedAt) { + return { ok: false, message: "Arrive at the table before scoring" }; + } + if (isPastCutoff(input.arrivedAt, input.now, input.config)) { + return { + ok: false, + message: `Vote is past the ${input.config.hardLimitSeconds}s hard limit`, + }; + } + const check = validateScores(input.criteria, input.scores); + if (!check.ok) return { ok: false, message: check.reason }; + return { ok: true, total: weightedTotal(input.criteria, input.scores) }; +} + +export async function castVote( + db: PanelDb, + input: { + eventId: string; + judgeId: string; + visitId: string; + scores: readonly ScoreInput[]; + comment: string | null; + now: Date; + }, +): Promise<{ total: number }> { + return db.transaction(async (tx) => { + const [evt] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + const [config] = await tx + .select() + .from(eventConfig) + .where(eq(eventConfig.eventId, input.eventId)); + const [row] = await tx + .select() + .from(visit) + .where(and(eq(visit.id, input.visitId), eq(visit.judgeId, input.judgeId))); + if (!evt || !config || !row) throw new Error("Visit not found"); + if (row.eventId !== input.eventId) throw new Error("Visit not found"); + if (row.voidedAt || row.completedAt) throw new Error("This visit is closed"); + + const criteria = await loadCriteria(tx, input.eventId, row.judgeGroup); + const decision = assessVote({ + phase: evt.phase, + arrivedAt: row.arrivedAt, + now: input.now, + config: { + targetSeconds: config.targetSeconds, + hardLimitSeconds: config.hardLimitSeconds, + walkLimitSeconds: config.walkLimitSeconds, + submitGraceSeconds: config.submitGraceSeconds, + }, + criteria, + scores: input.scores, + }); + if (!decision.ok) throw new Error(decision.message); + + const duration = + row.arrivedAt === null + ? null + : Math.round((input.now.getTime() - row.arrivedAt.getTime()) / 1000); + + const [stored] = await tx + .insert(vote) + .values({ + visitId: row.id, + judgeId: input.judgeId, + projectId: row.projectId, + eventId: input.eventId, + total: decision.total, + comment: input.comment, + durationSeconds: duration, + isCalibration: false, + }) + .returning({ id: vote.id }); + if (!stored) throw new Error("Vote was not stored"); + + if (input.scores.length > 0) { + await tx.insert(voteScore).values( + input.scores.map((score) => ({ + voteId: stored.id, + criterionId: score.criterionId, + value: score.value, + })), + ); + } + await tx + .update(visit) + .set({ completedAt: input.now }) + .where(eq(visit.id, row.id)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "vote.cast", + actor: { judgeId: input.judgeId }, + subject: { visitId: row.id, projectId: row.projectId }, + payload: { total: decision.total }, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "vote.cast", + payload: { visitId: row.id, judgeId: input.judgeId, total: decision.total }, + }); + return { total: decision.total }; + }); +} + +async function loadCriteria( + tx: Parameters[0]>[0], + eventId: string, + judgeGroup: string, +): Promise { + const [groupTrack] = await tx + .select({ rubricId: track.rubricId }) + .from(track) + .where(and(eq(track.eventId, eventId), eq(track.judgeGroup, judgeGroup))); + const rubricId = groupTrack?.rubricId + ? groupTrack.rubricId + : ( + await tx + .select({ id: rubric.id }) + .from(rubric) + .where(and(eq(rubric.eventId, eventId), eq(rubric.isDefault, true))) + )[0]?.id; + if (!rubricId) return []; + const rows = await tx + .select() + .from(criterion) + .where(eq(criterion.rubricId, rubricId)); + return rows.map((row) => ({ + id: row.id, + min: row.min, + max: row.max, + weight: row.weight, + })); +} diff --git a/packages/judging-server/tsconfig.json b/packages/judging-server/tsconfig.json new file mode 100644 index 00000000..f54bde2c --- /dev/null +++ b/packages/judging-server/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tooling/typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/judging-server/vitest.config.ts b/packages/judging-server/vitest.config.ts new file mode 100644 index 00000000..f62398d7 --- /dev/null +++ b/packages/judging-server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + fileParallelism: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8907bf4d..ecbd1840 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,12 @@ importers: '@query/db': specifier: workspace:* version: link:../db + '@query/judging-db': + specifier: workspace:* + version: link:../judging-db + '@query/judging-server': + specifier: workspace:* + version: link:../judging-server '@tanstack/react-query': specifier: 5.103.2 version: 5.103.2(react@19.3.0) @@ -86,6 +92,9 @@ importers: specifier: 4.6.5 version: 4.6.5 devDependencies: + '@query/judging-core': + specifier: workspace:* + version: link:../judging-core '@query/tsconfig': specifier: workspace:* version: link:../../tooling/typescript @@ -188,6 +197,129 @@ importers: specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + packages/judging-cli: + dependencies: + '@query/judging-db': + specifier: workspace:* + version: link:../judging-db + '@query/judging-server': + specifier: workspace:* + version: link:../judging-server + drizzle-orm: + specifier: 0.45.3 + version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) + tsx: + specifier: ^4.23.15 + version: 4.23.15 + devDependencies: + '@query/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@query/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + typescript: + specifier: 7.0.2 + version: 7.0.2 + + packages/judging-core: + devDependencies: + '@query/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@query/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: ^5.0.1 + version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + + packages/judging-db: + dependencies: + '@query/judging-core': + specifier: workspace:* + version: link:../judging-core + drizzle-orm: + specifier: 0.45.3 + version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) + pg: + specifier: 8.23.0 + version: 8.23.0 + devDependencies: + '@query/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@query/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + '@types/pg': + specifier: ^8.23.1 + version: 8.23.1 + typescript: + specifier: 7.0.2 + version: 7.0.2 + + packages/judging-server: + dependencies: + '@query/judging-core': + specifier: workspace:* + version: link:../judging-core + '@query/judging-db': + specifier: workspace:* + version: link:../judging-db + '@trpc/server': + specifier: 11.19.0 + version: 11.19.0(typescript@7.0.2) + drizzle-orm: + specifier: 0.45.3 + version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) + hono: + specifier: ^4.9.0 + version: 4.13.13 + ioredis: + specifier: ^5.8.2 + version: 5.11.1 + jose: + specifier: ^6.1.0 + version: 6.2.3 + nodemailer: + specifier: ^10.0.10 + version: 10.0.10 + prom-client: + specifier: 15.1.3 + version: 15.1.3 + zod: + specifier: 4.6.5 + version: 4.6.5 + devDependencies: + '@query/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@query/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: ^5.0.1 + version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + packages/ui: dependencies: minimatch: @@ -1162,6 +1294,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2651,6 +2786,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2758,6 +2897,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + detect-libc@1.0.3: resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} engines: {node: '>=0.10'} @@ -3357,6 +3500,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hono@4.13.13: + resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} + engines: {node: '>=16.9.0'} + hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -3411,6 +3558,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -4204,6 +4355,14 @@ packages: resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==} engines: {node: '>=18'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4362,6 +4521,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -5326,6 +5488,8 @@ snapshots: '@img/sharp-win32-x64@0.35.4': optional: true + '@ioredis/commands@1.10.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -6748,6 +6912,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -6842,6 +7008,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + denque@2.1.0: {} + detect-libc@1.0.3: {} detect-libc@2.1.2: {} @@ -7526,6 +7694,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + hono@4.13.13: {} + hookified@1.15.1: {} hookified@2.2.0: {} @@ -7580,6 +7750,18 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.1 + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -8257,6 +8439,12 @@ snapshots: dependencies: minimatch: 10.2.6 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -8495,6 +8683,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + std-env@4.2.0: {} stop-iteration-iterator@1.1.0: diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index 805ed3c0..14e9d414 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useEffect, useMemo, useRef } from "react"; +import Link from "next/link"; import { loginHref } from "@/lib/safe-callback"; import { useSession } from "next-auth/react"; import { trpc } from "@/lib/trpc"; @@ -57,6 +58,11 @@ export default function AdminResultsPage() { ); // Get rankings for selected hackathon + const { data: panelConsole } = trpc.judge.panelConsole.useQuery( + { hackathonId: selectedHackathon as string }, + { enabled: !!selectedHackathon }, + ); + const { data: rankings } = trpc.judge.getRankings.useQuery( { hackathonId: selectedHackathon as string }, { enabled: !!selectedHackathon }, @@ -82,6 +88,9 @@ export default function AdminResultsPage() { // enforced: sync submissions, then build the queues. Doing them in the wrong // order leaves late projects in nobody's queue. const utils = trpc.useUtils(); + const setJudgingBackend = trpc.judge.setJudgingBackend.useMutation({ + onSuccess: () => utils.judge.panelConsole.invalidate(), + }); const [prepState, setPrepState] = useState<{ busy: boolean; message: string | null; @@ -251,6 +260,36 @@ export default function AdminResultsPage() { ? "Pick a hackathon to see its results." : "Open and close judging, watch the floor, and compare scores."}

+ {selectedHackathon && panelConsole ? ( +
+

+ {panelConsole.backend === "panel" + ? "Judged on panel: judges score from their phones." + : "Classic judging."} +

+ {panelConsole.url ? ( + + Open the panel console + + ) : null} + +
+ ) : null} {/* Judging Control Panel */} diff --git a/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/console.tsx b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/console.tsx new file mode 100644 index 00000000..197c95e1 --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/console.tsx @@ -0,0 +1,464 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { apiBase } from "@/lib/panel"; +import type { PublicEvent } from "@/lib/panel"; +import { Editors } from "./editors"; + +const phases = [ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", +] as const; + +export function Console({ event }: { event: PublicEvent }) { + const [message, setMessage] = useState(event.phase); + const [phase, setPhase] = useState(event.phase); + const [floor, setFloor] = useState<{ + tables: { tableNumber: number | null; looks: number }[]; + judges: { + name: string; + state: string; + overtime: boolean; + tableNumber: number | null; + }[]; + } | null>(null); + const [cards, setCards] = useState< + { + id: string; + name: string; + tableNumber: number | null; + zoneName: string | null; + }[] + >([]); + const [logs, setLogs] = useState<{ id: string; kind: string }[]>([]); + const [runId, setRunId] = useState(""); + const [inspected, setInspected] = useState< + { + projectId: string; + projectName: string; + trackId: string; + placement: number | null; + score: number; + rubricComponent: number; + pairwiseComponent: number; + }[] + >([]); + const [trackName, setTrackName] = useState(""); + const [judgeId, setJudgeId] = useState(""); + + async function post(path: string, body: unknown) { + const response = await fetch(`${apiBase}/trpc/${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const payload = (await response.json()) as { + error?: { message?: string }; + result?: { data?: { runId?: string; assigned?: number } }; + }; + if (!response.ok || payload.error) { + throw new Error(payload.error?.message ?? "Request failed"); + } + return payload.result?.data; + } + + useEffect(() => { + // The floor only moves while judging is live, and only matters on screen. + // Anything else would keep the database awake (see the public board). + if (phase !== "judging_live") return; + let stop = false; + let timer: ReturnType | undefined; + const pull = async () => { + const response = await fetch(`${apiBase}/v1/floor/${event.eventId}`); + if (!response.ok || stop) return; + setFloor( + (await response.json()) as { + tables: { tableNumber: number | null; looks: number }[]; + judges: { + name: string; + state: string; + overtime: boolean; + tableNumber: number | null; + }[]; + }, + ); + }; + const sync = () => { + clearInterval(timer); + if (document.visibilityState !== "visible") return; + void pull(); + timer = setInterval(() => void pull(), 5000); + }; + sync(); + document.addEventListener("visibilitychange", sync); + return () => { + stop = true; + clearInterval(timer); + document.removeEventListener("visibilitychange", sync); + }; + }, [phase, event.eventId]); + + return ( +
+

{event.name}

+

Current phase: {message}

+ {floor ? ( +
+

Floor

+
    + {floor.tables.map((table) => ( +
  • + {table.tableNumber}: {table.looks} +
  • + ))} +
+
    + {floor.judges.map((person) => ( +
  • + {person.name}: {person.state} + {person.tableNumber !== null + ? ` · table ${person.tableNumber}` + : ""} + {person.overtime ? " · overtime" : ""} +
  • + ))} +
+
+ ) : null} + {phases.map((phase) => ( + + ))} + + + + {inspected.length > 0 ? ( + + + + + + + + + + + + {inspected.map((row) => ( + + + + + + + + ))} + +
PlaceProjectScoreRubricPairwise
{row.placement}{row.projectName}{row.score}{row.rubricComponent}{row.pairwiseComponent}
+ ) : null} + + + + + + + + + +
    + {logs.map((row) => ( +
  • {row.kind}
  • + ))} +
+ +
+ {cards + .slice() + .sort( + (a, b) => + (a.zoneName ?? "").localeCompare(b.zoneName ?? "") || + (a.tableNumber ?? 0) - (b.tableNumber ?? 0), + ) + .map((card, index, all) => { + const zoneLabel = card.zoneName ?? "Unzoned"; + const previous = all[index - 1]; + const showZone = + !previous || (previous.zoneName ?? "Unzoned") !== zoneLabel; + return ( +
+ {showZone ?

{zoneLabel}

: null} +

+ {card.tableNumber} +

+

{card.name}

+
+ ); + })} +
+
+ ); +} diff --git a/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/editors.tsx b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/editors.tsx new file mode 100644 index 00000000..49de1196 --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/editors.tsx @@ -0,0 +1,426 @@ +"use client"; + +import { useState } from "react"; +import type { CSSProperties, PointerEvent } from "react"; +import { apiBase } from "@/lib/panel"; + +type Spot = { + number: number; + zoneId: string | null; + x: number | null; + y: number | null; +}; +type JudgeRow = { id: string; name: string; email: string; status: string }; + +function readValue(event: { target: EventTarget | null }) { + const target = event.target; + if (target && "value" in target) return String(target.value); + return ""; +} + +function placeOf(spot: Spot) { + return { + x: spot.x ?? (spot.number % 8) * 76, + y: spot.y ?? Math.floor((spot.number - 1) / 8) * 68, + }; +} + +export function Editors({ + eventId, + onMessage, +}: { + eventId: string; + onMessage: (message: string) => void; +}) { + const [rubricName, setRubricName] = useState(""); + const [criterionLabel, setCriterionLabel] = useState(""); + const [zoneName, setZoneName] = useState(""); + const [trackId, setTrackId] = useState(""); + const [prizeTitle, setPrizeTitle] = useState(""); + const [judgeEmail, setJudgeEmail] = useState(""); + const [judgeName, setJudgeName] = useState(""); + const [projectId, setProjectId] = useState(""); + const [tables, setTables] = useState([]); + const [judges, setJudges] = useState([]); + const [drag, setDrag] = useState<{ + number: number; + dx: number; + dy: number; + } | null>(null); + + async function post(path: string, body: unknown) { + const response = await fetch(`${apiBase}/trpc/${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const payload = (await response.json()) as { error?: { message?: string } }; + if (!response.ok || payload.error) { + throw new Error(payload.error?.message ?? "Request failed"); + } + } + + function moveTable(pointer: PointerEvent, spot: Spot) { + if (!drag || drag.number !== spot.number) return; + const parent = pointer.currentTarget.parentElement; + if (!parent) return; + const rect = parent.getBoundingClientRect(); + const x = Math.max(0, Math.round(pointer.clientX - rect.left - drag.dx)); + const y = Math.max(0, Math.round(pointer.clientY - rect.top - drag.dy)); + setTables((current) => + current.map((item) => + item.number === spot.number ? { ...item, x, y } : item, + ), + ); + } + + return ( +
+

Rubric

+ + + + +

Zone and floor

+ + + +
+ {tables.map((spot) => { + const at = placeOf(spot); + return ( + + ); + })} +
+ +

Prize

+ + + + +

Judges

+ + + + +
    + {judges.map((person) => ( +
  • + {person.name} · {person.status}{" "} + +
  • + ))} +
+ +

Withdraw a project

+ + +
+ ); +} + +const field: CSSProperties = { + display: "block", + width: "100%", + margin: "0.25rem 0 0.75rem", +}; + +const floor: CSSProperties = { + position: "relative", + height: 420, + margin: "1rem 0", + background: "#f5f5f4", + border: "1px solid #d6d3d1", +}; + +const chip: CSSProperties = { + position: "absolute", + width: 64, + height: 48, + background: "white", + border: "1px solid #1c1917", +}; diff --git a/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/page.tsx b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/page.tsx new file mode 100644 index 00000000..92ca5b08 --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/page.tsx @@ -0,0 +1,16 @@ +import { Console } from "./console"; +import { loadEvent } from "@/lib/panel.server"; + +export default async function OrganizerPage({ + params, +}: { + params: Promise<{ hackathonId: string }>; +}) { + const { hackathonId } = await params; + const event = await loadEvent(hackathonId); + if (!event) + return ( +
This event is not available.
+ ); + return ; +} diff --git a/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts b/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts new file mode 100644 index 00000000..982485c6 --- /dev/null +++ b/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts @@ -0,0 +1,27 @@ +import { panel } from "@query/api/panel"; + +/** + * The judging API, served by this app. Routes are written against `/`, so the + * prefix comes off before the request reaches them. Callers are identified by + * their portal session. + */ +const PREFIX = "/api/panel"; + +async function handler(req: Request) { + const url = new URL(req.url); + url.pathname = url.pathname.slice(PREFIX.length) || "/"; + // Not public: process metrics, and a readiness probe that queries Postgres. + // An uptime checker on /readyz would keep Neon awake around the clock. + if (url.pathname === "/metrics" || url.pathname === "/readyz") { + return new Response(null, { status: 404 }); + } + const body = + req.method === "GET" || req.method === "HEAD" + ? undefined + : await req.arrayBuffer(); + return panel().app.fetch( + new Request(url, { method: req.method, headers: req.headers, body }), + ); +} + +export { handler as GET, handler as POST }; diff --git a/sites/mainweb/app/(portal)/judge/page.tsx b/sites/mainweb/app/(portal)/judge/page.tsx index 8a61fae2..43bf60c0 100644 --- a/sites/mainweb/app/(portal)/judge/page.tsx +++ b/sites/mainweb/app/(portal)/judge/page.tsx @@ -55,6 +55,9 @@ export default function JudgePage() { // Gated on any approved application, not isJudge: isJudge answers for the // current edition only, while assignments span every edition, so a judge // approved for another one saw "Awaiting approval" forever. + const { data: panelDesk } = trpc.judge.panelDesk.useQuery(undefined, { + enabled: !!session, + }); const { data: assignments } = trpc.judge.getMyAssignments.useQuery( undefined, { @@ -112,6 +115,11 @@ export default function JudgePage() { Apply to judge an event. An organiser approves you before you can score.

+ {panelDesk?.url ? ( + + Open the judging desk + + ) : null} {!hackathons?.length ? ( diff --git a/sites/mainweb/app/(portal)/judge/panel/[hackathonId]/desk.tsx b/sites/mainweb/app/(portal)/judge/panel/[hackathonId]/desk.tsx new file mode 100644 index 00000000..b748cefc --- /dev/null +++ b/sites/mainweb/app/(portal)/judge/panel/[hackathonId]/desk.tsx @@ -0,0 +1,506 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { CSSProperties } from "react"; +import { apiBase } from "@/lib/panel"; +import type { PublicEvent } from "@/lib/panel"; +import { flushHeld, holdVote } from "@/lib/panel-held"; + +type Visit = { + done: boolean; + reused?: boolean; + visitId?: string; + projectName?: string; + tableNumber?: number | null; + arrivedAt?: string | null; + handedOutAt?: string; +}; + +type Anchor = { label?: string; value?: number }; +type CriterionRow = { + id: string; + label: string; + min: number; + max: number; + anchors?: Anchor[]; +}; + +export function JudgeDesk({ + event, + name, +}: { + event: PublicEvent; + name: string; +}) { + const [visit, setVisit] = useState(null); + const [criteria, setCriteria] = useState([]); + const [scores, setScores] = useState>({}); + const [tableNumber, setTableNumber] = useState(""); + const [message, setMessage] = useState(""); + const [progress, setProgress] = useState(""); + const [now, setNow] = useState(() => Date.now()); + const [scanning, setScanning] = useState(false); + const videoRef = useRef(null); + const accent = event.branding.colors?.accent ?? "#1c1917"; + + async function loadProgress() { + const response = await fetch( + `${apiBase}/v1/session/progress?eventId=${event.eventId}`, + ); + if (!response.ok) return; + const row = (await response.json()) as { completed: number; total: number }; + setProgress(`${row.completed} of ${row.total} scored`); + } + + useEffect(() => { + void fetch(`${apiBase}/v1/session/rubric?eventId=${event.eventId}`) + .then((response) => response.json()) + .then(async (rows: CriterionRow[]) => { + if (!Array.isArray(rows)) { + setMessage("You are not an approved judge for this event yet."); + return; + } + setCriteria(rows); + await loadProgress(); + }) + .catch(() => setMessage("Could not load the rubric.")); + // loadProgress only reads event.eventId. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [event.eventId]); + + async function call(path: string, body?: unknown) { + const response = await fetch(`${apiBase}${path}`, { + method: body ? "POST" : "GET", + headers: { "content-type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); + const payload = (await response.json().catch(() => ({}))) as { + message?: string; + }; + if (!response.ok) { + throw Object.assign(new Error(payload.message ?? "Request failed"), { + status: response.status, + }); + } + return payload; + } + + async function compare(outcome: "a" | "b" | "tie") { + try { + await call("/v1/session/compare", { eventId: event.eventId, outcome }); + setMessage( + outcome === "b" + ? "This table is better." + : outcome === "a" + ? "The previous table was better." + : "Recorded as about the same.", + ); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Could not compare"); + } + } + + useEffect(() => { + const send = () => { + void flushHeld((path, payload) => + fetch(`${apiBase}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }).then((response) => { + // A refusal (past the hard limit, a voided visit) is final; only an + // outage keeps the score on the phone for another try. + if (response.status >= 500) throw new Error("still offline"); + }), + ) + .then((sent) => { + if (sent > 0) { + setMessage( + `Sent ${sent} score${sent === 1 ? "" : "s"} held on this phone.`, + ); + } + }) + .catch(() => undefined); + }; + send(); + window.addEventListener("online", send); + return () => window.removeEventListener("online", send); + }, []); + + const openVisitId = visit && !visit.done ? visit.visitId : undefined; + const acknowledgedRecall = useRef(null); + useEffect(() => { + // Recall and void are the only things an organizer sends a judge. Ask only + // while a visit is open and the screen is on: judging is live then, so the + // database is awake anyway, and a phone in a pocket costs nothing. + if (!openVisitId) return; + let stop = false; + let timer: ReturnType | undefined; + const check = async () => { + const response = await fetch( + `${apiBase}/v1/session/status?eventId=${event.eventId}&visitId=${openVisitId}`, + ); + if (!response.ok || stop) return; + const status = (await response.json()) as { + voided: boolean; + recalledAt: string | null; + }; + if (status.voided) { + setVisit(null); + setMessage("This visit was voided."); + return; + } + if ( + status.recalledAt && + status.recalledAt !== acknowledgedRecall.current + ) { + acknowledgedRecall.current = status.recalledAt; + setVisit(null); + setMessage("Come back to the desk."); + } + }; + const sync = () => { + clearInterval(timer); + if (document.visibilityState !== "visible") return; + void check(); + timer = setInterval(() => void check(), 15_000); + }; + sync(); + document.addEventListener("visibilitychange", sync); + return () => { + stop = true; + clearInterval(timer); + document.removeEventListener("visibilitychange", sync); + }; + }, [openVisitId, event.eventId]); + + const walkingSince = + visit?.handedOutAt && !visit.arrivedAt && !visit.done + ? new Date(visit.handedOutAt).getTime() + : null; + useEffect(() => { + if (walkingSince === null) return; + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [walkingSince]); + const walkedSeconds = + walkingSince === null + ? 0 + : Math.max(0, Math.floor((now - walkingSince) / 1000)); + const elapsed = + walkingSince === null + ? "" + : `${Math.floor(walkedSeconds / 60)}:${String(walkedSeconds % 60).padStart(2, "0")} walking`; + + return ( +
+

+ {event.orgName} +

+

{event.name}

+

{event.branding.tagline}

+

Phase: {event.phase}

+ +
+ + + {progress ?

{progress}

: null} + {visit && !visit.done ? ( + <> +

+ {visit.projectName} · table {visit.tableNumber} +

+ {elapsed ?

{elapsed}

: null} +
+ {message ?

{message}

: null} +
+ ); +} + +function readValue(event: { target: EventTarget | null }) { + const target = event.target; + if (target && "value" in target) return String(target.value); + return ""; +} + +const field: CSSProperties = { + display: "block", + width: "100%", + margin: "0.25rem 0 0.75rem", + padding: "0.75rem", + fontSize: "1.1rem", +}; + +const button: CSSProperties = { + display: "block", + width: "100%", + margin: "0.5rem 0", + padding: "0.9rem", + color: "white", + background: "#1c1917", + border: 0, + fontSize: "1rem", +}; diff --git a/sites/mainweb/app/(portal)/judge/panel/[hackathonId]/page.tsx b/sites/mainweb/app/(portal)/judge/panel/[hackathonId]/page.tsx new file mode 100644 index 00000000..7ccd0db1 --- /dev/null +++ b/sites/mainweb/app/(portal)/judge/panel/[hackathonId]/page.tsx @@ -0,0 +1,24 @@ +import { redirect } from "next/navigation"; +import { portalActor } from "@query/api/panel"; +import { JudgeDesk } from "./desk"; +import { loadEvent } from "@/lib/panel.server"; + +export default async function JudgePage({ + params, +}: { + params: Promise<{ hackathonId: string }>; +}) { + const { hackathonId } = await params; + const actor = await portalActor(); + if (!actor) { + redirect( + `/login?callbackUrl=${encodeURIComponent(`/judge/panel/${hackathonId}`)}`, + ); + } + const event = await loadEvent(hackathonId); + if (!event) + return ( +
This event is not available.
+ ); + return ; +} diff --git a/sites/mainweb/app/judging/[hackathonId]/board.tsx b/sites/mainweb/app/judging/[hackathonId]/board.tsx new file mode 100644 index 00000000..c94909b5 --- /dev/null +++ b/sites/mainweb/app/judging/[hackathonId]/board.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { apiBase } from "@/lib/panel"; + +type Snapshot = { + eventId: string; + phase: string; + pollSeconds: number; + projects: { id: string; tableNumber: number | null; looks: number }[]; + placements: { + projectId: string; + projectName?: string; + placement: number | null; + }[]; + prizes: { + title: string; + place: number; + track: string; + amount: number | null; + }[]; +}; + +export function LiveBoard({ + orgSlug, + eventSlug, +}: { + orgSlug: string; + eventSlug: string; +}) { + const [snapshot, setSnapshot] = useState(null); + + const [reload, setReload] = useState(0); + + useEffect(() => { + let stop = false; + let timer: ReturnType | undefined; + + const pull = async () => { + clearTimeout(timer); + const response = await fetch( + `${apiBase}/v1/live/${orgSlug}/${eventSlug}`, + ); + if (!response.ok || stop) return; + const next = (await response.json()) as Snapshot; + setSnapshot(next); + // Only live judging changes minute to minute. Polling any other phase, + // or a tab nobody is looking at, keeps the database awake for nothing: + // Neon suspends only after five idle minutes, and those minutes are the + // monthly compute allowance. + if ( + next.phase === "judging_live" && + document.visibilityState === "visible" + ) { + timer = setTimeout( + () => void pull(), + Math.max(next.pollSeconds || 5, 5) * 1000, + ); + } + }; + const onVisible = () => { + if (document.visibilityState === "visible") void pull(); + }; + + void pull(); + document.addEventListener("visibilitychange", onVisible); + return () => { + stop = true; + clearTimeout(timer); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [orgSlug, eventSlug, reload]); + + if (!snapshot) return

Loading the floor.

; + return ( +
+

+ {snapshot.phase} + {snapshot.phase === "judging_live" ? null : ( + <> + {" · "} + + + )} +

+ {(snapshot.prizes ?? []).length > 0 ? ( + <> +

Prizes

+
    + {(snapshot.prizes ?? []).map((row) => ( +
  • + {row.track}: {row.place}. {row.title} + {row.amount !== null ? ` (${row.amount})` : ""} +
  • + ))} +
+ + ) : null} + {snapshot.phase === "published" ? ( +
    + {snapshot.placements.map((row) => ( +
  1. + {row.placement}. {row.projectName || row.projectId} +
  2. + ))} +
+ ) : ( +
    + {snapshot.projects.map((row) => ( +
  • + Table {row.tableNumber}: {row.looks} looks +
  • + ))} +
+ )} +
+ ); +} diff --git a/sites/mainweb/app/judging/[hackathonId]/feedback/[token]/page.tsx b/sites/mainweb/app/judging/[hackathonId]/feedback/[token]/page.tsx new file mode 100644 index 00000000..59aae1bf --- /dev/null +++ b/sites/mainweb/app/judging/[hackathonId]/feedback/[token]/page.tsx @@ -0,0 +1,57 @@ +import { panelRequest } from "@/lib/panel.server"; + +export default async function FeedbackPage({ + params, +}: { + params: Promise<{ token: string }>; +}) { + const { token } = await params; + const response = await panelRequest( + `/v1/feedback/${encodeURIComponent(token)}`, + ); + if (response.status === 403) { + return ( +
+ Feedback is available after results are published, and only with this + project's link. +
+ ); + } + if (!response.ok) { + return ( +
+ This feedback link is not available. +
+ ); + } + const card = (await response.json()) as { + name: string; + criteria: { label: string; mean: number; median: number }[]; + places: { track: string; placement: number | null }[]; + comments: string[]; + }; + return ( +
+

{card.name}

+
    + {card.places.map((item) => ( +
  • + {item.track}:{" "} + {item.placement === null ? "not placed" : item.placement} +
  • + ))} +
+
    + {card.criteria.map((item) => ( +
  • + {item.label}: {item.mean.toFixed(1)} versus a median of{" "} + {item.median.toFixed(1)} +
  • + ))} +
+ {card.comments.map((comment, index) => ( +

{comment}

+ ))} +
+ ); +} diff --git a/sites/mainweb/app/judging/[hackathonId]/page.tsx b/sites/mainweb/app/judging/[hackathonId]/page.tsx new file mode 100644 index 00000000..7703f5fb --- /dev/null +++ b/sites/mainweb/app/judging/[hackathonId]/page.tsx @@ -0,0 +1,27 @@ +import { LiveBoard } from "./board"; +import { PANEL_ORG, loadEvent } from "@/lib/panel.server"; + +export default async function PublicBoard({ + params, +}: { + params: Promise<{ hackathonId: string }>; +}) { + const { hackathonId } = await params; + const event = await loadEvent(hackathonId); + if (!event) + return ( +
This event is not available.
+ ); + return ( +
+

{event.orgName}

+

{event.name}

+

+ {event.phase === "published" + ? "Results are published." + : "Judging is in progress."} +

+ +
+ ); +} diff --git a/sites/mainweb/lib/panel-held.ts b/sites/mainweb/lib/panel-held.ts new file mode 100644 index 00000000..40c3811b --- /dev/null +++ b/sites/mainweb/lib/panel-held.ts @@ -0,0 +1,59 @@ +type HeldVote = { + id: string; + path: string; + body: unknown; +}; + +const DB_NAME = "panel"; +const STORE = "votes"; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => { + request.result.createObjectStore(STORE, { keyPath: "id" }); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +export async function holdVote(body: { visitId?: string; eventId: string }) { + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).put({ + id: body.visitId ?? crypto.randomUUID(), + path: "/v1/session/vote", + body, + } satisfies HeldVote); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); +} + +export async function flushHeld( + send: (path: string, body: unknown) => Promise, +): Promise { + const db = await openDb(); + const rows = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readonly"); + const request = tx.objectStore(STORE).getAll(); + request.onsuccess = () => resolve(request.result as HeldVote[]); + request.onerror = () => reject(request.error); + }); + let sent = 0; + for (const row of rows) { + await send(row.path, row.body); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).delete(row.id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + sent += 1; + } + db.close(); + return sent; +} diff --git a/sites/mainweb/lib/panel.server.ts b/sites/mainweb/lib/panel.server.ts new file mode 100644 index 00000000..257d221a --- /dev/null +++ b/sites/mainweb/lib/panel.server.ts @@ -0,0 +1,20 @@ +import { PANEL_ORG, panel } from "@query/api/panel"; +import type { PublicEvent } from "./panel"; + +/** Calls the judging API in process, the same routes the browser reaches at /api/panel. */ +export async function panelRequest(path: string): Promise { + return panel().app.request(path); +} + +/** The judging event for a hackathon edition, or null before it has one. */ +export async function loadEvent( + hackathonId: string, +): Promise { + const response = await panelRequest( + `/v1/public/${PANEL_ORG.slug}/${encodeURIComponent(hackathonId)}`, + ); + if (!response.ok) return null; + return response.json() as Promise; +} + +export { PANEL_ORG }; diff --git a/sites/mainweb/lib/panel.ts b/sites/mainweb/lib/panel.ts new file mode 100644 index 00000000..79095225 --- /dev/null +++ b/sites/mainweb/lib/panel.ts @@ -0,0 +1,10 @@ +/** Judging runs inside this app; its API is mounted here. */ +export const apiBase = "/api/panel"; + +export type PublicEvent = { + eventId: string; + name: string; + phase: string; + branding: { tagline?: string; colors?: { accent?: string } }; + orgName: string; +}; diff --git a/sites/mainweb/next.config.mjs b/sites/mainweb/next.config.mjs index c329b835..7dd94ff7 100644 --- a/sites/mainweb/next.config.mjs +++ b/sites/mainweb/next.config.mjs @@ -58,7 +58,15 @@ const cspHeaderName = const nextConfig = { output: "standalone", reactCompiler: true, - transpilePackages: ["@query/api", "@query/auth", "@query/db", "@query/ui"], + transpilePackages: [ + "@query/api", + "@query/auth", + "@query/db", + "@query/judging-core", + "@query/judging-db", + "@query/judging-server", + "@query/ui", + ], outputFileTracingRoot: path.join(__dirname, "../../"), // Native/dynamic requires that a bundler mangles. Left external so the // standalone output loads them from node_modules at runtime. diff --git a/turbo.json b/turbo.json index 9a01a7cf..51ec52db 100644 --- a/turbo.json +++ b/turbo.json @@ -94,6 +94,11 @@ "NODE_ENV", "CI", "npm_lifecycle_event", - "JUDGING_TEST_DATABASE_URL" + "JUDGING_TEST_DATABASE_URL", + "PANEL_DATABASE_URL", + "PANEL_JWT_SECRET", + "PANEL_SMTP_URL", + "PANEL_SMTP_FROM", + "REDIS_URL" ] }