From aaad2da573abe51dbbbba54e0462b4d409fc863a Mon Sep 17 00:00:00 2001 From: aamoghS Date: Wed, 7 Oct 2026 18:17:20 -0400 Subject: [PATCH 1/8] feat(panel): start a standalone judging core Ranking and dispatch have to be extractable later, so they live under judging/ as pure functions with no host imports. Co-authored-by: Cursor --- PLAN.md | 454 ++++++++++++++++++ judging/LICENSE | 21 + judging/README.md | 32 ++ judging/core/eslint.config.mjs | 45 ++ judging/core/package.json | 22 + judging/core/src/aggregate/bayes.test.ts | 27 ++ judging/core/src/aggregate/bayes.ts | 19 + judging/core/src/aggregate/blend.test.ts | 18 + judging/core/src/aggregate/blend.ts | 11 + .../core/src/aggregate/bradleyTerry.test.ts | 50 ++ judging/core/src/aggregate/bradleyTerry.ts | 72 +++ judging/core/src/aggregate/rank.test.ts | 204 ++++++++ judging/core/src/aggregate/rank.ts | 304 ++++++++++++ judging/core/src/aggregate/zscore.test.ts | 79 +++ judging/core/src/aggregate/zscore.ts | 22 + judging/core/src/comparison.ts | 5 + judging/core/src/dispatch.test.ts | 139 ++++++ judging/core/src/dispatch.ts | 128 +++++ judging/core/src/distance.test.ts | 31 ++ judging/core/src/distance.ts | 30 ++ judging/core/src/events.test.ts | 34 ++ judging/core/src/events.ts | 55 +++ judging/core/src/index.ts | 54 +++ judging/core/src/match.test.ts | 21 + judging/core/src/match.ts | 20 + judging/core/src/package.test.ts | 14 + judging/core/src/phase.test.ts | 59 +++ judging/core/src/phase.ts | 63 +++ judging/core/src/rubric.test.ts | 67 +++ judging/core/src/rubric.ts | 85 ++++ judging/core/src/timers.test.ts | 88 ++++ judging/core/src/timers.ts | 74 +++ judging/core/src/uncertainty.test.ts | 66 +++ judging/core/src/uncertainty.ts | 66 +++ judging/core/tsconfig.json | 10 + judging/core/vitest.config.ts | 7 + judging/docs/adr/001-standalone.md | 22 + judging/docs/adr/002-pure-core.md | 22 + package.json | 2 +- pnpm-lock.yaml | 18 + pnpm-workspace.yaml | 1 + 41 files changed, 2560 insertions(+), 1 deletion(-) create mode 100644 PLAN.md create mode 100644 judging/LICENSE create mode 100644 judging/README.md create mode 100644 judging/core/eslint.config.mjs create mode 100644 judging/core/package.json create mode 100644 judging/core/src/aggregate/bayes.test.ts create mode 100644 judging/core/src/aggregate/bayes.ts create mode 100644 judging/core/src/aggregate/blend.test.ts create mode 100644 judging/core/src/aggregate/blend.ts create mode 100644 judging/core/src/aggregate/bradleyTerry.test.ts create mode 100644 judging/core/src/aggregate/bradleyTerry.ts create mode 100644 judging/core/src/aggregate/rank.test.ts create mode 100644 judging/core/src/aggregate/rank.ts create mode 100644 judging/core/src/aggregate/zscore.test.ts create mode 100644 judging/core/src/aggregate/zscore.ts create mode 100644 judging/core/src/comparison.ts create mode 100644 judging/core/src/dispatch.test.ts create mode 100644 judging/core/src/dispatch.ts create mode 100644 judging/core/src/distance.test.ts create mode 100644 judging/core/src/distance.ts create mode 100644 judging/core/src/events.test.ts create mode 100644 judging/core/src/events.ts create mode 100644 judging/core/src/index.ts create mode 100644 judging/core/src/match.test.ts create mode 100644 judging/core/src/match.ts create mode 100644 judging/core/src/package.test.ts create mode 100644 judging/core/src/phase.test.ts create mode 100644 judging/core/src/phase.ts create mode 100644 judging/core/src/rubric.test.ts create mode 100644 judging/core/src/rubric.ts create mode 100644 judging/core/src/timers.test.ts create mode 100644 judging/core/src/timers.ts create mode 100644 judging/core/src/uncertainty.test.ts create mode 100644 judging/core/src/uncertainty.ts create mode 100644 judging/core/tsconfig.json create mode 100644 judging/core/vitest.config.ts create mode 100644 judging/docs/adr/001-standalone.md create mode 100644 judging/docs/adr/002-pure-core.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..00cca049 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,454 @@ +# Judging platform plan + +**Repo:** `query` · **Drafted:** 2026-10-07 · **Working name:** `panel` (rename freely) + +A standalone hackathon judging platform, built inside this monorepo under +`judging/`, with no knowledge of DSGT in it. Hacklytics is its first user +through an adapter. When it is stable it leaves this repo with +`git subtree split --prefix=judging` and ships as an open-source project with +its own licence, docs, CI and Docker image. + +The current judging code in `packages/api/src/routers/judge` and +`sites/mainweb` keeps running until cutover, then is deleted. + +--- + +## 0. Why build it this way + +**What exists** works for one event: pool dispatch with QR arrival +(`dispatch.ts`), per-judge z-scores with a Bayesian shrink (`rankings.ts`), +frozen results, printable table cards. It is also welded to one edition: a +five-column rubric in `judge_vote`, timers as constants, tracks as `text[]`, +a `"createx"` string in the matcher, one results track, table distance as +`abs(n1 - n2)`, auth and roles borrowed from the club portal, cache +per-instance and polling for every live view. + +**What "real infrastructure" means here** + +1. **A product, not a feature.** Own data model, own API, own UI, own deploy. + Anyone with Postgres can run it. The club site is a client of it. +2. **Everything is data.** Rubric, tracks, prizes, timers, rooms, scoring + weights, dispatch strategy, branding: rows and config, never code. +3. **Correct under load and partition.** Advisory locks and unique indexes for + every write that matters; realtime as a convenience layer that can be + switched off without losing correctness. +4. **Boring and portable.** Postgres, Node, one Docker image, optional Redis. + No vendor SDK in the core. Cloud bits live behind adapters. +5. **Pure core.** Every decision (next table, score, rank) is a function from + plain data to plain data, tested without a database. + +--- + +## 1. Layout + +``` +judging/ + core/ @panel/core pure TS, zero runtime deps. rubric validation, + dispatch, distance, uncertainty, aggregators, + phase state machine, timers + db/ @panel/db Drizzle schema, SQL migrations (generated, + committed), seed, demo data + server/ @panel/server Node service: tRPC over HTTP, OpenAPI mirror, + WebSocket hub, auth adapters, outbox + webhooks, + Prometheus metrics + web/ @panel/web Next.js: judge PWA, admin console, live board, + public leaderboard, team feedback + cli/ @panel/cli import/export/seed/migrate/doctor + infra/ + docker/ Dockerfiles, docker-compose.yml (full stack) + gcp/ optional Terraform module (Cloud Run, Memorystore, + Secret Manager, Monitoring) + docs/ architecture, ADRs, deploy, API, rubric guide + README.md LICENSE (MIT) CONTRIBUTING.md CHANGELOG.md +``` + +Registered as workspaces in `pnpm-workspace.yaml`. Internal imports are +`@panel/*` only; nothing under `judging/` imports from `packages/*` or +`sites/*`. A lint rule (`no-restricted-imports`) enforces the boundary, which is +what makes the later extraction a one-command job. + +The reverse direction, `packages/api` importing `@panel/*`, is allowed and is +how the club site integrates (§7). + +--- + +## 2. Domain model (`@panel/db`) + +Multi-tenant from day one so one deployment can host many events; a +single-event self-host simply has one org and one event. + +``` +organization id, slug, name, branding jsonb (logo, colors, tagline) +event id, org_id, slug, name, starts_at, ends_at, + phase enum (setup | submissions_open | submissions_closed | + judging_live | judging_closed | published | archived) +event_config event_id pk, target_seconds, hard_limit_seconds, + walk_limit_seconds, submit_grace_seconds, + min_looks_per_project, dispatch_strategy (coverage|uncertainty), + pairwise_enabled, pairwise_weight, bayesian_c, + calibration_looks, calibration_weight, + feedback_cards_enabled, leaderboard_public bool, + board_poll_seconds (fallback when ws is off) + +track id, event_id, slug, name, kind (main|sponsor|special), + judge_group text, rubric_id null, position, is_active +prize id, track_id, place, title, amount, description + +rubric id, event_id, name, is_default +criterion id, rubric_id, position, key, label, description, + min, max, weight, anchors jsonb + +zone id, event_id, name, position +table event_id, number, zone_id, x null, y null unique(event_id, number) + +project id, event_id, external_id null, name, description, + team_name, members jsonb, links jsonb, table_number null, + zone_id null, qr_token uuid unique, arrived_first_at, + withdrawn_at +project_track project_id, track_id + +judge id, event_id, external_id null, name, email, org, title, + status (invited|applied|approved|suspended), + track_id null, zone_id null, is_lead + unique(event_id, email) + +visit id, event_id, judge_id, project_id, judge_group, + handed_out_at, arrived_at null, completed_at null, + voided_at null, void_reason null +vote id, visit_id unique, judge_id, project_id, event_id, + total numeric, comment, duration_seconds, is_calibration +vote_score vote_id, criterion_id, value unique(vote_id, criterion_id) +comparison id, event_id, judge_id, judge_group, a_project_id, + b_project_id, outcome (a|b|tie), created_at + +result_run id, event_id, computed_at, computed_by, config_snapshot jsonb, + published_at null, notes +result run_id, project_id, track_id, placement, score numeric, + rubric_component, pairwise_component, vote_count, + comparison_count, flags text[] + unique(run_id, project_id, track_id) + +event_log id, event_id, kind, actor jsonb, subject jsonb, payload jsonb, + created_at -- append only +outbox id, event_id, topic, payload jsonb, created_at, + delivered_at null, attempts +webhook id, org_id, url, secret, topics text[], is_active +api_key id, org_id, hashed_key, scopes text[], created_at, revoked_at + +user id, email, name -- platform identities +membership user_id, org_id, role (owner|admin|organizer|volunteer) +judge_identity judge_id, user_id -- which login is which judge +``` + +Design notes: + +- `visit` replaces `judge_queue`: it is a record of a hand-out, not a plan. + A visit without a vote past the hard limit is voided, not deleted. +- `vote.total` is derived at write time from `vote_score` and the criterion + weights; the score rows are the truth. +- `result_run` snapshots the config used, so a result is reproducible and an + admin can compute twice and compare before publishing one. +- `external_id` on project and judge is how an integrator (the club site, a + Devpost CSV, a Google Form) keeps its own identity in sync. +- Migrations are generated SQL files committed under `judging/db/migrations`, + applied with `panel migrate`. No `drizzle-kit push` in this product; a + self-hoster needs reproducible, reviewable migrations. + +--- + +## 3. The core (`@panel/core`) + +No IO, no dates from the clock (every function takes `now`), no randomness +without an injected source. Each module has a fixture-based test file. + +- `phase.ts` state machine: allowed transitions, what each phase permits + (submit, apply, dispatch, vote, compute, publish). Server checks it on + every mutation. +- `rubric.ts` `validateScores(criteria, input)`, `weightedTotal(criteria, scores)`. +- `timers.ts` `isLive`, `isPastCutoff`, taking the config. +- `distance.ts` `distance(a, b, zones)`: same zone `abs(n1 - n2)`; different + zone a large constant plus the diff; Euclidean when `x, y` set. +- `dispatch.ts` `pickNext(candidates, judge, config, random)` with strategies: + - `coverage`: fewest looks, nearest, random (today's algorithm). + - `uncertainty`: coverage until `min_looks_per_project` everywhere, then + highest rank-uncertainty first. Uncertainty = variance of normalised + scores / sqrt(votes), plus a bonus for contested pairwise neighbours. +- `aggregate/zscore.ts`, `aggregate/bayes.ts`, `aggregate/bradleyTerry.ts`, + `aggregate/blend.ts`, `aggregate/rank.ts` composing them per track and + judge group, honouring calibration down-weighting. +- `uncertainty.ts` per-project vector, consumed by dispatch. +- `events.ts` typed `kind` union for the log, outbox topics and ws messages, + shared by server and web. + +Published to npm on release so anyone can run the ranking offline on an +export. + +--- + +## 4. Server (`@panel/server`) + +Node 22, Hono as the HTTP shell, tRPC router mounted on it, `trpc-openapi` +exposing the same procedures as REST with a generated OpenAPI document for +non-TypeScript integrators. Single process; horizontal scale is stateless +plus Redis. + +**Routers** + +- `org`, `event`, `config`, `track`, `prize`, `rubric`, `zone`, `table`: + organizer CRUD, phase-gated. +- `project`: import (API, CSV via CLI), assign tables (per zone, by track + balance), withdraw, QR token rotate. +- `judge`: invite, apply, approve, suspend, assign track and zone. +- `session` (judge-facing): `next`, `arrive(qrToken)`, `vote`, `compare`, + `skip`, `progress`, `rubric`. +- `results`: `compute` (new `result_run`), `diff(runA, runB)`, `publish`, + `unpublish`, `export`. +- `live`: snapshot for the admin board and public leaderboard. +- `feedback`: a team's card after publish. +- `webhook`, `apiKey`: integrator management. + +**Concurrency** + +- `pg_advisory_xact_lock(hashtext('dispatch:' || event_id))` around `next`, + exactly as today. Dispatch is milliseconds and runs a few times a minute + even at 60 judges. +- Unique indexes on `(judge_id, project_id)` across live visits (partial + index where `voided_at is null`), on `vote.visit_id`, on + `(run_id, project_id, track_id)`. +- Every mutation writes its `event_log` row and any `outbox` row in the same + transaction. The outbox is drained by the same process on a short loop (no + external scheduler) and by `panel outbox drain` for operators who want cron. + +**Realtime** + +- WebSocket hub on `/ws` (`ws` library). Clients subscribe to channels: + `event:{id}:board`, `event:{id}:leaderboard`, `judge:{id}`. +- Fan-out adapter interface: `InMemoryBus` (single instance, default) and + `RedisBus` (ioredis pub/sub) selected by `REDIS_URL`. Correctness never + depends on the bus; a client that misses a message gets the same state on + its next snapshot, and the web falls back to polling at `board_poll_seconds` + when the socket is down. +- Server pushes: dispatch handed out, arrival, vote landed, judge overtime, + phase change, results published. Admin can push `recall` to a judge + (bring them back to the desk) and `void` a visit. + +**Auth adapters** (`server/src/auth/`) + +- `magic-link`: email code, built in, zero config beyond SMTP. +- `oidc`: any provider (Google, GitHub, Okta) via `openid-client`. +- `trusted-jwt`: an integrator (the club site) mints a short-lived JWT with + `{ sub, email, name, org, role, judge_external_id }`; the server verifies + with a shared JWKS. This is how mainweb users land in the judge PWA with + no second login. +- `api-key`: for machine integrators (import, export, webhooks). + +Roles: `owner`, `admin`, `organizer`, `volunteer` on the org; `judge` is a +row on the event, not a role, and `is_lead` is a flag. Every procedure +declares the minimum role; there is no unguarded "protected" alias, the lesson +from `../PLAN.md` W8. + +**Observability** + +- `/metrics` Prometheus: dispatch latency, votes per minute, live judges, + coverage histogram per event, ws connections, outbox lag. +- Structured JSON logs with `event_id`, `judge_id`, `visit_id` on every line. +- `/healthz` (process), `/readyz` (Postgres, bus). + +--- + +## 5. Web (`@panel/web`) + +Next.js app, standalone output, one Docker image. Branding from +`organization.branding`; no hardcoded name or colours. + +- **Judge PWA** (`/j/[eventSlug]`): installable, works on a phone with one + hand. Screens: queue/next, walking (shows table, zone and a count-up), + arrive by camera QR or typed table number, score (criteria rendered from + rubric rows, anchors as tap targets, comment), compare (previous table vs + this one, one tap), done. Scores are held in IndexedDB until the server + acknowledges, so a dead Wi-Fi corner loses nothing; the server rejects a + late vote past the cutoff and the client shows why. +- **Admin console** (`/o/[orgSlug]/[eventSlug]`): phase control, rubric and + track editors, zone and table layout (grid with drag to set `x, y`), + judges (invite, approve, assign, recall), projects (import, tables, + withdraw), table cards print view per zone, live board (coverage heatmap by + table, judges on the floor, overtime, idle), results (compute, diff two + runs, inspect a project's rubric and pairwise components, publish), logs. +- **Public** (`/e/[orgSlug]/[eventSlug]`): leaderboard after publish, live + "judging in progress" board if `leaderboard_public` (no scores, just + coverage and phase), prize list per track. +- **Team feedback** (`/e/.../feedback/[token]`): per-criterion mean vs event + median, anonymised comments, placement per track or "not placed". Token + minted per project at publish; integrators can fetch it by `external_id`. + +--- + +## 6. CLI (`@panel/cli`) + +`panel migrate`, `panel seed demo` (an event with 40 projects, 6 judges, +three tracks, a sponsor rubric), `panel import projects ` (column map in +a yaml; Devpost export supported out of the box), `panel export results + --format csv|json`, `panel outbox drain`, `panel doctor` (checks +Postgres, Redis, migrations, config). + +CSV import was removed from the club site on purpose (`../PLAN.md` §"what not +to build"). It is in the product because a self-hoster has no other source. +The club adapter does not use it; it imports through the API. + +--- + +## 7. Integrating the club site + +Thin and replaceable. + +- `packages/api/src/routers/judging.ts` (new, small): on `promoteSubmissions` + calls `@panel/server` `project.upsert` with `external_id = hackathon_project.id`; + on judge approval calls `judge.upsert`; on publish, pulls results and + feedback tokens back into `hackathon_result` so `/hackathons/[id]` keeps + rendering from its own tables. +- `sites/mainweb` `/judge` becomes a trusted-JWT handoff to the judge PWA. + Admin "Judging" tab deep-links into the console with the same handoff. +- Old `routers/judge/*` and the judging UI stay behind a feature flag per + edition (`hackathon.judging_backend = legacy | panel`) until one event has + run on the new system, then are deleted. +- Deploy: `@panel/server` and `@panel/web` as two Cloud Run services in the + same GCP project, Postgres as a second Neon database (not a schema in the + club DB, so the extraction is clean), Memorystore Redis only if more than + one server instance is wanted. Terraform under `judging/infra/gcp`. + +--- + +## 8. Phases + +Each phase ships green on `pnpm test`, `lint --max-warnings 0`, `typecheck`, +`build`, and leaves the current event-day path untouched. + +### Phase 0 — Scaffold and core (1 week) + +- Workspaces, tsconfig, eslint boundary rule, `judging/README.md` with the + vision, MIT `LICENSE`, ADR-001 (why standalone), ADR-002 (pure core). +- Port `pickNext`, `isLive`, `isPastCutoff`, `projectMatchesTrack`, + `zNormalize`, the Bayesian step into `@panel/core` as parametrised + functions. Bring `dispatch.test.ts` cases with them. +- Add `distance`, `phase`, `rubric`, `bradleyTerry`, `blend`, `uncertainty` + with tests. + +**Verify:** `@panel/core` has no dependencies and 100% of exported functions +have a test. The legacy `rankings.ts` output on the seed equals +`@panel/core` `rank()` output byte for byte at `pairwise_weight = 0`. + +### Phase 1 — Data and server (2 weeks) + +- Schema and first migration. Seed and demo. +- Hono + tRPC + OpenAPI. Routers from §4 except `live` and `feedback`. +- Auth: magic-link and trusted-jwt. Roles enforced per procedure. +- Advisory lock dispatch, visit voiding, outbox table and in-process drain. +- `event_log` on every mutation. `/metrics`, `/healthz`, `/readyz`. +- Dockerfile, `docker-compose.yml` (Postgres, server). + +**Verify:** DB tests: two concurrent `next` calls never hand out the same +table; a vote after cutoff is rejected with the configured limit in the +message; phase machine refuses `vote` outside `judging_live`; every mutation +in the flow test produces exactly one log row. `panel doctor` passes in the +compose stack. + +### Phase 2 — Judge PWA and admin console (3 weeks) + +- Judge flow end to end with offline hold. Camera QR via `getUserMedia`. +- Admin: phases, rubric, tracks, prizes, zones, tables, judges, projects, + table cards, results compute and publish. +- Branding from org row. + +**Verify:** Playwright: seed demo, approve a judge, run a full visit on a +phone viewport, compute, publish, see the leaderboard. Kill the network +mid-score, restore, vote lands once. + +### Phase 3 — Realtime and live board (1 week) + +- WebSocket hub, `InMemoryBus`, `RedisBus`. Live board and leaderboard + subscribe; fallback polling. +- Admin `recall` and `void` pushes. + +**Verify:** two server instances behind compose with Redis; a vote on one +appears on a board connected to the other within one second. Stop Redis; +boards degrade to polling with a visible indicator and no errors. + +### Phase 4 — Hybrid scoring and uncertainty dispatch (2 weeks) + +- Compare screen, `comparison` rows, Bradley–Terry in results, blend + components visible in the results inspector and the run diff. +- `uncertainty` strategy with a cached vector per event (in-process, hint + only). +- Team feedback cards and tokens. + +**Verify:** property test: consistent comparisons produce the transitive +order at `w = 1`; `w = 0` reproduces Phase 2 results. Simulation: 200 +projects, 20 judges, three synthetic hours; top-10 Kendall tau is higher +under `uncertainty` than `coverage` at equal looks. Feedback returns 403 +before publish and for the wrong token. + +### Phase 5 — Club integration and first live event (2 weeks plus the event) + +- `routers/judging.ts` adapter, trusted-JWT handoff, results pull-back, + feature flag per edition. +- Terraform: two Cloud Run services, Neon connection via Secret Manager, + optional Memorystore, uptime check and alert on `/readyz`. +- Run a small internal event on `panel`, then Hacklytics. + +**Verify:** the legacy and new systems produce the same placings on a replay +of an old event's votes. Post-event retro recorded as ADR-00x. + +### Phase 6 — Open-source release (1 week) + +- `CONTRIBUTING.md`, issue templates, GitHub Actions (test, lint, typecheck, + build, Docker publish to GHCR, `@panel/core` to npm on tag). +- Docs: quick start (`docker compose up`, `panel seed demo`), deploy guide, + API reference from OpenAPI, rubric design guide, scoring explainer. +- `git subtree split --prefix=judging -b panel-main`, push to the new repo, + replace `judging/` here with a git submodule or a pinned npm dependency. +- Delete `routers/judge/*` and the legacy judging UI from the club site. + +**Verify:** a fresh clone of the new repo runs the demo in under five +minutes with nothing but Docker. No string "DSGT", "Hacklytics" or +"datasciencegt" anywhere under `judging/`. + +--- + +## 9. Decisions taken and open + +**Taken** + +- Standalone product in `judging/`, extracted later by subtree. Not a schema + inside the club database, so the extraction has no data entanglement. +- WebSockets on our own server with a bus adapter; Redis optional. +- Terraform for GCP as an optional module; Docker Compose is the primary + documented deploy. +- Committed SQL migrations, not `push`. +- Pairwise comparisons against the judge's previous table, not a + server-chosen comparator (no extra walk, no recall burden). Revisit after + the Phase 4 simulation. +- CSV import exists in the product; the club adapter does not use it. + +**Open** + +- **Name.** `panel` is a placeholder; check npm and GitHub availability + before Phase 6. +- **Rank visibility.** Teams outside the podium see "not placed", not a + number. Organizer's call per event; default off. +- **Hosted mode.** Multi-tenant tables exist, but billing, org sign-up and + abuse controls are not in this plan. Self-host first. +- **Peer voting.** Possible as `judge_group = 'peer'` with its own weight. + Not designed here. + +--- + +## 10. Not in this plan + +- A scheduler or job queue service. The outbox drain runs in-process; cron is + an operator option, not a requirement. +- Devpost API sync. CSV import covers it; an API adapter can come as a + community contribution. +- Replacing the club site's registration, check-in, teams or submissions. + Those stay in `packages/api`; only judging moves. +- Any change to `packages/db` schema beyond the `judging_backend` flag and the + feedback token column needed for cutover. diff --git a/judging/LICENSE b/judging/LICENSE new file mode 100644 index 00000000..354a5c40 --- /dev/null +++ b/judging/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Panel contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/judging/README.md b/judging/README.md new file mode 100644 index 00000000..5b99dcec --- /dev/null +++ b/judging/README.md @@ -0,0 +1,32 @@ +# Panel + +A hackathon judging product: its own data, its own API, its own web app. +Rubrics, tracks, timers, rooms, and dispatch strategy are rows, not code. +Postgres and Node are enough to run it. Redis is optional and never required +for a correct result. + +`judging/` does not import the host application. The host may call `@panel/*`. +When the product is stable it leaves this repository with +`git subtree split --prefix=judging`. + +## Layout + +| Path | Package | What it is | +| --- | --- | --- | +| `core/` | `@panel/core` | Pure functions. No I/O, no clock, no runtime dependencies. | +| `db/` | `@panel/db` | Schema and committed SQL migrations. Not built yet. | +| `server/` | `@panel/server` | HTTP API. Not built yet. | +| `web/` | `@panel/web` | Judge, admin, and public views. Not built yet. | +| `cli/` | `@panel/cli` | Migrate, seed, import, export. Not built yet. | + +## Run the core + +From the repository root: + +``` +pnpm --filter @panel/core test +pnpm --filter @panel/core typecheck +pnpm --filter @panel/core lint +``` + +`pnpm test` includes `@panel/core`. diff --git a/judging/core/eslint.config.mjs b/judging/core/eslint.config.mjs new file mode 100644 index 00000000..a7d0fb28 --- /dev/null +++ b/judging/core/eslint.config.mjs @@ -0,0 +1,45 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...config, + { + files: ["src/**/*.ts"], + ignores: ["src/**/*.test.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "process", + importNames: ["env"], + message: + "@panel/core takes its inputs as arguments. It does not read the environment.", + }, + ], + patterns: [ + { + group: ["@query", "@query/*"], + message: + "judging/ is a standalone product. Import from @panel/* only.", + }, + { + group: [ + "node:*", + "fs", + "path", + "crypto", + "node:fs", + "node:path", + "node:crypto", + ], + message: + "@panel/core has no runtime dependencies and does not call Node APIs.", + }, + ], + }, + ], + }, + }, +]; diff --git a/judging/core/package.json b/judging/core/package.json new file mode 100644 index 00000000..fd17a170 --- /dev/null +++ b/judging/core/package.json @@ -0,0 +1,22 @@ +{ + "name": "@panel/core", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Pure judging decisions: dispatch, rubric, phase, and ranking.", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc --noEmit", + "test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts" + }, + "devDependencies": { + "@query/eslint-config": "workspace:*", + "@query/tsconfig": "workspace:*", + "@types/node": "22.20.4", + "typescript": "7.0.2", + "vitest": "^5.0.1" + } +} diff --git a/judging/core/src/aggregate/bayes.test.ts b/judging/core/src/aggregate/bayes.test.ts new file mode 100644 index 00000000..df67d118 --- /dev/null +++ b/judging/core/src/aggregate/bayes.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { bayesianShrink, round2 } from "./bayes"; + +describe("bayesianShrink", () => { + it("is zero when nobody voted", () => { + expect(bayesianShrink(9, 0, 5, 2)).toBe(0); + }); + + it("weighs two votes equally with the prior at c = 2", () => { + expect(bayesianShrink(8, 2, 4, 2)).toBe(6); + }); + + it("approaches the project's own average as votes accumulate", () => { + const few = bayesianShrink(10, 1, 0, 2); + const many = bayesianShrink(10, 100, 0, 2); + expect(many).toBeGreaterThan(few); + expect(many).toBeGreaterThan(9.5); + }); +}); + +describe("round2", () => { + it("rounds half up to the hundredth", () => { + expect(round2(1.234)).toBe(1.23); + expect(round2(1.235)).toBe(1.24); + expect(round2(7.085)).toBe(7.09); + }); +}); diff --git a/judging/core/src/aggregate/bayes.ts b/judging/core/src/aggregate/bayes.ts new file mode 100644 index 00000000..15f764fe --- /dev/null +++ b/judging/core/src/aggregate/bayes.ts @@ -0,0 +1,19 @@ +/** + * Pull a project's normalised average toward the field when few judges have + * seen it. `c` is how many average votes the prior is worth. At `c = 2`, + * two real votes and the prior weigh the same. + */ +export function bayesianShrink( + normalizedAvg: number, + n: number, + globalAvg: number, + c: number, +): number { + if (n <= 0) return 0; + return (n / (n + c)) * normalizedAvg + (c / (n + c)) * globalAvg; +} + +/** Hundredths, same rounding the published placings use. */ +export function round2(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/judging/core/src/aggregate/blend.test.ts b/judging/core/src/aggregate/blend.test.ts new file mode 100644 index 00000000..d40cf033 --- /dev/null +++ b/judging/core/src/aggregate/blend.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { blend } from "./blend"; + +describe("blend", () => { + it("returns the rubric at weight 0 and the pairwise score at weight 1", () => { + expect(blend(4, 10, 0)).toBe(4); + expect(blend(4, 10, 1)).toBe(10); + }); + + it("takes the midpoint at a half", () => { + expect(blend(4, 10, 0.5)).toBe(7); + }); + + it("rejects a weight outside 0 to 1", () => { + expect(() => blend(1, 1, 1.1)).toThrow(/weight/); + expect(() => blend(1, 1, -0.1)).toThrow(/weight/); + }); +}); diff --git a/judging/core/src/aggregate/blend.ts b/judging/core/src/aggregate/blend.ts new file mode 100644 index 00000000..9b3bba95 --- /dev/null +++ b/judging/core/src/aggregate/blend.ts @@ -0,0 +1,11 @@ +/** `weight` is the share of the pairwise component. Zero is rubric only. */ +export function blend( + rubric: number, + pairwise: number, + weight: number, +): number { + if (!Number.isFinite(weight) || weight < 0 || weight > 1) { + throw new Error("pairwise weight must be between 0 and 1"); + } + return (1 - weight) * rubric + weight * pairwise; +} diff --git a/judging/core/src/aggregate/bradleyTerry.test.ts b/judging/core/src/aggregate/bradleyTerry.test.ts new file mode 100644 index 00000000..3a54f9b6 --- /dev/null +++ b/judging/core/src/aggregate/bradleyTerry.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { bradleyTerry } from "./bradleyTerry"; + +describe("bradleyTerry", () => { + it("returns an empty map when there is nobody", () => { + expect(bradleyTerry([], []).size).toBe(0); + }); + + it("leaves everyone equal when there are no comparisons", () => { + const strength = bradleyTerry(["a", "b"], []); + expect(strength.get("a")).toBe(1); + expect(strength.get("b")).toBe(1); + }); + + it("ranks a consistent set in transitive order", () => { + const strength = bradleyTerry( + ["a", "b", "c"], + [ + { a: "a", b: "b", outcome: "a" }, + { a: "b", b: "c", outcome: "a" }, + { a: "a", b: "c", outcome: "a" }, + ], + ); + const a = strength.get("a") ?? 0; + const b = strength.get("b") ?? 0; + const c = strength.get("c") ?? 0; + expect(a).toBeGreaterThan(b); + expect(b).toBeGreaterThan(c); + }); + + it("puts a tie closer together than a sweep", () => { + const tied = bradleyTerry( + ["a", "b"], + [ + { a: "a", b: "b", outcome: "tie" }, + { a: "a", b: "b", outcome: "tie" }, + ], + ); + const swept = bradleyTerry( + ["a", "b"], + [ + { a: "a", b: "b", outcome: "a" }, + { a: "a", b: "b", outcome: "a" }, + ], + ); + const tieGap = Math.abs((tied.get("a") ?? 0) - (tied.get("b") ?? 0)); + const sweepGap = Math.abs((swept.get("a") ?? 0) - (swept.get("b") ?? 0)); + expect(tieGap).toBeLessThan(sweepGap); + }); +}); diff --git a/judging/core/src/aggregate/bradleyTerry.ts b/judging/core/src/aggregate/bradleyTerry.ts new file mode 100644 index 00000000..71132a65 --- /dev/null +++ b/judging/core/src/aggregate/bradleyTerry.ts @@ -0,0 +1,72 @@ +import type { Comparison } from "../comparison"; + +/** + * Bradley-Terry strengths from pairwise outcomes. A tie is half a win each. + * Strengths are scaled so their mean is 1. Projects that never appear in a + * comparison are left at 1. Iteration is the minorization-maximization + * update; `iterations` is a cap, not a clock. + */ +export function bradleyTerry( + projectIds: readonly string[], + comparisons: readonly Comparison[], + iterations = 50, +): Map { + const ids = [...new Set(projectIds)]; + const strength = new Map(ids.map((id) => [id, 1])); + if (ids.length === 0 || comparisons.length === 0) return strength; + + const wins = new Map(ids.map((id) => [id, 0])); + const games = new Map>(); + + const addGame = (left: string, right: string) => { + const row = games.get(left) ?? new Map(); + row.set(right, (row.get(right) ?? 0) + 1); + games.set(left, row); + }; + + for (const comparison of comparisons) { + if ( + comparison.a === comparison.b || + !strength.has(comparison.a) || + !strength.has(comparison.b) + ) { + continue; + } + if (comparison.outcome === "tie") { + wins.set(comparison.a, (wins.get(comparison.a) ?? 0) + 0.5); + wins.set(comparison.b, (wins.get(comparison.b) ?? 0) + 0.5); + } else if (comparison.outcome === "a") { + wins.set(comparison.a, (wins.get(comparison.a) ?? 0) + 1); + } else { + wins.set(comparison.b, (wins.get(comparison.b) ?? 0) + 1); + } + addGame(comparison.a, comparison.b); + addGame(comparison.b, comparison.a); + } + + for (let step = 0; step < iterations; step += 1) { + const next = new Map(); + for (const id of ids) { + const opponents = games.get(id); + if (!opponents || opponents.size === 0) { + next.set(id, strength.get(id) ?? 1); + continue; + } + let denominator = 0; + for (const [other, count] of opponents) { + const pi = strength.get(id) ?? 1; + const pj = strength.get(other) ?? 1; + const sum = pi + pj; + if (sum > 0) denominator += count / sum; + } + const won = wins.get(id) ?? 0; + next.set(id, denominator > 0 ? won / denominator : 0); + } + let total = 0; + for (const value of next.values()) total += value; + const mean = total / ids.length || 1; + for (const id of ids) strength.set(id, (next.get(id) ?? 0) / mean); + } + + return strength; +} diff --git a/judging/core/src/aggregate/rank.test.ts b/judging/core/src/aggregate/rank.test.ts new file mode 100644 index 00000000..ce684acd --- /dev/null +++ b/judging/core/src/aggregate/rank.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import { zNormalize } from "./zscore"; +import { bayesianShrink, round2 } from "./bayes"; +import { rank } from "./rank"; +import type { ProjectInput, RankConfig, VoteInput } from "./rank"; + +/** + * The ranking the current event publishes: z-score per judge against the + * population mean and spread, round the project mean, shrink toward the mean + * of those means with c = 2, round again. Same raw score maps to one + * normalised value because the z-score is linear, so a value-keyed lookup + * and an index-aligned one agree. + */ +function legacyRank( + projects: readonly { id: string }[], + votes: readonly { judgeId: string; projectId: string; score: number }[], +) { + const byJudge = new Map(); + for (const vote of votes) { + const list = byJudge.get(vote.judgeId) ?? []; + list.push(vote.score); + byJudge.set(vote.judgeId, list); + } + const raw = [...byJudge.values()].flat(); + const globalMean = raw.length > 0 ? raw.reduce((a, b) => a + b, 0) / raw.length : 0; + const globalVariance = + raw.length > 0 + ? raw.reduce((sum, value) => sum + (value - globalMean) ** 2, 0) / raw.length + : 1; + const globalStd = Math.sqrt(globalVariance) || 1; + + const normalizedByJudge = new Map>(); + for (const [judgeId, scores] of byJudge) { + const normalized = zNormalize(scores, globalMean, globalStd); + const lookup = new Map(); + scores.forEach((score, index) => { + const existing = lookup.get(score); + const value = normalized[index] ?? globalMean; + lookup.set(score, existing !== undefined ? (existing + value) / 2 : value); + }); + normalizedByJudge.set(judgeId, lookup); + } + + const rows = projects.map((project) => { + const mine = votes.filter((vote) => vote.projectId === project.id); + const normalizedScores = mine.map( + (vote) => normalizedByJudge.get(vote.judgeId)?.get(vote.score) ?? vote.score, + ); + const normalizedAvg = + mine.length > 0 + ? round2(normalizedScores.reduce((a, b) => a + b, 0) / mine.length) + : 0; + return { id: project.id, voteCount: mine.length, normalizedAvg }; + }); + + const voted = rows.filter((row) => row.voteCount > 0); + const globalAvg = + voted.length > 0 + ? round2(voted.reduce((sum, row) => sum + row.normalizedAvg, 0) / voted.length) + : 0; + + return rows + .map((row) => ({ + id: row.id, + voteCount: row.voteCount, + score: + row.voteCount > 0 + ? round2(bayesianShrink(row.normalizedAvg, row.voteCount, globalAvg, 2)) + : 0, + })) + .filter((row) => row.voteCount > 0) + .sort((a, b) => b.score - a.score || projects.findIndex((p) => p.id === a.id) - projects.findIndex((p) => p.id === b.id)); +} + +const config: RankConfig = { + bayesianC: 2, + pairwiseWeight: 0, + calibrationWeight: 1, +}; + +const projects: ProjectInput[] = [ + { id: "A", trackIds: ["overall"] }, + { id: "B", trackIds: ["overall"] }, + { id: "C", trackIds: ["overall"] }, +]; + +function vote(judgeId: string, projectId: string, total: number): VoteInput { + return { judgeId, projectId, judgeGroup: "main", total }; +} + +describe("rank matches the current event's pipeline at pairwise weight 0", () => { + const votes = [ + vote("j1", "A", 8), + vote("j1", "B", 5), + vote("j1", "C", 2), + vote("j2", "A", 9), + vote("j2", "B", 9), + vote("j2", "C", 6), + ]; + + it("places the same projects at the same hundredths", () => { + const legacy = legacyRank(projects, votes.map((item) => ({ + judgeId: item.judgeId, + projectId: item.projectId, + score: item.total, + }))); + const ranked = rank(projects, votes, [], config) + .filter((row) => row.placement !== null) + .sort((a, b) => (a.placement ?? 0) - (b.placement ?? 0)); + + expect(ranked.map((row) => [row.projectId, row.score])).toEqual( + legacy.map((row) => [row.id, row.score]), + ); + expect(ranked.map((row) => row.score)).toEqual([7.71, 6.94, 4.85]); + }); + + it("ignores comparisons when the pairwise weight is zero", () => { + const withPairs = rank( + projects, + votes, + [ + { + judgeId: "j1", + judgeGroup: "main", + a: "C", + b: "A", + outcome: "a", + }, + ], + config, + ); + const without = rank(projects, votes, [], config); + expect(withPairs.map((row) => row.score)).toEqual(without.map((row) => row.score)); + }); +}); + +describe("rank", () => { + it("does not place a project nobody scored", () => { + const rows = rank( + [ + { id: "seen", trackIds: ["overall"] }, + { id: "missed", trackIds: ["overall"] }, + ], + [vote("j1", "seen", 8), vote("j2", "seen", 6)], + [], + config, + ); + const seen = rows.find((row) => row.projectId === "seen"); + const missed = rows.find((row) => row.projectId === "missed"); + expect(seen?.placement).toBe(1); + expect(missed?.placement).toBeNull(); + }); + + it("keeps judge groups apart", () => { + const rows = rank( + [{ id: "A", trackIds: ["overall"] }], + [ + { judgeId: "j1", projectId: "A", judgeGroup: "main", total: 10 }, + { judgeId: "j2", projectId: "A", judgeGroup: "sponsor", total: 1 }, + ], + [], + config, + ); + expect(rows).toHaveLength(2); + expect(rows.find((row) => row.judgeGroup === "main")?.voteCount).toBe(1); + expect(rows.find((row) => row.judgeGroup === "sponsor")?.voteCount).toBe(1); + }); + + it("drops a calibration vote whose weight is zero", () => { + const base = rank(projects, [vote("j1", "A", 8), vote("j1", "B", 4)], [], config); + const extra = rank( + projects, + [ + vote("j1", "A", 8), + vote("j1", "B", 4), + { ...vote("j1", "C", 1), isCalibration: true }, + ], + [], + { ...config, calibrationWeight: 0 }, + ); + const score = (rows: typeof base, id: string) => + rows.find((row) => row.projectId === id)?.score; + expect(score(extra, "A")).toBe(score(base, "A")); + expect(score(extra, "B")).toBe(score(base, "B")); + expect(extra.find((row) => row.projectId === "C")?.voteCount).toBe(0); + }); + + it("orders a pairwise-only field by who won, at weight 1", () => { + const rows = rank( + projects, + [], + [ + { judgeId: "j1", judgeGroup: "main", a: "A", b: "B", outcome: "a" }, + { judgeId: "j1", judgeGroup: "main", a: "B", b: "C", outcome: "a" }, + { judgeId: "j1", judgeGroup: "main", a: "A", b: "C", outcome: "a" }, + ], + { ...config, pairwiseWeight: 1 }, + ); + const placed = rows + .filter((row) => row.placement !== null) + .sort((a, b) => (a.placement ?? 0) - (b.placement ?? 0)); + expect(placed.map((row) => row.projectId)).toEqual(["A", "B", "C"]); + }); +}); diff --git a/judging/core/src/aggregate/rank.ts b/judging/core/src/aggregate/rank.ts new file mode 100644 index 00000000..0b687d32 --- /dev/null +++ b/judging/core/src/aggregate/rank.ts @@ -0,0 +1,304 @@ +import type { Comparison } from "../comparison"; +import { bayesianShrink, round2 } from "./bayes"; +import { bradleyTerry } from "./bradleyTerry"; +import { blend } from "./blend"; +import { zNormalize } from "./zscore"; + +export type VoteInput = { + judgeId: string; + projectId: string; + judgeGroup: string; + total: number; + isCalibration?: boolean; +}; + +export type RankComparison = Comparison & { + judgeId: string; + judgeGroup: string; +}; + +export type ProjectInput = { + id: string; + trackIds: readonly string[]; +}; + +export type RankConfig = { + bayesianC: number; + pairwiseWeight: number; + /** How much a calibration vote counts, relative to a normal vote of 1. */ + calibrationWeight: number; +}; + +export type RankedRow = { + projectId: string; + trackId: string; + judgeGroup: string; + placement: number | null; + score: number; + rubricComponent: number; + pairwiseComponent: number; + voteCount: number; + comparisonCount: number; +}; + +/** + * Rank each track and judge group. + * + * Rubric side, at `pairwiseWeight = 0` and `calibrationWeight = 1`: z-score + * each judge onto the group's mean and spread, average per project (rounded + * to hundredths), shrink toward the mean of those averages with `bayesianC`, + * round again, and place by that score. That is the pipeline the existing + * event uses, so a replay of its votes lands on the same numbers. + * + * Pairwise side: Bradley-Terry on comparisons in the group, scaled onto the + * rubric scores, then blended. Weight 0 leaves the rubric number untouched. + * A calibration vote is down-weighted in the average and in the shrink's + * sample size. It still informs that judge's own z-score. + */ +export function rank( + projects: readonly ProjectInput[], + votes: readonly VoteInput[], + comparisons: readonly RankComparison[], + config: RankConfig, +): RankedRow[] { + const known = new Set(projects.map((project) => project.id)); + const tracks = trackOrder(projects); + const groups = groupOrder(votes, comparisons); + const rows: RankedRow[] = []; + + for (const judgeGroup of groups) { + const groupVotes = votes.filter( + (vote) => vote.judgeGroup === judgeGroup && known.has(vote.projectId), + ); + const normalized = normalize(groupVotes, config); + for (const trackId of tracks) { + const onTrack = projects.filter((project) => + project.trackIds.includes(trackId), + ); + const onTrackIds = new Set(onTrack.map((project) => project.id)); + const trackVotes = groupVotes + .map((vote, index) => ({ + vote, + normalized: normalized[index] ?? 0, + })) + .filter(({ vote }) => onTrackIds.has(vote.projectId)); + const trackComparisons = comparisons.filter( + (comparison) => + comparison.judgeGroup === judgeGroup && + onTrackIds.has(comparison.a) && + onTrackIds.has(comparison.b), + ); + rows.push( + ...rankTrack( + onTrack, + trackVotes, + trackComparisons, + trackId, + judgeGroup, + config, + ), + ); + } + } + + return rows; +} + +function weightOf(vote: VoteInput, config: RankConfig): number { + return vote.isCalibration ? config.calibrationWeight : 1; +} + +function spread(scores: readonly number[]): { mean: number; std: number } { + if (scores.length === 0) return { mean: 0, std: 1 }; + const mean = scores.reduce((sum, value) => sum + value, 0) / scores.length; + const variance = + scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / scores.length; + return { mean, std: Math.sqrt(variance) || 1 }; +} + +function normalize( + votes: readonly VoteInput[], + config: RankConfig, +): number[] { + const weights = votes.map((vote) => weightOf(vote, config)); + const raw: number[] = []; + for (let index = 0; index < votes.length; index += 1) { + const vote = votes[index]; + if (vote && (weights[index] ?? 0) > 0) raw.push(vote.total); + } + const { mean, std } = spread(raw); + + const byJudge = new Map(); + votes.forEach((vote, index) => { + if ((weights[index] ?? 0) <= 0) return; + const list = byJudge.get(vote.judgeId) ?? []; + list.push(index); + byJudge.set(vote.judgeId, list); + }); + + const out = votes.map(() => 0); + for (const indexes of byJudge.values()) { + const scores = indexes.map((index) => votes[index]?.total ?? 0); + const normalized = zNormalize(scores, mean, std); + indexes.forEach((index, offset) => { + out[index] = normalized[offset] ?? 0; + }); + } + return out; +} + +type CarriedVote = { vote: VoteInput; normalized: number }; + +function rankTrack( + projects: readonly ProjectInput[], + votes: readonly CarriedVote[], + comparisons: readonly RankComparison[], + trackId: string, + judgeGroup: string, + config: RankConfig, +): RankedRow[] { + const stats = new Map(); + for (const project of projects) { + let weightedSum = 0; + let valueSum = 0; + let count = 0; + for (const item of votes) { + if (item.vote.projectId !== project.id) continue; + const weight = weightOf(item.vote, config); + if (weight <= 0) continue; + weightedSum += weight; + valueSum += item.normalized * weight; + count += 1; + } + stats.set(project.id, { + avg: weightedSum > 0 ? round2(valueSum / weightedSum) : 0, + n: weightedSum, + count, + }); + } + + const votedAverages: number[] = []; + for (const row of stats.values()) { + if (row.n > 0) votedAverages.push(row.avg); + } + const globalAvg = + votedAverages.length > 0 + ? round2( + votedAverages.reduce((sum, value) => sum + value, 0) / + votedAverages.length, + ) + : 0; + + const rubricScore = new Map(); + const anchors: number[] = []; + for (const project of projects) { + const row = stats.get(project.id); + const n = row?.n ?? 0; + const score = + n > 0 + ? round2(bayesianShrink(row?.avg ?? 0, n, globalAvg, config.bayesianC)) + : 0; + rubricScore.set(project.id, score); + if (n > 0) anchors.push(score); + } + + const compared = new Set(); + for (const comparison of comparisons) { + compared.add(comparison.a); + compared.add(comparison.b); + } + const scaled = scaleStrengths( + bradleyTerry([...compared], comparisons), + anchors, + compared, + ); + + const draft: RankedRow[] = projects.map((project) => { + const row = stats.get(project.id); + const rubricComponent = rubricScore.get(project.id) ?? 0; + const comparisonCount = comparisons.filter( + (comparison) => + comparison.a === project.id || comparison.b === project.id, + ).length; + const scaledValue = scaled.get(project.id); + const pairwiseForBlend = scaledValue ?? rubricComponent; + const score = + config.pairwiseWeight === 0 + ? rubricComponent + : round2( + blend(rubricComponent, pairwiseForBlend, config.pairwiseWeight), + ); + return { + projectId: project.id, + trackId, + judgeGroup, + placement: null, + score, + rubricComponent, + pairwiseComponent: scaledValue ?? 0, + voteCount: row?.count ?? 0, + comparisonCount, + }; + }); + + const order = new Map(projects.map((project, index) => [project.id, index])); + const placeable = draft.filter( + (row) => + row.voteCount > 0 || + (config.pairwiseWeight > 0 && row.comparisonCount > 0), + ); + placeable.sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; + return (order.get(a.projectId) ?? 0) - (order.get(b.projectId) ?? 0); + }); + placeable.forEach((row, index) => { + row.placement = index + 1; + }); + return draft; +} + +function scaleStrengths( + strength: ReadonlyMap, + anchors: readonly number[], + compared: ReadonlySet, +): Map { + const out = new Map(); + if (compared.size === 0) return out; + const values = [...compared].map((id) => strength.get(id) ?? 0); + const lowStrength = Math.min(...values); + const highStrength = Math.max(...values); + const low = anchors.length > 0 ? Math.min(...anchors) : lowStrength; + const high = anchors.length > 0 ? Math.max(...anchors) : highStrength; + for (const id of compared) { + const value = strength.get(id) ?? 0; + if (anchors.length === 0) out.set(id, value); + else if (highStrength === lowStrength) out.set(id, (low + high) / 2); + else { + out.set( + id, + low + + ((value - lowStrength) / (highStrength - lowStrength)) * (high - low), + ); + } + } + return out; +} + +function trackOrder(projects: readonly ProjectInput[]): string[] { + const seen = new Set(); + for (const project of projects) { + for (const trackId of project.trackIds) seen.add(trackId); + } + return [...seen]; +} + +function groupOrder( + votes: readonly VoteInput[], + comparisons: readonly RankComparison[], +): string[] { + const seen = new Set(); + for (const vote of votes) seen.add(vote.judgeGroup); + for (const comparison of comparisons) seen.add(comparison.judgeGroup); + if (seen.size === 0) seen.add("main"); + return [...seen]; +} diff --git a/judging/core/src/aggregate/zscore.test.ts b/judging/core/src/aggregate/zscore.test.ts new file mode 100644 index 00000000..29722da2 --- /dev/null +++ b/judging/core/src/aggregate/zscore.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { zNormalize } from "./zscore"; + +const mean = (xs: number[]) => xs.reduce((sum, value) => sum + value, 0) / xs.length; +const stddev = (xs: number[]) => { + const center = mean(xs); + return Math.sqrt(mean(xs.map((value) => (value - center) ** 2))); +}; + +describe("zNormalize", () => { + it("returns the global mean when fewer than two scores", () => { + expect(zNormalize([8], 5, 2)).toEqual([5]); + expect(zNormalize([], 5, 2)).toEqual([]); + }); + + it("returns the global mean when a judge scored everything the same", () => { + expect(zNormalize([7, 7, 7], 5, 2)).toEqual([5, 5, 5]); + }); + + it("spreads scores around the global mean", () => { + const normalized = zNormalize([2, 5, 8], 5, 2); + expect(normalized[0] ?? 0).toBeLessThan(5); + expect(normalized[1] ?? 0).toBeCloseTo(5, 1); + expect(normalized[2] ?? 0).toBeGreaterThan(5); + }); + + it("preserves a judge's own ordering", () => { + const normalized = zNormalize([1, 4, 9, 10], 5, 2); + for (let index = 1; index < normalized.length; index += 1) { + expect(normalized[index] ?? 0).toBeGreaterThan(normalized[index - 1] ?? 0); + } + }); + + it("makes a harsh judge and a lenient judge agree", () => { + const harsh = zNormalize([1, 2, 3], 5, 2); + const lenient = zNormalize([8, 9, 10], 5, 2); + expect(harsh[0] ?? 0).toBeCloseTo(lenient[0] ?? 0, 10); + expect(harsh[1] ?? 0).toBeCloseTo(lenient[1] ?? 0, 10); + expect(harsh[2] ?? 0).toBeCloseTo(lenient[2] ?? 0, 10); + }); + + it("makes a wide-spread judge and a narrow-spread judge agree", () => { + const wide = zNormalize([0, 5, 10], 5, 2); + const narrow = zNormalize([4, 5, 6], 5, 2); + expect(wide[0] ?? 0).toBeCloseTo(narrow[0] ?? 0, 10); + expect(wide[2] ?? 0).toBeCloseTo(narrow[2] ?? 0, 10); + }); + + it("centres the normalized scores on the global mean", () => { + expect(mean(zNormalize([1, 4, 9, 10], 7, 2))).toBeCloseTo(7, 10); + }); + + it("gives the normalized scores the global spread", () => { + expect(stddev(zNormalize([1, 4, 9, 10], 5, 3))).toBeCloseTo(3, 10); + }); + + it("never produces NaN or Infinity for degenerate input", () => { + const cases = [ + zNormalize([7, 7], 5, 2), + zNormalize([0, 0, 0], 5, 2), + zNormalize([5], 5, 0), + zNormalize([1, 2], 0, 0), + ]; + for (const result of cases) { + for (const value of result) expect(Number.isFinite(value)).toBe(true); + } + }); + + it("handles negative scores and keeps equals equal", () => { + const negative = zNormalize([-5, 0, 5], 5, 2); + expect(negative[0] ?? 0).toBeLessThan(negative[2] ?? 0); + const tied = zNormalize([3, 3, 9], 5, 2); + expect(tied[0] ?? 0).toBeCloseTo(tied[1] ?? 0, 10); + }); + + it("collapses to the global mean when the global spread is zero", () => { + expect(zNormalize([1, 5, 9], 6, 0)).toEqual([6, 6, 6]); + }); +}); diff --git a/judging/core/src/aggregate/zscore.ts b/judging/core/src/aggregate/zscore.ts new file mode 100644 index 00000000..e6c96bb4 --- /dev/null +++ b/judging/core/src/aggregate/zscore.ts @@ -0,0 +1,22 @@ +/** + * Z-score a judge's scores against their own mean and spread, then place + * that shape on the global mean and spread. A judge with fewer than two + * scores, or who gave everything the same number, has no spread of their + * own, so every score becomes the global mean. A global spread of zero + * collapses everyone onto the global mean too. + */ +export function zNormalize( + scores: readonly number[], + globalMean: number, + globalStd: number, +): number[] { + if (scores.length < 2) return scores.map(() => globalMean); + const mean = scores.reduce((sum, value) => sum + value, 0) / scores.length; + const variance = + scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / scores.length; + const std = Math.sqrt(variance); + if (std === 0 || globalStd === 0) return scores.map(() => globalMean); + return scores.map( + (value) => globalMean + ((value - mean) / std) * globalStd, + ); +} diff --git a/judging/core/src/comparison.ts b/judging/core/src/comparison.ts new file mode 100644 index 00000000..7937600b --- /dev/null +++ b/judging/core/src/comparison.ts @@ -0,0 +1,5 @@ +export type Comparison = { + a: string; + b: string; + outcome: "a" | "b" | "tie"; +}; diff --git a/judging/core/src/dispatch.test.ts b/judging/core/src/dispatch.test.ts new file mode 100644 index 00000000..9c2f0ec5 --- /dev/null +++ b/judging/core/src/dispatch.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { pickNext } from "./dispatch"; +import type { Candidate, DispatchConfig } from "./dispatch"; + +const t0 = new Date("2027-02-28T13:00:00Z"); +const at = (seconds: number) => new Date(t0.getTime() + seconds * 1000); +const first = () => 0; +const coverage: DispatchConfig = { + strategy: "coverage", + minLooksPerProject: 1, +}; + +const c = ( + id: string, + tableNumber: number, + looks = 0, + claimedAt: number | null = null, + extra: Partial = {}, +): Candidate => ({ + id, + tableNumber, + coverage: looks, + claimedAt, + ...extra, +}); + +describe("pickNext coverage", () => { + it("returns nothing when there is nothing left", () => { + expect(pickNext([], { lastTable: null }, coverage, first)).toBeNull(); + }); + + it("takes the least-covered project", () => { + expect( + pickNext( + [c("a", 1, 2), c("b", 2, 0), c("c", 3, 1)], + { lastTable: null }, + coverage, + first, + )?.id, + ).toBe("b"); + }); + + it("breaks a coverage tie by the table nearest the judge's last one", () => { + const pick = pickNext( + [c("far", 90, 1), c("near", 12, 1), c("mid", 40, 1)], + { lastTable: 10 }, + coverage, + first, + ); + expect(pick?.id).toBe("near"); + }); + + it("never trades coverage for distance", () => { + expect( + pickNext( + [c("next-door", 11, 2), c("across", 200, 1)], + { lastTable: 10 }, + coverage, + first, + )?.id, + ).toBe("across"); + }); + + it("passes over a table someone is at while a free one exists", () => { + expect( + pickNext( + [c("taken", 1, 0, at(0).getTime()), c("free", 2, 3)], + { lastTable: null }, + coverage, + first, + )?.id, + ).toBe("free"); + }); + + it("sends the judge to the longest-held table when every one is taken", () => { + const pick = pickNext( + [ + c("recent", 1, 0, at(100).getTime()), + c("oldest", 2, 0, at(0).getTime()), + ], + { lastTable: null }, + coverage, + first, + ); + expect(pick?.id).toBe("oldest"); + }); + + it("chooses among equals at random", () => { + const tier = [c("a", 5, 0), c("b", 15, 0)]; + expect(pickNext(tier, { lastTable: 10 }, coverage, () => 0)?.id).toBe("a"); + expect(pickNext(tier, { lastTable: 10 }, coverage, () => 1)?.id).toBe("b"); + }); + + it("prefers the same zone over a closer table number in another zone", () => { + const pick = pickNext( + [ + c("other-room", 2, 0, null, { zoneId: "atrium" }), + c("same-room", 30, 0, null, { zoneId: "hall" }), + ], + { lastTable: 4, zoneId: "hall" }, + coverage, + first, + ); + expect(pick?.id).toBe("same-room"); + }); +}); + +describe("pickNext uncertainty", () => { + const uncertainty: DispatchConfig = { + strategy: "uncertainty", + minLooksPerProject: 2, + }; + + it("keeps covering while any project is under the minimum", () => { + const pick = pickNext( + [ + c("thin", 1, 0, null, { uncertainty: 0 }), + c("shaky", 2, 5, null, { uncertainty: 9 }), + ], + { lastTable: null }, + uncertainty, + first, + ); + expect(pick?.id).toBe("thin"); + }); + + it("takes the least settled project once coverage is met", () => { + const pick = pickNext( + [ + c("settled", 1, 2, null, { uncertainty: 0.1 }), + c("open", 9, 2, null, { uncertainty: 4 }), + ], + { lastTable: 1 }, + uncertainty, + first, + ); + expect(pick?.id).toBe("open"); + }); +}); diff --git a/judging/core/src/dispatch.ts b/judging/core/src/dispatch.ts new file mode 100644 index 00000000..aa453f98 --- /dev/null +++ b/judging/core/src/dispatch.ts @@ -0,0 +1,128 @@ +import { distance } from "./distance"; +import type { TablePoint } from "./distance"; + +export type Candidate = { + id: string; + tableNumber: number; + zoneId?: string | null; + x?: number | null; + y?: number | null; + /** Scores plus live claims from judges in the same group. */ + coverage: number; + /** Most recent live claim by another judge, if any (ms epoch). */ + claimedAt: number | null; + /** Used by the uncertainty strategy once every project has its minimum looks. */ + uncertainty?: number; +}; + +export type JudgeSpot = { + lastTable: number | null; + zoneId?: string | null; + x?: number | null; + y?: number | null; +}; + +export type DispatchStrategy = "coverage" | "uncertainty"; + +export type DispatchConfig = { + strategy: DispatchStrategy; + /** Uncertainty waits until every candidate has at least this many looks. */ + minLooksPerProject: number; +}; + +/** + * The next project for one judge, from the projects they are eligible for + * and have not had. + * + * Coverage: fewest looks, then the nearest table, then `random`. A table + * someone is already at is passed over while any other exists. When every + * remaining one is taken, the one claimed longest ago, because an idle + * judge is worse than two at a table. + * + * Uncertainty: the same coverage rule until `minLooksPerProject` is met + * everywhere in `candidates`, then the highest uncertainty, then nearest. + */ +export function pickNext( + candidates: readonly Candidate[], + judge: JudgeSpot, + config: DispatchConfig, + random: (n: number) => number, +): Candidate | null { + if (candidates.length === 0) return null; + + const free = candidates.filter((candidate) => candidate.claimedAt === null); + if (free.length === 0) return oldestClaim(candidates); + + const stillCovering = + config.strategy === "coverage" || + candidates.some( + (candidate) => candidate.coverage < config.minLooksPerProject, + ); + + const tier = stillCovering ? fewestLooks(free) : highestUncertainty(free); + return narrow(tier, judge, random); +} + +function fewestLooks(free: readonly Candidate[]): Candidate[] { + const fewest = Math.min(...free.map((candidate) => candidate.coverage)); + return free.filter((candidate) => candidate.coverage === fewest); +} + +function highestUncertainty(free: readonly Candidate[]): Candidate[] { + const highest = Math.max( + ...free.map((candidate) => candidate.uncertainty ?? 0), + ); + return free.filter( + (candidate) => (candidate.uncertainty ?? 0) === highest, + ); +} + +function oldestClaim(candidates: readonly Candidate[]): Candidate | null { + let best: Candidate | null = null; + for (const candidate of candidates) { + if (!best || (candidate.claimedAt ?? 0) < (best.claimedAt ?? 0)) { + best = candidate; + } + } + return best; +} + +function narrow( + tier: readonly Candidate[], + judge: JudgeSpot, + random: (n: number) => number, +): Candidate | null { + if (tier.length === 0) return null; + if (judge.lastTable === null) return choose(tier, random); + + const here: TablePoint = { + number: judge.lastTable, + zoneId: judge.zoneId ?? null, + x: judge.x ?? null, + y: judge.y ?? null, + }; + const nearest = Math.min( + ...tier.map((candidate) => distance(here, candidatePoint(candidate))), + ); + const closest = tier.filter( + (candidate) => distance(here, candidatePoint(candidate)) === nearest, + ); + return choose(closest, random); +} + +function choose( + tier: readonly Candidate[], + random: (n: number) => number, +): Candidate | null { + if (tier.length === 0) return null; + return tier[random(tier.length)] ?? null; +} + +function candidatePoint(candidate: Candidate): TablePoint { + return { + number: candidate.tableNumber, + zoneId: candidate.zoneId ?? null, + x: candidate.x ?? null, + y: candidate.y ?? null, + }; +} diff --git a/judging/core/src/distance.test.ts b/judging/core/src/distance.test.ts new file mode 100644 index 00000000..3bd7ee03 --- /dev/null +++ b/judging/core/src/distance.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { distance, ZONE_HOP } from "./distance"; + +const table = ( + number: number, + zoneId: string | null = null, + x: number | null = null, + y: number | null = null, +) => ({ number, zoneId, x, y }); + +describe("distance", () => { + it("is the table-number gap inside one zone", () => { + expect(distance(table(4, "hall"), table(11, "hall"))).toBe(7); + }); + + it("is the same gap when neither table has a zone", () => { + expect(distance(table(4), table(11))).toBe(7); + }); + + it("adds a zone hop when the rooms differ", () => { + expect(distance(table(4, "hall"), table(6, "atrium"))).toBe(ZONE_HOP + 2); + }); + + it("uses the floor plan when both tables have coordinates", () => { + expect(distance(table(1, "hall", 0, 0), table(9, "atrium", 3, 4))).toBe(5); + }); + + it("falls back to numbers when only one table is placed", () => { + expect(distance(table(1, "hall", 0, 0), table(4, "hall"))).toBe(3); + }); +}); diff --git a/judging/core/src/distance.ts b/judging/core/src/distance.ts new file mode 100644 index 00000000..6601a79c --- /dev/null +++ b/judging/core/src/distance.ts @@ -0,0 +1,30 @@ +/** + * How far one table is from another. + * + * Same zone: the difference in table numbers, which is the walk. + * Another zone: a hop larger than any table gap, then the number difference, + * so a same-zone walk always beats changing rooms. + * When both tables have coordinates, the floor plan wins and the distance + * is Euclidean. + */ + +export type TablePoint = { + number: number; + zoneId: string | null; + x: number | null; + y: number | null; +}; + +/** Larger than any table-number gap, so a zone change never sorts as "near". */ +export const ZONE_HOP = 1_000_000; + +export function distance(a: TablePoint, b: TablePoint): number { + if (a.x !== null && a.y !== null && b.x !== null && b.y !== null) { + return Math.hypot(a.x - b.x, a.y - b.y); + } + const diff = Math.abs(a.number - b.number); + if (a.zoneId !== null && b.zoneId !== null && a.zoneId !== b.zoneId) { + return ZONE_HOP + diff; + } + return diff; +} diff --git a/judging/core/src/events.test.ts b/judging/core/src/events.test.ts new file mode 100644 index 00000000..72a63800 --- /dev/null +++ b/judging/core/src/events.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + boardChannel, + isOutboxTopic, + judgeChannel, + leaderboardChannel, + LOG_KINDS, + OUTBOX_TOPICS, +} from "./events"; +import type { LogKind } from "./events"; + +describe("events", () => { + it("only queues topics the log already knows", () => { + for (const topic of OUTBOX_TOPICS) { + expect(LOG_KINDS).toContain(topic); + expect(isOutboxTopic(topic)).toBe(true); + } + }); + + it("does not queue a log row that nobody subscribes to", () => { + expect(isOutboxTopic("project.upserted")).toBe(false); + }); + + it("names the channels the board, the leaderboard, and a judge listen on", () => { + expect(boardChannel("evt")).toBe("event:evt:board"); + expect(leaderboardChannel("evt")).toBe("event:evt:leaderboard"); + expect(judgeChannel("j1")).toBe("judge:j1"); + }); + + it("accepts every log kind as a log kind", () => { + const kind: LogKind = "vote.cast"; + expect(LOG_KINDS).toContain(kind); + }); +}); diff --git a/judging/core/src/events.ts b/judging/core/src/events.ts new file mode 100644 index 00000000..6e9278ed --- /dev/null +++ b/judging/core/src/events.ts @@ -0,0 +1,55 @@ +/** + * One vocabulary for the append-only log, the outbox, and the websocket. + * The server and the web both import this so a topic cannot drift. + */ + +export const LOG_KINDS = [ + "phase.changed", + "project.upserted", + "project.withdrawn", + "project.table_assigned", + "judge.upserted", + "judge.approved", + "judge.suspended", + "visit.handed_out", + "visit.arrived", + "visit.voided", + "vote.cast", + "comparison.cast", + "results.computed", + "results.published", + "results.unpublished", + "judge.recalled", +] as const; + +export type LogKind = (typeof LOG_KINDS)[number]; + +export const OUTBOX_TOPICS = [ + "visit.handed_out", + "visit.arrived", + "visit.voided", + "vote.cast", + "phase.changed", + "results.published", + "judge.recalled", +] as const; + +export type OutboxTopic = (typeof OUTBOX_TOPICS)[number]; + +const OUTBOX: ReadonlySet = new Set(OUTBOX_TOPICS); + +export function isOutboxTopic(kind: LogKind): kind is OutboxTopic { + return OUTBOX.has(kind); +} + +export function boardChannel(eventId: string): string { + return `event:${eventId}:board`; +} + +export function leaderboardChannel(eventId: string): string { + return `event:${eventId}:leaderboard`; +} + +export function judgeChannel(judgeId: string): string { + return `judge:${judgeId}`; +} diff --git a/judging/core/src/index.ts b/judging/core/src/index.ts new file mode 100644 index 00000000..6acfb348 --- /dev/null +++ b/judging/core/src/index.ts @@ -0,0 +1,54 @@ +export { bayesianShrink, round2 } from "./aggregate/bayes"; +export { bradleyTerry } from "./aggregate/bradleyTerry"; +export { blend } from "./aggregate/blend"; +export { rank } from "./aggregate/rank"; +export { zNormalize } from "./aggregate/zscore"; +export type { + ProjectInput, + RankComparison, + RankConfig, + RankedRow, + VoteInput, +} from "./aggregate/rank"; +export type { Comparison } from "./comparison"; +export { pickNext } from "./dispatch"; +export type { + Candidate, + DispatchConfig, + DispatchStrategy, + JudgeSpot, +} from "./dispatch"; +export { distance, ZONE_HOP } from "./distance"; +export type { TablePoint } from "./distance"; +export { + boardChannel, + isOutboxTopic, + judgeChannel, + leaderboardChannel, + LOG_KINDS, + OUTBOX_TOPICS, +} from "./events"; +export type { LogKind, OutboxTopic } from "./events"; +export { projectMatchesTrack } from "./match"; +export { + assertPhaseAllows, + assertTransition, + canTransition, + phaseAllows, + PHASES, +} from "./phase"; +export type { Phase, PhaseAction } from "./phase"; +export { validateScores, weightedTotal } from "./rubric"; +export type { Criterion, ScoreCheck, ScoreInput } from "./rubric"; +export { + DEFAULT_TIMERS, + isLive, + isOverTarget, + isPastCutoff, +} from "./timers"; +export type { TimerConfig, VisitClock } from "./timers"; +export { + contestBonus, + projectUncertainty, + scoreUncertainty, +} from "./uncertainty"; diff --git a/judging/core/src/match.test.ts b/judging/core/src/match.test.ts new file mode 100644 index 00000000..9127ca98 --- /dev/null +++ b/judging/core/src/match.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { projectMatchesTrack } from "./match"; + +describe("projectMatchesTrack", () => { + const project = { + tracks: ["Finance"], + challenges: ["Sponsor"], + isCreateX: true, + }; + + it("matches every project when the judge has no track", () => { + expect(projectMatchesTrack(project, null)).toBe(true); + }); + + it("matches by track, challenge, and the createx flag", () => { + expect(projectMatchesTrack(project, "Finance")).toBe(true); + expect(projectMatchesTrack(project, "Sponsor")).toBe(true); + expect(projectMatchesTrack(project, "createX")).toBe(true); + expect(projectMatchesTrack(project, "Health")).toBe(false); + }); +}); diff --git a/judging/core/src/match.ts b/judging/core/src/match.ts new file mode 100644 index 00000000..e2abcea4 --- /dev/null +++ b/judging/core/src/match.ts @@ -0,0 +1,20 @@ +/** + * Label match for an import that still carries track strings on the project. + * The product path is a project_track row. This remains so those strings + * route the same way they did before tracks were rows. + */ +export function projectMatchesTrack( + project: { + tracks: string[] | null; + challenges: string[] | null; + isCreateX?: boolean | null; + }, + track: string | null, +): boolean { + if (!track) return true; + const inTracks = project.tracks?.includes(track) ?? false; + const inChallenges = project.challenges?.includes(track) ?? false; + const matchCreateX = + track.toLowerCase() === "createx" && !!project.isCreateX; + return inTracks || inChallenges || matchCreateX; +} diff --git a/judging/core/src/package.test.ts b/judging/core/src/package.test.ts new file mode 100644 index 00000000..678fffae --- /dev/null +++ b/judging/core/src/package.test.ts @@ -0,0 +1,14 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +describe("@panel/core package", () => { + it("has no runtime dependencies", () => { + const packageJson = join(dirname(fileURLToPath(import.meta.url)), "../package.json"); + const pkg = JSON.parse(readFileSync(packageJson, "utf8")) as { + dependencies?: Record; + }; + expect(pkg.dependencies ?? {}).toEqual({}); + }); +}); diff --git a/judging/core/src/phase.test.ts b/judging/core/src/phase.test.ts new file mode 100644 index 00000000..a4f885ba --- /dev/null +++ b/judging/core/src/phase.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + assertPhaseAllows, + assertTransition, + canTransition, + phaseAllows, +} from "./phase"; + +describe("phase", () => { + it("walks an event from setup through archive", () => { + const path = [ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", + ] as const; + for (let index = 1; index < path.length; index += 1) { + const from = path[index - 1]; + const to = path[index]; + if (!from || !to) throw new Error("path"); + expect(canTransition(from, to)).toBe(true); + } + }); + + it("allows the steps an organizer actually takes backwards", () => { + expect(canTransition("submissions_open", "setup")).toBe(true); + expect(canTransition("submissions_closed", "submissions_open")).toBe(true); + expect(canTransition("judging_closed", "judging_live")).toBe(true); + expect(canTransition("published", "judging_closed")).toBe(true); + }); + + it("refuses a jump and a move out of the archive", () => { + expect(canTransition("setup", "judging_live")).toBe(false); + expect(canTransition("archived", "setup")).toBe(false); + expect(() => assertTransition("setup", "published")).toThrow(/setup/); + }); + + it("permits a vote only while judging is live", () => { + expect(phaseAllows("judging_live", "vote")).toBe(true); + expect(phaseAllows("judging_closed", "vote")).toBe(false); + expect(phaseAllows("submissions_open", "vote")).toBe(false); + expect(() => assertPhaseAllows("setup", "vote")).toThrow(/vote/); + }); + + it("permits dispatch only while judging is live, and publish only after it closes", () => { + expect(phaseAllows("judging_live", "dispatch")).toBe(true); + expect(phaseAllows("judging_closed", "dispatch")).toBe(false); + expect(phaseAllows("judging_closed", "publish")).toBe(true); + expect(phaseAllows("judging_live", "publish")).toBe(false); + expect(phaseAllows("judging_live", "compute")).toBe(true); + expect(phaseAllows("submissions_open", "submit")).toBe(true); + expect(phaseAllows("judging_live", "submit")).toBe(false); + expect(phaseAllows("setup", "apply")).toBe(true); + expect(phaseAllows("judging_live", "apply")).toBe(false); + }); +}); diff --git a/judging/core/src/phase.ts b/judging/core/src/phase.ts new file mode 100644 index 00000000..7a947a65 --- /dev/null +++ b/judging/core/src/phase.ts @@ -0,0 +1,63 @@ +/** + * What an event is allowed to do, and which phase it may move to next. + * The server checks this on every mutation. The core does not persist it. + */ + +export const PHASES = [ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", +] as const; + +export type Phase = (typeof PHASES)[number]; + +export type PhaseAction = + | "submit" + | "apply" + | "dispatch" + | "vote" + | "compute" + | "publish"; + +const TRANSITIONS: Record = { + setup: ["submissions_open"], + submissions_open: ["submissions_closed", "setup"], + submissions_closed: ["judging_live", "submissions_open"], + judging_live: ["judging_closed"], + judging_closed: ["published", "judging_live"], + published: ["archived", "judging_closed"], + archived: [], +}; + +const PERMISSIONS: Record = { + submit: ["submissions_open"], + apply: ["setup", "submissions_open", "submissions_closed"], + dispatch: ["judging_live"], + vote: ["judging_live"], + compute: ["judging_live", "judging_closed"], + publish: ["judging_closed"], +}; + +export function canTransition(from: Phase, to: Phase): boolean { + return TRANSITIONS[from].includes(to); +} + +export function assertTransition(from: Phase, to: Phase): void { + if (!canTransition(from, to)) { + throw new Error(`Cannot move an event from ${from} to ${to}`); + } +} + +export function phaseAllows(phase: Phase, action: PhaseAction): boolean { + return PERMISSIONS[action].includes(phase); +} + +export function assertPhaseAllows(phase: Phase, action: PhaseAction): void { + if (!phaseAllows(phase, action)) { + throw new Error(`${action} is not allowed while the event is ${phase}`); + } +} diff --git a/judging/core/src/rubric.test.ts b/judging/core/src/rubric.test.ts new file mode 100644 index 00000000..589db6f1 --- /dev/null +++ b/judging/core/src/rubric.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { validateScores, weightedTotal } from "./rubric"; +import type { Criterion } from "./rubric"; + +const criteria: Criterion[] = [ + { id: "creativity", min: 0, max: 10, weight: 1 }, + { id: "impact", min: 0, max: 10, weight: 3 }, +]; + +describe("validateScores", () => { + it("accepts one in-range value per criterion", () => { + expect( + validateScores(criteria, [ + { criterionId: "creativity", value: 0 }, + { criterionId: "impact", value: 10 }, + ]), + ).toEqual({ ok: true }); + }); + + it("rejects a missing, duplicate, unknown, or out-of-range score", () => { + expect(validateScores(criteria, []).ok).toBe(false); + expect( + validateScores(criteria, [ + { criterionId: "creativity", value: 1 }, + { criterionId: "creativity", value: 2 }, + { criterionId: "impact", value: 3 }, + ]).ok, + ).toBe(false); + expect( + validateScores(criteria, [ + { criterionId: "creativity", value: 1 }, + { criterionId: "nope", value: 1 }, + ]).ok, + ).toBe(false); + expect( + validateScores(criteria, [ + { criterionId: "creativity", value: 11 }, + { criterionId: "impact", value: 1 }, + ]).ok, + ).toBe(false); + }); +}); + +describe("weightedTotal", () => { + it("is the weighted mean", () => { + expect( + weightedTotal(criteria, [ + { criterionId: "creativity", value: 4 }, + { criterionId: "impact", value: 8 }, + ]), + ).toBe(7); + }); + + it("is zero when every weight is zero", () => { + const flat = criteria.map((criterion) => ({ ...criterion, weight: 0 })); + expect( + weightedTotal(flat, [ + { criterionId: "creativity", value: 4 }, + { criterionId: "impact", value: 8 }, + ]), + ).toBe(0); + }); + + it("throws when the scores are not valid", () => { + expect(() => weightedTotal(criteria, [])).toThrow(/missing/); + }); +}); diff --git a/judging/core/src/rubric.ts b/judging/core/src/rubric.ts new file mode 100644 index 00000000..14292b8d --- /dev/null +++ b/judging/core/src/rubric.ts @@ -0,0 +1,85 @@ +/** + * A score is one number per criterion, inside that criterion's range. + * The total is the weighted mean, so it stays on the same scale as the + * criteria. The rows are the truth; the total is derived. + */ + +export type Criterion = { + id: string; + min: number; + max: number; + weight: number; +}; + +export type ScoreInput = { + criterionId: string; + value: number; +}; + +export type ScoreCheck = { ok: true } | { ok: false; reason: string }; + +export function validateScores( + criteria: readonly Criterion[], + input: readonly ScoreInput[], +): ScoreCheck { + for (const criterion of criteria) { + if (!Number.isFinite(criterion.weight) || criterion.weight < 0) { + return { + ok: false, + reason: `weight for ${criterion.id} must be zero or more`, + }; + } + if ( + !Number.isFinite(criterion.min) || + !Number.isFinite(criterion.max) || + criterion.min > criterion.max + ) { + return { ok: false, reason: `range for ${criterion.id} is invalid` }; + } + } + + const seen = new Set(); + for (const score of input) { + if (seen.has(score.criterionId)) { + return { ok: false, reason: `duplicate score for ${score.criterionId}` }; + } + seen.add(score.criterionId); + const criterion = criteria.find((item) => item.id === score.criterionId); + if (!criterion) { + return { ok: false, reason: `unknown criterion ${score.criterionId}` }; + } + if ( + !Number.isFinite(score.value) || + score.value < criterion.min || + score.value > criterion.max + ) { + return { + ok: false, + reason: `${score.criterionId} must be between ${criterion.min} and ${criterion.max}`, + }; + } + } + + for (const criterion of criteria) { + if (!seen.has(criterion.id)) { + return { ok: false, reason: `missing score for ${criterion.id}` }; + } + } + + return { ok: true }; +} + +export function weightedTotal( + criteria: readonly Criterion[], + input: readonly ScoreInput[], +): number { + const check = validateScores(criteria, input); + if (!check.ok) throw new Error(check.reason); + const weight = criteria.reduce((sum, criterion) => sum + criterion.weight, 0); + if (weight === 0) return 0; + const byId = new Map(input.map((score) => [score.criterionId, score.value])); + const sum = criteria.reduce((total, criterion) => { + return total + (byId.get(criterion.id) ?? 0) * criterion.weight; + }, 0); + return sum / weight; +} diff --git a/judging/core/src/timers.test.ts b/judging/core/src/timers.test.ts new file mode 100644 index 00000000..24c3a19d --- /dev/null +++ b/judging/core/src/timers.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_TIMERS, isLive, isOverTarget, isPastCutoff } from "./timers"; + +const t0 = new Date("2027-02-28T13:00:00Z"); +const at = (seconds: number) => new Date(t0.getTime() + seconds * 1000); +const timers = DEFAULT_TIMERS; + +describe("isLive", () => { + const walking = { + handedOutAt: t0, + arrivedAt: null, + completedAt: null, + }; + const atTable = { + handedOutAt: t0, + arrivedAt: at(60), + completedAt: null, + }; + + it("holds a table for the walk limit after hand-out", () => { + expect(isLive(walking, at(timers.walkLimitSeconds), timers)).toBe(true); + expect(isLive(walking, at(timers.walkLimitSeconds + 1), timers)).toBe( + false, + ); + }); + + it("holds it through the hard limit and grace after arrival", () => { + const end = + 60 + timers.hardLimitSeconds + timers.submitGraceSeconds; + expect(isLive(atTable, at(end), timers)).toBe(true); + expect(isLive(atTable, at(end + 1), timers)).toBe(false); + }); + + it("measures from arrival, not hand-out, once the judge has tapped in", () => { + expect(isLive(atTable, at(timers.walkLimitSeconds + 30), timers)).toBe( + true, + ); + }); + + it("is never live once completed, or when never handed out", () => { + expect( + isLive({ ...atTable, completedAt: at(61) }, at(61), timers), + ).toBe(false); + expect( + isLive( + { handedOutAt: null, arrivedAt: null, completedAt: null }, + t0, + timers, + ), + ).toBe(false); + }); + + it("honours a shorter limit from config", () => { + const short = { ...timers, walkLimitSeconds: 30 }; + expect(isLive(walking, at(30), short)).toBe(true); + expect(isLive(walking, at(31), short)).toBe(false); + }); +}); + +describe("isPastCutoff", () => { + it("allows a score at the hard limit and within the grace after it", () => { + expect(isPastCutoff(t0, at(timers.hardLimitSeconds), timers)).toBe(false); + expect( + isPastCutoff( + t0, + at(timers.hardLimitSeconds + timers.submitGraceSeconds), + timers, + ), + ).toBe(false); + }); + + it("refuses one after the grace", () => { + expect( + isPastCutoff( + t0, + at(timers.hardLimitSeconds + timers.submitGraceSeconds + 1), + timers, + ), + ).toBe(true); + }); +}); + +describe("isOverTarget", () => { + it("turns over at the target and not before", () => { + expect(isOverTarget(t0, at(timers.targetSeconds), timers)).toBe(false); + expect(isOverTarget(t0, at(timers.targetSeconds + 1), timers)).toBe(true); + }); +}); diff --git a/judging/core/src/timers.ts b/judging/core/src/timers.ts new file mode 100644 index 00000000..d14bdd5b --- /dev/null +++ b/judging/core/src/timers.ts @@ -0,0 +1,74 @@ +/** + * Visit clocks. Every function takes `now` so a replay of an event does not + * depend on when the code happens to run. + * + * The defaults match the timings the first event used: three minutes at a + * table, four minutes hard, four minutes to walk, fifteen seconds of grace + * for a submit that left the phone on the buzzer. + */ + +export type TimerConfig = { + targetSeconds: number; + hardLimitSeconds: number; + walkLimitSeconds: number; + submitGraceSeconds: number; +}; + +export const DEFAULT_TIMERS: TimerConfig = { + targetSeconds: 180, + hardLimitSeconds: 240, + walkLimitSeconds: 240, + submitGraceSeconds: 15, +}; + +export type VisitClock = { + handedOutAt: Date | null; + arrivedAt: Date | null; + completedAt: Date | null; +}; + +/** + * Whether a visit still holds its table. Walking: until the walk limit after + * hand-out. At the table: until the hard limit (plus grace) after arrival. + */ +export function isLive( + slot: VisitClock, + now: Date, + config: TimerConfig, +): boolean { + if (slot.completedAt) return false; + const t = now.getTime(); + if (slot.arrivedAt) { + return ( + t <= + slot.arrivedAt.getTime() + + (config.hardLimitSeconds + config.submitGraceSeconds) * 1000 + ); + } + if (slot.handedOutAt) { + return t <= slot.handedOutAt.getTime() + config.walkLimitSeconds * 1000; + } + return false; +} + +/** Past the hard cutoff, with grace: a score now arrives too late to count. */ +export function isPastCutoff( + arrivedAt: Date, + now: Date, + config: TimerConfig, +): boolean { + return ( + now.getTime() > + arrivedAt.getTime() + + (config.hardLimitSeconds + config.submitGraceSeconds) * 1000 + ); +} + +/** The judge page turns amber here. Measured from arrival, not hand-out. */ +export function isOverTarget( + arrivedAt: Date, + now: Date, + config: TimerConfig, +): boolean { + return now.getTime() > arrivedAt.getTime() + config.targetSeconds * 1000; +} diff --git a/judging/core/src/uncertainty.test.ts b/judging/core/src/uncertainty.test.ts new file mode 100644 index 00000000..d97fc34e --- /dev/null +++ b/judging/core/src/uncertainty.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + contestBonus, + projectUncertainty, + scoreUncertainty, +} from "./uncertainty"; + +describe("scoreUncertainty", () => { + it("is infinite when nobody has scored the project", () => { + expect(scoreUncertainty([])).toBe(Number.POSITIVE_INFINITY); + }); + + it("is zero when every score agrees", () => { + expect(scoreUncertainty([5, 5, 5])).toBe(0); + }); + + it("shrinks as the same spread is seen more times", () => { + const small = scoreUncertainty([0, 10]); + const large = scoreUncertainty([0, 10, 0, 10]); + expect(large).toBeLessThan(small); + }); +}); + +describe("contestBonus", () => { + it("is zero when one project always wins", () => { + expect( + contestBonus( + [ + { a: "a", b: "b", outcome: "a" }, + { a: "a", b: "b", outcome: "a" }, + ], + "a", + ), + ).toBe(0); + }); + + it("is one when the two projects split", () => { + expect( + contestBonus( + [ + { a: "a", b: "b", outcome: "a" }, + { a: "a", b: "b", outcome: "b" }, + ], + "a", + ), + ).toBe(1); + }); + + it("counts a tie as contested", () => { + expect(contestBonus([{ a: "a", b: "b", outcome: "tie" }], "a")).toBe(1); + }); +}); + +describe("projectUncertainty", () => { + it("adds the contest bonus on top of the score spread", () => { + const scores = [0, 10]; + const bonus = contestBonus([{ a: "a", b: "b", outcome: "tie" }], "a"); + expect( + projectUncertainty(scores, [{ a: "a", b: "b", outcome: "tie" }], "a"), + ).toBeCloseTo(scoreUncertainty(scores) + bonus, 10); + }); + + it("stays infinite when there are no scores yet", () => { + expect(projectUncertainty([], [], "a")).toBe(Number.POSITIVE_INFINITY); + }); +}); diff --git a/judging/core/src/uncertainty.ts b/judging/core/src/uncertainty.ts new file mode 100644 index 00000000..07a5b4b2 --- /dev/null +++ b/judging/core/src/uncertainty.ts @@ -0,0 +1,66 @@ +import type { Comparison } from "./comparison"; + +/** + * How unsettled a project's rank is. + * Score spread shrinks as more judges agree and as the sample grows. + * A pairwise neighbour that is split (or tied) adds a bonus, because those + * two projects are the ones another look can still reorder. + */ + +export function scoreUncertainty(scores: readonly number[]): number { + const n = scores.length; + if (n === 0) return Number.POSITIVE_INFINITY; + const mean = scores.reduce((sum, value) => sum + value, 0) / n; + const variance = + scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / n; + return variance / Math.sqrt(n); +} + +export function contestBonus( + comparisons: readonly Comparison[], + projectId: string, +): number { + const against = new Map< + string, + { wins: number; losses: number; ties: number } + >(); + + for (const comparison of comparisons) { + const opponent = + comparison.a === projectId + ? comparison.b + : comparison.b === projectId + ? comparison.a + : null; + if (!opponent) continue; + const row = against.get(opponent) ?? { wins: 0, losses: 0, ties: 0 }; + if (comparison.outcome === "tie") row.ties += 1; + else if ( + (comparison.outcome === "a" && comparison.a === projectId) || + (comparison.outcome === "b" && comparison.b === projectId) + ) { + row.wins += 1; + } else { + row.losses += 1; + } + against.set(opponent, row); + } + + let bonus = 0; + for (const row of against.values()) { + const n = row.wins + row.losses + row.ties; + if (n === 0) continue; + bonus += (n - Math.abs(row.wins - row.losses)) / n; + } + return bonus; +} + +export function projectUncertainty( + scores: readonly number[], + comparisons: readonly Comparison[], + projectId: string, +): number { + const spread = scoreUncertainty(scores); + if (!Number.isFinite(spread)) return spread; + return spread + contestBonus(comparisons, projectId); +} diff --git a/judging/core/tsconfig.json b/judging/core/tsconfig.json new file mode 100644 index 00000000..f54bde2c --- /dev/null +++ b/judging/core/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tooling/typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/judging/core/vitest.config.ts b/judging/core/vitest.config.ts new file mode 100644 index 00000000..ae847ff6 --- /dev/null +++ b/judging/core/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/judging/docs/adr/001-standalone.md b/judging/docs/adr/001-standalone.md new file mode 100644 index 00000000..6c417fb3 --- /dev/null +++ b/judging/docs/adr/001-standalone.md @@ -0,0 +1,22 @@ +# ADR-001: Standalone product + +## Status + +Accepted + +## Decision + +Panel lives under `judging/` with its own packages, its own Postgres database, +and its own deploy. It does not share a schema or a module graph with the +application that hosts the first event. + +Internal imports are `@panel/*` only. A lint rule rejects imports from the +host. The host is allowed to import `@panel/*`. That one-way edge is what +makes `git subtree split --prefix=judging` a later extraction rather than a +rewrite. + +## Why + +Judging welded into one event's tables cannot be given to the next event, or +to anyone else, without carrying the rest of that application with it. +A separate database means the extraction has no data to untangle. diff --git a/judging/docs/adr/002-pure-core.md b/judging/docs/adr/002-pure-core.md new file mode 100644 index 00000000..1421615c --- /dev/null +++ b/judging/docs/adr/002-pure-core.md @@ -0,0 +1,22 @@ +# ADR-002: Pure core + +## Status + +Accepted + +## Decision + +Every decision Panel makes — the next table, whether a visit is still live, +whether a score is valid, how a set of votes becomes a ranking — is a function +in `@panel/core`. Those functions take plain data, including `now` and a +`random` source, and return plain data. They do not read the clock, generate +randomness, or touch a database, the network, or the filesystem. + +The server is the only place that performs I/O. It loads rows, calls the +core, and writes the result in the same transaction as the event log. + +## Why + +A ranking you cannot recompute from the votes is not a result you can defend. +Tests of the core need no database, so the scoring bugs surface without +standing up Postgres. The same functions can later run offline on an export. diff --git a/package.json b/package.json index 6dcbb690..f3ddfc8d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint": "turbo run lint", "format": "prettier --write .", "typecheck": "turbo run typecheck", - "test": "vitest run packages/api packages/auth packages/db sites/mainweb/lib" + "test": "vitest run packages/api packages/auth packages/db sites/mainweb/lib judging/core" }, "dependencies": { "next": "16.3.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8907bf4d..056a6a71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,24 @@ importers: specifier: ^5.0.1 version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + judging/core: + devDependencies: + '@query/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@query/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: ^5.0.1 + version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + packages/api: dependencies: '@query/auth': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4da7f960..c9f5f1e7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,3 +7,4 @@ packages: - packages/auth - packages/db - packages/ui + - judging/* From 5ee687360325f32e0461ec45fc1d1f10efe7db0d Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 8 Oct 2026 13:14:42 -0400 Subject: [PATCH 2/8] feat(panel): run judging on the shared database One API and the club Postgres, with the judge desk, organizer console, and a handoff from the club site when an edition opts in. Co-authored-by: Cursor --- .github/workflows/panel.yml | 75 ++ docs/architecture.md | 2 +- judging/.github/ISSUE_TEMPLATE/bug.md | 11 + judging/.github/ISSUE_TEMPLATE/feature.md | 9 + judging/CHANGELOG.md | 8 + judging/CONTRIBUTING.md | 28 + judging/README.md | 38 +- judging/cli/eslint.config.mjs | 27 + judging/cli/package.json | 24 + judging/cli/src/csv.test.ts | 41 ++ judging/cli/src/csv.ts | 127 ++++ judging/cli/src/main.ts | 153 ++++ judging/cli/src/transfer.ts | 67 ++ judging/cli/tsconfig.json | 10 + judging/core/package.json | 11 + judging/core/scripts/emit-extensions.mjs | 25 + judging/core/src/aggregate/rank.test.ts | 42 +- judging/core/src/aggregate/rank.ts | 8 + judging/core/src/dispatch.sim.test.ts | 120 ++++ judging/core/src/events.ts | 11 + judging/core/src/index.ts | 2 + judging/core/src/standalone.test.ts | 34 + judging/core/src/tables.test.ts | 34 + judging/core/src/tables.ts | 52 ++ judging/core/tsconfig.build.json | 12 + judging/db/eslint.config.mjs | 24 + judging/db/package.json | 25 + judging/db/src/client.ts | 16 + judging/db/src/env.ts | 11 + judging/db/src/index.ts | 6 + judging/db/src/migrate.ts | 69 ++ judging/db/src/schema.ts | 433 ++++++++++++ judging/db/src/seed.ts | 238 +++++++ judging/db/tsconfig.json | 10 + judging/docs/adr/001-standalone.md | 10 +- judging/docs/adr/003-one-database.md | 26 + judging/docs/api.md | 17 + judging/docs/deploy.md | 25 + judging/docs/rubric.md | 7 + judging/docs/scoring.md | 9 + judging/infra/docker/Dockerfile | 15 + judging/infra/docker/Dockerfile.web | 30 + judging/infra/docker/docker-compose.yml | 30 + judging/infra/gcp/main.tf | 129 ++++ judging/server/eslint.config.mjs | 28 + judging/server/package.json | 38 + judging/server/src/app.test.ts | 18 + judging/server/src/app.ts | 391 +++++++++++ judging/server/src/auth.test.ts | 16 + judging/server/src/auth.ts | 100 +++ judging/server/src/bus.test.ts | 51 ++ judging/server/src/bus.ts | 131 ++++ judging/server/src/cross-server.check.ts | 111 +++ judging/server/src/env.ts | 11 + judging/server/src/hub.ts | 31 + judging/server/src/index.ts | 73 ++ judging/server/src/log.ts | 3 + judging/server/src/mail.ts | 12 + judging/server/src/metrics.ts | 40 ++ judging/server/src/router.ts | 652 ++++++++++++++++++ judging/server/src/services/arrive.ts | 76 ++ judging/server/src/services/catalog.ts | 454 ++++++++++++ judging/server/src/services/compare.ts | 63 ++ .../src/services/dispatch.concurrent.test.ts | 33 + judging/server/src/services/dispatch.ts | 413 +++++++++++ judging/server/src/services/feedback.test.ts | 18 + judging/server/src/services/feedback.ts | 17 + judging/server/src/services/floor.test.ts | 29 + judging/server/src/services/floor.ts | 79 +++ .../src/services/flow.integration.test.ts | 110 +++ judging/server/src/services/live.ts | 122 ++++ judging/server/src/services/logs.test.ts | 16 + judging/server/src/services/logs.ts | 28 + judging/server/src/services/outbox.ts | 52 ++ judging/server/src/services/overtime.test.ts | 30 + judging/server/src/services/overtime.ts | 49 ++ judging/server/src/services/records.ts | 358 ++++++++++ .../src/services/redis.integration.test.ts | 41 ++ judging/server/src/services/results.test.ts | 61 ++ judging/server/src/services/results.ts | 328 +++++++++ judging/server/src/services/vote.test.ts | 48 ++ judging/server/src/services/vote.ts | 175 +++++ judging/server/tsconfig.json | 10 + judging/server/vitest.config.ts | 8 + .../web/app/e/[orgSlug]/[eventSlug]/board.tsx | 92 +++ .../[eventSlug]/feedback/[token]/page.tsx | 48 ++ .../web/app/e/[orgSlug]/[eventSlug]/page.tsx | 20 + .../web/app/j/[orgSlug]/[eventSlug]/desk.tsx | 474 +++++++++++++ .../web/app/j/[orgSlug]/[eventSlug]/page.tsx | 16 + judging/web/app/layout.tsx | 30 + .../app/o/[orgSlug]/[eventSlug]/console.tsx | 408 +++++++++++ .../app/o/[orgSlug]/[eventSlug]/editors.tsx | 338 +++++++++ .../web/app/o/[orgSlug]/[eventSlug]/page.tsx | 16 + judging/web/app/page.tsx | 8 + judging/web/e2e/visit.spec.ts | 61 ++ judging/web/eslint.config.mjs | 23 + judging/web/lib/api.ts | 15 + judging/web/lib/held.ts | 59 ++ judging/web/next.config.ts | 7 + judging/web/package.json | 26 + judging/web/playwright.config.ts | 9 + judging/web/public/manifest.webmanifest | 8 + judging/web/test-results/.last-run.json | 4 + judging/web/tsconfig.json | 11 + package.json | 2 +- packages/api/src/routers/judge/admin.ts | 11 +- packages/api/src/routers/judge/portal.ts | 20 + packages/api/src/routers/judge/rankings.ts | 100 +-- packages/api/src/routers/judge/replay.test.ts | 55 ++ packages/api/src/routers/judge/weight.ts | 84 +++ packages/api/src/services/panel-sync.ts | 225 ++++++ packages/api/tsconfig.json | 2 +- packages/db/src/schemas/hackathons.ts | 5 + pnpm-lock.yaml | 286 +++++++- .../app/(portal)/admin/judging/page.tsx | 10 + sites/mainweb/app/(portal)/judge/page.tsx | 8 + turbo.json | 17 +- 117 files changed, 8810 insertions(+), 113 deletions(-) create mode 100644 .github/workflows/panel.yml create mode 100644 judging/.github/ISSUE_TEMPLATE/bug.md create mode 100644 judging/.github/ISSUE_TEMPLATE/feature.md create mode 100644 judging/CHANGELOG.md create mode 100644 judging/CONTRIBUTING.md create mode 100644 judging/cli/eslint.config.mjs create mode 100644 judging/cli/package.json create mode 100644 judging/cli/src/csv.test.ts create mode 100644 judging/cli/src/csv.ts create mode 100644 judging/cli/src/main.ts create mode 100644 judging/cli/src/transfer.ts create mode 100644 judging/cli/tsconfig.json create mode 100644 judging/core/scripts/emit-extensions.mjs create mode 100644 judging/core/src/dispatch.sim.test.ts create mode 100644 judging/core/src/standalone.test.ts create mode 100644 judging/core/src/tables.test.ts create mode 100644 judging/core/src/tables.ts create mode 100644 judging/core/tsconfig.build.json create mode 100644 judging/db/eslint.config.mjs create mode 100644 judging/db/package.json create mode 100644 judging/db/src/client.ts create mode 100644 judging/db/src/env.ts create mode 100644 judging/db/src/index.ts create mode 100644 judging/db/src/migrate.ts create mode 100644 judging/db/src/schema.ts create mode 100644 judging/db/src/seed.ts create mode 100644 judging/db/tsconfig.json create mode 100644 judging/docs/adr/003-one-database.md create mode 100644 judging/docs/api.md create mode 100644 judging/docs/deploy.md create mode 100644 judging/docs/rubric.md create mode 100644 judging/docs/scoring.md create mode 100644 judging/infra/docker/Dockerfile create mode 100644 judging/infra/docker/Dockerfile.web create mode 100644 judging/infra/docker/docker-compose.yml create mode 100644 judging/infra/gcp/main.tf create mode 100644 judging/server/eslint.config.mjs create mode 100644 judging/server/package.json create mode 100644 judging/server/src/app.test.ts create mode 100644 judging/server/src/app.ts create mode 100644 judging/server/src/auth.test.ts create mode 100644 judging/server/src/auth.ts create mode 100644 judging/server/src/bus.test.ts create mode 100644 judging/server/src/bus.ts create mode 100644 judging/server/src/cross-server.check.ts create mode 100644 judging/server/src/env.ts create mode 100644 judging/server/src/hub.ts create mode 100644 judging/server/src/index.ts create mode 100644 judging/server/src/log.ts create mode 100644 judging/server/src/mail.ts create mode 100644 judging/server/src/metrics.ts create mode 100644 judging/server/src/router.ts create mode 100644 judging/server/src/services/arrive.ts create mode 100644 judging/server/src/services/catalog.ts create mode 100644 judging/server/src/services/compare.ts create mode 100644 judging/server/src/services/dispatch.concurrent.test.ts create mode 100644 judging/server/src/services/dispatch.ts create mode 100644 judging/server/src/services/feedback.test.ts create mode 100644 judging/server/src/services/feedback.ts create mode 100644 judging/server/src/services/floor.test.ts create mode 100644 judging/server/src/services/floor.ts create mode 100644 judging/server/src/services/flow.integration.test.ts create mode 100644 judging/server/src/services/live.ts create mode 100644 judging/server/src/services/logs.test.ts create mode 100644 judging/server/src/services/logs.ts create mode 100644 judging/server/src/services/outbox.ts create mode 100644 judging/server/src/services/overtime.test.ts create mode 100644 judging/server/src/services/overtime.ts create mode 100644 judging/server/src/services/records.ts create mode 100644 judging/server/src/services/redis.integration.test.ts create mode 100644 judging/server/src/services/results.test.ts create mode 100644 judging/server/src/services/results.ts create mode 100644 judging/server/src/services/vote.test.ts create mode 100644 judging/server/src/services/vote.ts create mode 100644 judging/server/tsconfig.json create mode 100644 judging/server/vitest.config.ts create mode 100644 judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx create mode 100644 judging/web/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx create mode 100644 judging/web/app/e/[orgSlug]/[eventSlug]/page.tsx create mode 100644 judging/web/app/j/[orgSlug]/[eventSlug]/desk.tsx create mode 100644 judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx create mode 100644 judging/web/app/layout.tsx create mode 100644 judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx create mode 100644 judging/web/app/o/[orgSlug]/[eventSlug]/editors.tsx create mode 100644 judging/web/app/o/[orgSlug]/[eventSlug]/page.tsx create mode 100644 judging/web/app/page.tsx create mode 100644 judging/web/e2e/visit.spec.ts create mode 100644 judging/web/eslint.config.mjs create mode 100644 judging/web/lib/api.ts create mode 100644 judging/web/lib/held.ts create mode 100644 judging/web/next.config.ts create mode 100644 judging/web/package.json create mode 100644 judging/web/playwright.config.ts create mode 100644 judging/web/public/manifest.webmanifest create mode 100644 judging/web/test-results/.last-run.json create mode 100644 judging/web/tsconfig.json create mode 100644 packages/api/src/routers/judge/replay.test.ts create mode 100644 packages/api/src/routers/judge/weight.ts create mode 100644 packages/api/src/services/panel-sync.ts diff --git a/.github/workflows/panel.yml b/.github/workflows/panel.yml new file mode 100644 index 00000000..69d4aed6 --- /dev/null +++ b/.github/workflows/panel.yml @@ -0,0 +1,75 @@ +name: panel + +on: + push: + paths: + - "judging/**" + - ".github/workflows/panel.yml" + pull_request: + paths: + - "judging/**" + - ".github/workflows/panel.yml" + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @panel/core --filter @panel/db --filter @panel/server --filter @panel/cli --filter @panel/web lint + - run: pnpm --filter @panel/core --filter @panel/db --filter @panel/server --filter @panel/cli --filter @panel/web typecheck + - run: pnpm --filter @panel/core build + - run: pnpm --filter @panel/web build + - run: pnpm --filter @panel/core --filter @panel/server --filter @panel/cli test + + publish: + if: startsWith(github.ref, 'refs/tags/panel-v') + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: . + file: judging/infra/docker/Dockerfile + push: true + tags: ghcr.io/${{ github.repository }}/panel-server:${{ github.ref_name }} + - uses: docker/build-push-action@v6 + with: + context: . + file: judging/infra/docker/Dockerfile.web + push: true + build-args: NEXT_PUBLIC_PANEL_URL=${{ vars.NEXT_PUBLIC_PANEL_URL || 'http://localhost:8787' }} + tags: ghcr.io/${{ github.repository }}/panel-web:${{ github.ref_name }} + + release-core: + if: startsWith(github.ref, 'refs/tags/panel-v') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + registry-url: https://registry.npmjs.org + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @panel/core build + - working-directory: judging/core + run: npm pkg set private=false && npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/docs/architecture.md b/docs/architecture.md index 64914af3..af80d523 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,7 +33,7 @@ ## Two products, one database -The schema is split on purpose. Mixing them previously made membership vanish when a new hackathon edition was drafted. +The schema is split on purpose. Mixing them previously made membership vanish when a new hackathon edition was drafted. Panel judging tables live in this same Postgres database. ### Club diff --git a/judging/.github/ISSUE_TEMPLATE/bug.md b/judging/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 00000000..6da6dc23 --- /dev/null +++ b/judging/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,11 @@ +--- +name: Bug +description: Something in panel behaved wrongly +labels: bug +--- + +## What happened + +## What you expected + +## How to reproduce diff --git a/judging/.github/ISSUE_TEMPLATE/feature.md b/judging/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 00000000..41836efd --- /dev/null +++ b/judging/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,9 @@ +--- +name: Feature +description: A change to how panel runs an event +labels: enhancement +--- + +## What should change + +## Who it is for diff --git a/judging/CHANGELOG.md b/judging/CHANGELOG.md new file mode 100644 index 00000000..2f5090c7 --- /dev/null +++ b/judging/CHANGELOG.md @@ -0,0 +1,8 @@ +# Panel + +## 0.0.0 + +- Pure core for dispatch, rubrics, phases, and ranking. +- Postgres schema with committed SQL migrations, a demo seed, and `panel` commands for migrate, seed, import, export, and doctor. +- HTTP server for judge sessions, results, health, metrics, and a websocket bus. +- Judge desk, organizer console, public board, and a feedback link. diff --git a/judging/CONTRIBUTING.md b/judging/CONTRIBUTING.md new file mode 100644 index 00000000..6df5c2bf --- /dev/null +++ b/judging/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contributing + +Panel is the tree under `judging/`. It does not import the rest of this repository. + +## Checks + +From the repository root: + +``` +pnpm --filter @panel/core test +pnpm --filter @panel/server test +pnpm --filter @panel/cli test +pnpm --filter @panel/core lint +pnpm --filter @panel/server lint +pnpm --filter @panel/web lint +pnpm --filter @panel/db typecheck +pnpm --filter @panel/server typecheck +pnpm --filter @panel/web typecheck +pnpm --filter @panel/cli typecheck +``` + +## Migrations + +Schema changes are SQL files in `judging/db/migrations`, applied with `panel migrate`. Do not rename or drop a column in the same change that ships code reading the new shape. + +## Pull requests + +Describe the behaviour a judge or an organizer will see. Include the command you ran. diff --git a/judging/README.md b/judging/README.md index 5b99dcec..1a23e22f 100644 --- a/judging/README.md +++ b/judging/README.md @@ -2,8 +2,10 @@ A hackathon judging product: its own data, its own API, its own web app. Rubrics, tracks, timers, rooms, and dispatch strategy are rows, not code. -Postgres and Node are enough to run it. Redis is optional and never required -for a correct result. +Postgres and Node are enough to run it. Set `REDIS_URL` when more than one +server process should share live updates. Without it, each process fans out +in memory, and a client that misses a message reloads the same state from +the next snapshot. `judging/` does not import the host application. The host may call `@panel/*`. When the product is stable it leaves this repository with @@ -14,10 +16,10 @@ When the product is stable it leaves this repository with | Path | Package | What it is | | --- | --- | --- | | `core/` | `@panel/core` | Pure functions. No I/O, no clock, no runtime dependencies. | -| `db/` | `@panel/db` | Schema and committed SQL migrations. Not built yet. | -| `server/` | `@panel/server` | HTTP API. Not built yet. | -| `web/` | `@panel/web` | Judge, admin, and public views. Not built yet. | -| `cli/` | `@panel/cli` | Migrate, seed, import, export. Not built yet. | +| `db/` | `@panel/db` | Schema, committed SQL migrations, demo seed. | +| `server/` | `@panel/server` | HTTP API: dispatch, votes, health, metrics. | +| `web/` | `@panel/web` | Judge desk, organizer console, public board. | +| `cli/` | `@panel/cli` | `panel migrate`, `panel seed demo`, `panel doctor`. | ## Run the core @@ -29,4 +31,26 @@ pnpm --filter @panel/core typecheck pnpm --filter @panel/core lint ``` -`pnpm test` includes `@panel/core`. +`pnpm test` includes `@panel/core` and `@panel/server`. + +## Database and server + +Postgres is the one database the rest of the monorepo uses (`DATABASE_URL`). Panel migrations add its tables there. `PANEL_DATABASE_URL` is only a fallback when `DATABASE_URL` is unset. + +``` +docker compose -f judging/infra/docker/docker-compose.yml up --build +``` + +On the host, pointed at that database: + +``` +$env:PANEL_DATABASE_URL = "postgres://panel:panel@localhost:54329/panel" +$env:PANEL_JWT_SECRET = "dev-only-change-me" +pnpm --filter @panel/cli panel migrate +pnpm --filter @panel/cli panel seed demo +pnpm --filter @panel/cli panel doctor +``` + +`panel doctor` checks Postgres, pending migrations, and `PANEL_JWT_SECRET`. Redis is reported and not required. + +The HTTP server is Hono. tRPC is mounted at `/trpc`. `GET /healthz` is the process, `GET /readyz` is Postgres, `GET /metrics` is Prometheus. `GET /openapi.json` lists the REST routes. How to authenticate is in `docs/api.md`. Magic-link codes are returned in the response only when `PANEL_DEV_AUTH=1`, which is how the compose stack signs in without SMTP. diff --git a/judging/cli/eslint.config.mjs b/judging/cli/eslint.config.mjs new file mode 100644 index 00000000..a96e7a28 --- /dev/null +++ b/judging/cli/eslint.config.mjs @@ -0,0 +1,27 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...config, + { + files: ["src/**/*.ts"], + rules: { "no-console": "off" }, + }, + { + files: ["src/**/*.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["@query", "@query/*"], + message: + "judging/ is a standalone product. Import from @panel/* only.", + }, + ], + }, + ], + }, + }, +]; diff --git a/judging/cli/package.json b/judging/cli/package.json new file mode 100644 index 00000000..027dc16c --- /dev/null +++ b/judging/cli/package.json @@ -0,0 +1,24 @@ +{ + "name": "@panel/cli", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "panel": "tsx src/main.ts", + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc --noEmit", + "test": "node ../../node_modules/vitest/vitest.mjs run src/csv.test.ts" + }, + "dependencies": { + "@panel/db": "workspace:*", + "@panel/server": "workspace:*", + "drizzle-orm": "0.45.3", + "tsx": "^4.23.15" + }, + "devDependencies": { + "@query/eslint-config": "workspace:*", + "@query/tsconfig": "workspace:*", + "@types/node": "22.20.4", + "typescript": "7.0.2" + } +} diff --git a/judging/cli/src/csv.test.ts b/judging/cli/src/csv.test.ts new file mode 100644 index 00000000..b451f79c --- /dev/null +++ b/judging/cli/src/csv.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { formatResultsCsv, formatResultsJson, parseProjectCsv } from "./csv"; + +describe("parseProjectCsv", () => { + it("reads a Devpost-style header", () => { + const rows = parseProjectCsv( + "Project Title,Submission Url,Team Name,Table\nAlpha,https://example.com/a,A Team,4\n", + ); + expect(rows).toEqual([ + { + externalId: "https://example.com/a", + name: "Alpha", + teamName: "A Team", + tableNumber: 4, + }, + ]); + }); + + it("uses a column map when the headers are not the defaults", () => { + const rows = parseProjectCsv("Title,Squad\nBeta,B Team\n", { name: "Title", team: "Squad" }); + expect(rows[0]?.name).toBe("Beta"); + expect(rows[0]?.teamName).toBe("B Team"); + }); + + it("rejects a sheet with no name column", () => { + expect(() => parseProjectCsv("notes\nhello\n")).toThrow(/name/); + }); +}); + +describe("formatResultsCsv", () => { + it("quotes a name that contains a comma", () => { + expect( + formatResultsCsv([{ project: "A, B", track: "general", placement: 1, score: 7.71 }]), + ).toBe('project,track,placement,score\n"A, B",general,1,7.71\n'); + }); + + it("writes the same rows as JSON", () => { + const rows = [{ project: "Alpha", track: "general", placement: 1, score: 7.71 }]; + expect(JSON.parse(formatResultsJson(rows))).toEqual(rows); + }); +}); diff --git a/judging/cli/src/csv.ts b/judging/cli/src/csv.ts new file mode 100644 index 00000000..3414472a --- /dev/null +++ b/judging/cli/src/csv.ts @@ -0,0 +1,127 @@ +export type ImportedProject = { + externalId: string; + name: string; + teamName: string | null; + tableNumber: number | null; +}; + +/** Devpost exports and a plain name,team,table sheet. The first row is headers. + * A column map renames headers: `name: Project Title`. + */ +export function parseProjectCsv( + text: string, + columnMap: Record = {}, +): ImportedProject[] { + const rows = parseRows(text); + const header = rows[0]; + if (!header) return []; + const index = new Map(header.map((cell, i) => [cell.trim().toLowerCase(), i])); + const nameAt = column(index, columnMap.name, ["project title", "name"]); + if (nameAt === undefined) { + throw new Error("CSV needs a name or Project Title column"); + } + const teamAt = column(index, columnMap.team, ["team name", "team"]); + const tableAt = column(index, columnMap.table, ["table", "table number"]); + const idAt = column(index, columnMap.externalId, ["submission url", "external id", "id"]); + + return rows.slice(1).flatMap((row) => { + const name = row[nameAt]?.trim(); + if (!name) return []; + const tableRaw = tableAt === undefined ? "" : (row[tableAt] ?? ""); + const tableNumber = tableRaw.trim() === "" ? null : Number(tableRaw); + return [ + { + externalId: (idAt === undefined ? "" : (row[idAt] ?? "")).trim() || name, + name, + teamName: teamAt === undefined ? null : row[teamAt]?.trim() || null, + tableNumber: tableNumber !== null && Number.isFinite(tableNumber) ? tableNumber : null, + }, + ]; + }); +} + +export type ExportedResult = { + project: string; + track: string; + placement: number | null; + score: number; +}; + +export function formatResultsCsv(rows: readonly ExportedResult[]): string { + const lines = ["project,track,placement,score"]; + for (const row of rows) { + lines.push( + [cell(row.project), cell(row.track), row.placement ?? "", row.score].join(","), + ); + } + return `${lines.join("\n")}\n`; +} + +export function formatResultsJson(rows: readonly ExportedResult[]): string { + return `${JSON.stringify(rows, null, 2)}\n`; +} + +export function parseColumnMap(text: string): Record { + const map: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const split = trimmed.indexOf(":"); + if (split === -1) continue; + map[trimmed.slice(0, split).trim()] = trimmed.slice(split + 1).trim(); + } + return map; +} + +function column( + index: Map, + mapped: string | undefined, + fallbacks: string[], +): number | undefined { + const names = mapped ? [mapped.toLowerCase(), ...fallbacks] : fallbacks; + for (const name of names) { + const at = index.get(name); + if (at !== undefined) return at; + } + return undefined; +} + +function cell(value: string): string { + if (/[",\n]/.test(value)) return `"${value.replaceAll('"', '""')}"`; + return value; +} + +function parseRows(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ""; + let quoted = false; + const source = text.replace(/^\uFEFF/, ""); + for (let i = 0; i < source.length; i += 1) { + const char = source[i]; + if (quoted) { + if (char === '"') { + if (source[i + 1] === '"') { + field += '"'; + i += 1; + } else quoted = false; + } else field += char; + continue; + } + if (char === '"') quoted = true; + else if (char === ",") { + row.push(field); + field = ""; + } else if (char === "\n") { + row.push(field); + rows.push(row); + row = []; + field = ""; + } else if (char !== "\r") field += char; + } + if (field.length > 0 || row.length > 0) { + row.push(field); + rows.push(row); + } + return rows.filter((item) => item.some((value) => value.trim() !== "")); +} diff --git a/judging/cli/src/main.ts b/judging/cli/src/main.ts new file mode 100644 index 00000000..5e9b0df8 --- /dev/null +++ b/judging/cli/src/main.ts @@ -0,0 +1,153 @@ +import { + appliedMigrations, + createDb, + migrate, + migrationFiles, + databaseUrl, + requireDatabaseUrl, + seedDemo, +} from "@panel/db"; +import { drainOutbox } from "@panel/server/outbox"; +import { exportResults, importProjects } from "./transfer"; + +const [command, arg] = process.argv.slice(2); + +async function main() { + if (command === "import" && arg === "projects") { + const positionals = positionalsAfter(4); + const file = positionals[0]; + const eventId = positionals[1]; + const mapFile = option("--map"); + if (!file || !eventId) { + console.log("usage: panel import projects [--map columns.yaml]"); + process.exit(1); + } + const count = await importProjects(file, eventId, mapFile); + console.log(count); + return; + } + + if (command === "export" && arg === "results") { + const format = option("--format") === "json" ? "json" : "csv"; + const positionals = positionalsAfter(4); + const eventId = positionals[0]; + const file = positionals[1] ?? (format === "json" ? "results.json" : "results.csv"); + if (!eventId) { + console.log("usage: panel export results [file] [--format csv|json]"); + process.exit(1); + } + const count = await exportResults(eventId, file, format); + console.log(count); + return; + } + + if (command === "migrate") { + const { pool } = createDb(requireDatabaseUrl()); + const fresh = await migrate(pool); + console.log(fresh.length > 0 ? fresh.join("\n") : "up to date"); + await pool.end(); + return; + } + + if (command === "seed" && arg === "demo") { + const { db, pool } = createDb(requireDatabaseUrl()); + await migrate(pool); + const seeded = await seedDemo(db); + console.log(seeded.eventId); + await pool.end(); + return; + } + + if (command === "outbox" && arg === "drain") { + const { db, pool } = createDb(requireDatabaseUrl()); + let total = 0; + try { + for (;;) { + const delivered = await drainOutbox(db); + total += delivered; + if (delivered === 0) break; + } + } finally { + await pool.end(); + } + console.log(total); + return; + } + + if (command === "doctor") { + const checks: { name: string; ok: boolean; detail: string }[] = []; + const url = databaseUrl(); + if (!url) { + checks.push({ + name: "postgres", + ok: false, + detail: "DATABASE_URL is not set", + }); + } else { + const { pool } = createDb(url); + try { + await pool.query("select 1"); + checks.push({ name: "postgres", ok: true, detail: "connected" }); + const files = await migrationFiles(); + const applied = await appliedMigrations(pool); + const pending = files.filter((file) => !applied.includes(file)); + checks.push({ + name: "migrations", + ok: pending.length === 0, + detail: pending.length > 0 ? `pending ${pending.join(", ")}` : "up to date", + }); + } catch (error) { + checks.push({ + name: "postgres", + ok: false, + detail: error instanceof Error ? error.message : "connection failed", + }); + } finally { + await pool.end(); + } + } + + const secret = process.env.PANEL_JWT_SECRET; + checks.push({ + name: "jwt", + ok: Boolean(secret), + detail: secret ? "PANEL_JWT_SECRET is set" : "PANEL_JWT_SECRET is not set", + }); + checks.push({ + name: "redis", + ok: true, + detail: process.env.REDIS_URL ? "REDIS_URL is set" : "not configured", + }); + + for (const check of checks) { + console.log(`${check.ok ? "ok" : "fail"} ${check.name} ${check.detail}`); + } + if (checks.some((check) => !check.ok)) process.exit(1); + return; + } + + console.log( + "usage: panel migrate | panel seed demo | panel doctor | panel outbox drain | panel import projects | panel export results [file]", + ); + process.exit(1); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); + +function option(name: string): string | undefined { + const index = process.argv.indexOf(name); + if (index === -1) return undefined; + return process.argv[index + 1]; +} + +function positionalsAfter(start: number): string[] { + const args = process.argv.slice(start); + return args.filter((value, index) => { + if (value.startsWith("--")) return false; + const previous = args[index - 1]; + return previous !== "--format" && previous !== "--map"; + }); +} diff --git a/judging/cli/src/transfer.ts b/judging/cli/src/transfer.ts new file mode 100644 index 00000000..8daa0c5c --- /dev/null +++ b/judging/cli/src/transfer.ts @@ -0,0 +1,67 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { and, eq } from "drizzle-orm"; +import { createDb, project, requireDatabaseUrl, result, resultRun, track } from "@panel/db"; +import { formatResultsCsv, formatResultsJson, parseColumnMap, parseProjectCsv } from "./csv"; + +export async function importProjects(file: string, eventId: string, mapFile?: string) { + const columnMap = mapFile ? parseColumnMap(await readFile(mapFile, "utf8")) : {}; + const rows = parseProjectCsv(await readFile(file, "utf8"), columnMap); + const { db, pool } = createDb(requireDatabaseUrl()); + try { + for (const row of rows) { + const [found] = await db + .select({ id: project.id }) + .from(project) + .where(and(eq(project.eventId, eventId), eq(project.externalId, row.externalId))); + if (found) { + await db + .update(project) + .set({ name: row.name, teamName: row.teamName, tableNumber: row.tableNumber }) + .where(eq(project.id, found.id)); + } else { + await db.insert(project).values({ + eventId, + externalId: row.externalId, + name: row.name, + teamName: row.teamName, + tableNumber: row.tableNumber, + }); + } + } + return rows.length; + } finally { + await pool.end(); + } +} + +export async function exportResults( + eventId: string, + file: string, + format: "csv" | "json" = "csv", +) { + const { db, pool } = createDb(requireDatabaseUrl()); + try { + const [run] = await db + .select({ id: resultRun.id }) + .from(resultRun) + .where(eq(resultRun.eventId, eventId)); + if (!run) throw new Error("No result run for that event"); + const rows = await db + .select({ + project: project.name, + track: track.slug, + placement: result.placement, + score: result.score, + }) + .from(result) + .innerJoin(project, eq(project.id, result.projectId)) + .innerJoin(track, eq(track.id, result.trackId)) + .where(eq(result.runId, run.id)); + const body = + format === "json" ? formatResultsJson(rows) : formatResultsCsv(rows); + await writeFile(file, body); + return rows.length; + } finally { + await pool.end(); + } +} diff --git a/judging/cli/tsconfig.json b/judging/cli/tsconfig.json new file mode 100644 index 00000000..f54bde2c --- /dev/null +++ b/judging/cli/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tooling/typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/judging/core/package.json b/judging/core/package.json index fd17a170..8f0f6bbf 100644 --- a/judging/core/package.json +++ b/judging/core/package.json @@ -7,8 +7,19 @@ "exports": { ".": "./src/index.ts" }, + "files": ["dist"], + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } + }, "scripts": { "lint": "eslint . --max-warnings 0", + "build": "tsc -p tsconfig.build.json && node scripts/emit-extensions.mjs", "typecheck": "tsc --noEmit", "test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts" }, diff --git a/judging/core/scripts/emit-extensions.mjs b/judging/core/scripts/emit-extensions.mjs new file mode 100644 index 00000000..4efdcb3b --- /dev/null +++ b/judging/core/scripts/emit-extensions.mjs @@ -0,0 +1,25 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const dist = join(dirname(fileURLToPath(import.meta.url)), "../dist"); + +await walk(dist); + +async function walk(dir) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(path); + continue; + } + if (!entry.name.endsWith(".js")) continue; + const source = await readFile(path, "utf8"); + const next = source.replaceAll( + /(from\s+["'])(\.\.?\/[^"']+)(["'])/g, + (match, open, spec, close) => + spec.endsWith(".js") ? match : `${open}${spec}.js${close}`, + ); + if (next !== source) await writeFile(path, next); + } +} diff --git a/judging/core/src/aggregate/rank.test.ts b/judging/core/src/aggregate/rank.test.ts index ce684acd..800c28d9 100644 --- a/judging/core/src/aggregate/rank.test.ts +++ b/judging/core/src/aggregate/rank.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { zNormalize } from "./zscore"; import { bayesianShrink, round2 } from "./bayes"; import { rank } from "./rank"; -import type { ProjectInput, RankConfig, VoteInput } from "./rank"; +import type { ProjectInput, RankComparison, RankConfig, VoteInput } from "./rank"; /** * The ranking the current event publishes: z-score per judge against the @@ -202,3 +202,43 @@ describe("rank", () => { expect(placed.map((row) => row.projectId)).toEqual(["A", "B", "C"]); }); }); + +describe("rank at pairwise weight 1", () => { + it("keeps the true order when every comparison agrees", () => { + const ids = ["a", "b", "c", "d", "e"]; + let seed = 11; + const random = () => { + seed = (seed * 1664525 + 1013904223) % 4294967296; + return seed / 4294967296; + }; + for (let trial = 0; trial < 20; trial += 1) { + const truth = [...ids].sort(() => random() - 0.5); + const better = new Map(truth.map((id, index) => [id, index])); + const comparisons: RankComparison[] = []; + for (let left = 0; left < ids.length; left += 1) { + for (let right = left + 1; right < ids.length; right += 1) { + const a = ids[left] ?? ""; + const b = ids[right] ?? ""; + const aWins = (better.get(a) ?? 0) < (better.get(b) ?? 0); + comparisons.push({ + judgeId: "j1", + judgeGroup: "main", + a, + b, + outcome: aWins ? ("a" as const) : ("b" as const), + }); + } + } + const rows = rank( + ids.map((id) => ({ id, trackIds: ["overall"] })), + [], + comparisons, + { ...config, pairwiseWeight: 1 }, + ); + const placed = rows + .filter((row) => row.placement !== null) + .sort((left, right) => (left.placement ?? 0) - (right.placement ?? 0)); + expect(placed.map((row) => row.projectId)).toEqual(truth); + } + }); +}); diff --git a/judging/core/src/aggregate/rank.ts b/judging/core/src/aggregate/rank.ts index 0b687d32..6f1fa9d8 100644 --- a/judging/core/src/aggregate/rank.ts +++ b/judging/core/src/aggregate/rank.ts @@ -249,6 +249,14 @@ function rankTrack( ); placeable.sort((a, b) => { if (a.score !== b.score) return b.score - a.score; + // Hundredths can tie two neighbours. The unrounded pairwise strength + // still has the order the comparisons produced. + if ( + config.pairwiseWeight > 0 && + a.pairwiseComponent !== b.pairwiseComponent + ) { + return b.pairwiseComponent - a.pairwiseComponent; + } return (order.get(a.projectId) ?? 0) - (order.get(b.projectId) ?? 0); }); placeable.forEach((row, index) => { diff --git a/judging/core/src/dispatch.sim.test.ts b/judging/core/src/dispatch.sim.test.ts new file mode 100644 index 00000000..fa2f6396 --- /dev/null +++ b/judging/core/src/dispatch.sim.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { pickNext } from "./dispatch"; +import type { Candidate, DispatchStrategy } from "./dispatch"; +import { DEFAULT_TIMERS } from "./timers"; +import { scoreUncertainty } from "./uncertainty"; + +/** + * Twenty judges, two hundred projects, three hours at the table. The same + * noise is available to both strategies; only the choice of who is seen + * again changes. The ten truly strongest projects should end up in an order + * closer to the truth when extra looks go to the unsettled ones. + */ +describe("uncertainty dispatch", () => { + it("ranks the true top 10 better than even coverage at the same number of looks", () => { + const projects = 200; + const judges = 20; + const hours = 3; + const budget = judges * Math.floor((hours * 3600) / DEFAULT_TIMERS.targetSeconds); + const truth = Array.from({ length: projects }, (_, index) => index); + const noise = truth.map((_, project) => + Array.from({ length: 40 }, (_, look) => + gaussian(mulberry32(project * 1000 + look + 1)) * noiseScale(project), + ), + ); + + const coverage = simulate("coverage", { projects, judges, budget, truth, noise }); + const uncertainty = simulate("uncertainty", { projects, judges, budget, truth, noise }); + const top = truth.map((_, index) => index).slice(-10); + + expect(kendall(top, uncertainty, truth)).toBeGreaterThan(kendall(top, coverage, truth)); + }); +}); + +function noiseScale(project: number): number { + return project >= 185 ? 18 : 3; +} + +function simulate( + strategy: DispatchStrategy, + input: { + projects: number; + judges: number; + budget: number; + truth: number[]; + noise: number[][]; + }, +): number[] { + const scores = Array.from({ length: input.projects }, () => [] as number[]); + const seen = Array.from({ length: input.judges }, () => new Set()); + const last: (number | null)[] = Array.from({ length: input.judges }, () => null); + let used = 0; + let guard = 0; + while (used < input.budget && guard < input.budget) { + for (let judge = 0; judge < input.judges && used < input.budget; judge += 1) { + const candidates: Candidate[] = []; + for (let project = 0; project < input.projects; project += 1) { + if (seen[judge]?.has(project)) continue; + candidates.push({ + id: String(project), + tableNumber: project, + coverage: scores[project]?.length ?? 0, + claimedAt: null, + uncertainty: scoreUncertainty(scores[project] ?? []), + }); + } + const pick = pickNext( + candidates, + { lastTable: last[judge] ?? null }, + { strategy, minLooksPerProject: 2 }, + () => 0, + ); + if (!pick) continue; + const project = Number(pick.id); + const look = scores[project]?.length ?? 0; + scores[project]?.push((input.truth[project] ?? 0) + (input.noise[project]?.[look] ?? 0)); + seen[judge]?.add(project); + last[judge] = project; + used += 1; + } + guard += 1; + } + return scores.map((row, index) => + row.length === 0 + ? (input.truth[index] ?? 0) + : row.reduce((sum, value) => sum + value, 0) / row.length, + ); +} + +function kendall(ids: number[], estimate: number[], truth: number[]): number { + let concordant = 0; + let discordant = 0; + for (let left = 0; left < ids.length; left += 1) { + for (let right = left + 1; right < ids.length; right += 1) { + const a = ids[left] ?? 0; + const b = ids[right] ?? 0; + const truthSign = Math.sign((truth[a] ?? 0) - (truth[b] ?? 0)); + const estimateSign = Math.sign((estimate[a] ?? 0) - (estimate[b] ?? 0)); + if (truthSign === 0 || estimateSign === 0) continue; + if (truthSign === estimateSign) concordant += 1; + else discordant += 1; + } + } + return (concordant - discordant) / (concordant + discordant); +} + +function gaussian(rng: () => number): number { + const u = Math.max(rng(), 1e-9); + const v = rng(); + return Math.sqrt(-2 * Math.log(u)) * Math.cos(Math.PI * 2 * v); +} + +function mulberry32(seed: number): () => number { + let state = seed; + return () => { + state = (state + 0x6d2b79f5) | 0; + let value = Math.imul(state ^ (state >>> 15), 1 | state); + value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value; + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +} diff --git a/judging/core/src/events.ts b/judging/core/src/events.ts index 6e9278ed..63c04d3e 100644 --- a/judging/core/src/events.ts +++ b/judging/core/src/events.ts @@ -8,12 +8,22 @@ export const LOG_KINDS = [ "project.upserted", "project.withdrawn", "project.table_assigned", + "project.qr_rotated", + "track.saved", + "prize.saved", + "zone.saved", + "rubric.saved", + "table.placed", + "webhook.saved", + "api_key.created", + "config.saved", "judge.upserted", "judge.approved", "judge.suspended", "visit.handed_out", "visit.arrived", "visit.voided", + "visit.skipped", "vote.cast", "comparison.cast", "results.computed", @@ -31,6 +41,7 @@ export const OUTBOX_TOPICS = [ "vote.cast", "phase.changed", "results.published", + "results.unpublished", "judge.recalled", ] as const; diff --git a/judging/core/src/index.ts b/judging/core/src/index.ts index 6acfb348..a65497e1 100644 --- a/judging/core/src/index.ts +++ b/judging/core/src/index.ts @@ -12,6 +12,8 @@ export type { } from "./aggregate/rank"; export type { Comparison } from "./comparison"; export { pickNext } from "./dispatch"; +export { assignTables } from "./tables"; +export type { Assignment, Seat, SeatedProject } from "./tables"; export type { Candidate, DispatchConfig, diff --git a/judging/core/src/standalone.test.ts b/judging/core/src/standalone.test.ts new file mode 100644 index 00000000..166726ab --- /dev/null +++ b/judging/core/src/standalone.test.ts @@ -0,0 +1,34 @@ +import { readdir, readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const root = join(dirname(fileURLToPath(import.meta.url)), "../.."); + +describe("standalone tree", () => { + it("does not mention the host club", async () => { + const hits: string[] = []; + await walk(root, hits); + expect(hits).toEqual([]); + }); +}); + +async function walk(dir: string, hits: string[]) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === "node_modules" || entry.name === ".next" || entry.name === "dist") { + continue; + } + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(path, hits); + continue; + } + if (!/\.(ts|tsx|md|sql|yml|json|tf)$/.test(entry.name)) continue; + const text = await readFile(path, "utf8"); + const needles = ["DS" + "GT", "Hack" + "lytics", "datascience" + "gt"]; + if (needles.some((needle) => text.toLowerCase().includes(needle.toLowerCase()))) { + hits.push(path); + } + } +} diff --git a/judging/core/src/tables.test.ts b/judging/core/src/tables.test.ts new file mode 100644 index 00000000..b02827c8 --- /dev/null +++ b/judging/core/src/tables.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { assignTables } from "./tables"; + +describe("assignTables", () => { + it("alternates tracks across table numbers", () => { + const seated = assignTables( + [ + { id: "a1", trackId: "sponsor" }, + { id: "a2", trackId: "sponsor" }, + { id: "b1", trackId: "general" }, + { id: "b2", trackId: "general" }, + ], + [ + { number: 1, zoneId: "hall" }, + { number: 2, zoneId: "hall" }, + { number: 3, zoneId: "hall" }, + { number: 4, zoneId: "hall" }, + ], + ); + expect(seated.map((row) => row.projectId)).toEqual(["a1", "b1", "a2", "b2"]); + expect(seated.map((row) => row.tableNumber)).toEqual([1, 2, 3, 4]); + }); + + it("leaves extra projects unseated when tables run out", () => { + const seated = assignTables( + [ + { id: "a", trackId: null }, + { id: "b", trackId: null }, + ], + [{ number: 9, zoneId: null }], + ); + expect(seated).toEqual([{ projectId: "a", tableNumber: 9, zoneId: null }]); + }); +}); diff --git a/judging/core/src/tables.ts b/judging/core/src/tables.ts new file mode 100644 index 00000000..af287f1d --- /dev/null +++ b/judging/core/src/tables.ts @@ -0,0 +1,52 @@ +export type Seat = { + number: number; + zoneId: string | null; +}; + +export type SeatedProject = { + id: string; + trackId: string | null; +}; + +export type Assignment = { + projectId: string; + tableNumber: number; + zoneId: string | null; +}; + +/** + * Seats projects so tracks take turns. Two sponsor projects do not land on + * neighbouring tables while another track is still waiting for a seat. + */ +export function assignTables( + projects: readonly SeatedProject[], + tables: readonly Seat[], +): Assignment[] { + const queues = new Map(); + for (const project of projects) { + const key = project.trackId ?? ""; + const queue = queues.get(key) ?? []; + queue.push(project); + queues.set(key, queue); + } + const lanes = [...queues.values()]; + const ordered: SeatedProject[] = []; + while (ordered.length < projects.length) { + for (const lane of lanes) { + const next = lane.shift(); + if (next) ordered.push(next); + } + } + const seats = [...tables].sort((a, b) => a.number - b.number); + return ordered.slice(0, seats.length).flatMap((project, index) => { + const seat = seats[index]; + if (!seat) return []; + return [ + { + projectId: project.id, + tableNumber: seat.number, + zoneId: seat.zoneId, + }, + ]; + }); +} diff --git a/judging/core/tsconfig.build.json b/judging/core/tsconfig.build.json new file mode 100644 index 00000000..2da00172 --- /dev/null +++ b/judging/core/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "node_modules", "dist"] +} diff --git a/judging/db/eslint.config.mjs b/judging/db/eslint.config.mjs new file mode 100644 index 00000000..827bfcb7 --- /dev/null +++ b/judging/db/eslint.config.mjs @@ -0,0 +1,24 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...config, + { + files: ["src/**/*.ts"], + ignores: ["src/env.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["@query", "@query/*"], + message: + "judging/ is a standalone product. Import from @panel/* only.", + }, + ], + }, + ], + }, + }, +]; diff --git a/judging/db/package.json b/judging/db/package.json new file mode 100644 index 00000000..e6e07b1b --- /dev/null +++ b/judging/db/package.json @@ -0,0 +1,25 @@ +{ + "name": "@panel/db", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@panel/core": "workspace:*", + "drizzle-orm": "0.45.3", + "pg": "8.23.0" + }, + "devDependencies": { + "@query/eslint-config": "workspace:*", + "@query/tsconfig": "workspace:*", + "@types/node": "22.20.4", + "@types/pg": "^8.23.1", + "typescript": "7.0.2" + } +} diff --git a/judging/db/src/client.ts b/judging/db/src/client.ts new file mode 100644 index 00000000..6824b41c --- /dev/null +++ b/judging/db/src/client.ts @@ -0,0 +1,16 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import * as schema from "./schema"; + +export function createDb(url: string) { + const pool = new Pool({ connectionString: url, max: 10 }); + pool.on("error", () => { + // The pool drops a dead client on its own. An unhandled "error" event + // would take the process down. + }); + const db = drizzle(pool, { schema }); + return { db, pool }; +} + +export type PanelDb = ReturnType["db"]; +export type PanelTx = Parameters[0]>[0]; diff --git a/judging/db/src/env.ts b/judging/db/src/env.ts new file mode 100644 index 00000000..8966aa59 --- /dev/null +++ b/judging/db/src/env.ts @@ -0,0 +1,11 @@ +export function databaseUrl(): string | null { + return process.env.DATABASE_URL ?? process.env.PANEL_DATABASE_URL ?? null; +} + +export function requireDatabaseUrl(): string { + const url = databaseUrl(); + if (!url) { + throw new Error("Set DATABASE_URL to the Postgres database"); + } + return url; +} diff --git a/judging/db/src/index.ts b/judging/db/src/index.ts new file mode 100644 index 00000000..4514dd03 --- /dev/null +++ b/judging/db/src/index.ts @@ -0,0 +1,6 @@ +export { createDb } from "./client"; +export type { PanelDb, PanelTx } from "./client"; +export { databaseUrl, requireDatabaseUrl } from "./env"; +export { appliedMigrations, migrate, migrationFiles } from "./migrate"; +export * from "./schema"; +export { seedDemo } from "./seed"; diff --git a/judging/db/src/migrate.ts b/judging/db/src/migrate.ts new file mode 100644 index 00000000..9a5e8c76 --- /dev/null +++ b/judging/db/src/migrate.ts @@ -0,0 +1,69 @@ +import { readdir, readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Pool } from "pg"; + +const migrationsDir = join(dirname(fileURLToPath(import.meta.url)), "../migrations"); + +const MIGRATION_TABLE = ` + create table if not exists panel_migration ( + id text primary key, + applied_at timestamptz not null default now() + ) +`; + +/** Applies committed SQL files in order. Each file runs in one transaction. */ +export async function migrate(pool: Pool): Promise { + const client = await pool.connect(); + try { + await client.query(MIGRATION_TABLE); + const applied = await client.query<{ id: string }>( + "select id from panel_migration", + ); + const done = new Set(applied.rows.map((row) => row.id)); + const files = await migrationFiles(); + const fresh: string[] = []; + + for (const file of files) { + if (done.has(file)) continue; + const body = await readFile(join(migrationsDir, file), "utf8"); + try { + await client.query("begin"); + await client.query(body); + await client.query("insert into panel_migration (id) values ($1)", [ + file, + ]); + await client.query("commit"); + } catch (error) { + await client.query("rollback"); + throw error; + } + fresh.push(file); + } + return fresh; + } finally { + client.release(); + } +} + +export async function appliedMigrations(pool: Pool): Promise { + const client = await pool.connect(); + try { + const exists = await client.query<{ present: boolean }>( + `select to_regclass('panel_migration') is not null as present`, + ); + const row = exists.rows[0]; + if (!row?.present) return []; + const applied = await client.query<{ id: string }>( + "select id from panel_migration order by id", + ); + return applied.rows.map((item) => item.id); + } finally { + client.release(); + } +} + +export async function migrationFiles(): Promise { + const files = await readdir(migrationsDir); + return files.filter((name) => name.endsWith(".sql")).sort(); +} diff --git a/judging/db/src/schema.ts b/judging/db/src/schema.ts new file mode 100644 index 00000000..e206ba77 --- /dev/null +++ b/judging/db/src/schema.ts @@ -0,0 +1,433 @@ +import { + boolean, + integer, + jsonb, + numeric, + pgEnum, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; + +export const eventPhase = pgEnum("event_phase", [ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", +]); + +export const trackKind = pgEnum("track_kind", ["main", "sponsor", "special"]); + +export const dispatchStrategy = pgEnum("dispatch_strategy", [ + "coverage", + "uncertainty", +]); + +export const judgeStatus = pgEnum("judge_status", [ + "invited", + "applied", + "approved", + "suspended", +]); + +export const comparisonOutcome = pgEnum("comparison_outcome", ["a", "b", "tie"]); + +export const membershipRole = pgEnum("membership_role", [ + "owner", + "admin", + "organizer", + "volunteer", +]); + +export const organization = pgTable("organization", { + id: uuid("id").defaultRandom().primaryKey(), + slug: text("slug").notNull().unique(), + name: text("name").notNull(), + branding: jsonb("branding").notNull().default({}), +}); + +export const user = pgTable("panel_user", { + id: uuid("id").defaultRandom().primaryKey(), + email: text("email").notNull().unique(), + name: text("name").notNull(), +}); + +export const event = pgTable( + "panel_event", + { + id: uuid("id").defaultRandom().primaryKey(), + orgId: uuid("org_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + slug: text("slug").notNull(), + name: text("name").notNull(), + startsAt: timestamp("starts_at", { withTimezone: true }), + endsAt: timestamp("ends_at", { withTimezone: true }), + phase: eventPhase("phase").notNull().default("setup"), + }, + (table) => [uniqueIndex("event_org_slug_idx").on(table.orgId, table.slug)], +); + +export const eventConfig = pgTable("event_config", { + eventId: uuid("event_id") + .primaryKey() + .references(() => event.id, { onDelete: "cascade" }), + targetSeconds: integer("target_seconds").notNull(), + hardLimitSeconds: integer("hard_limit_seconds").notNull(), + walkLimitSeconds: integer("walk_limit_seconds").notNull(), + submitGraceSeconds: integer("submit_grace_seconds").notNull(), + minLooksPerProject: integer("min_looks_per_project").notNull(), + dispatchStrategy: dispatchStrategy("dispatch_strategy").notNull(), + pairwiseEnabled: boolean("pairwise_enabled").notNull().default(false), + pairwiseWeight: numeric("pairwise_weight", { mode: "number" }).notNull().default(0), + bayesianC: numeric("bayesian_c", { mode: "number" }).notNull().default(2), + calibrationLooks: integer("calibration_looks").notNull().default(0), + calibrationWeight: numeric("calibration_weight", { mode: "number" }) + .notNull() + .default(1), + feedbackCardsEnabled: boolean("feedback_cards_enabled").notNull().default(false), + leaderboardPublic: boolean("leaderboard_public").notNull().default(false), + boardPollSeconds: integer("board_poll_seconds").notNull().default(5), +}); + +export const rubric = pgTable("rubric", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + name: text("name").notNull(), + isDefault: boolean("is_default").notNull().default(false), +}); + +export const criterion = pgTable( + "criterion", + { + id: uuid("id").defaultRandom().primaryKey(), + rubricId: uuid("rubric_id") + .notNull() + .references(() => rubric.id, { onDelete: "cascade" }), + position: integer("position").notNull(), + key: text("key").notNull(), + label: text("label").notNull(), + description: text("description"), + min: numeric("min", { mode: "number" }).notNull(), + max: numeric("max", { mode: "number" }).notNull(), + weight: numeric("weight", { mode: "number" }).notNull(), + anchors: jsonb("anchors").notNull().default([]), + }, + (table) => [uniqueIndex("criterion_rubric_key_idx").on(table.rubricId, table.key)], +); + +export const track = pgTable( + "track", + { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + slug: text("slug").notNull(), + name: text("name").notNull(), + kind: trackKind("kind").notNull(), + judgeGroup: text("judge_group").notNull(), + rubricId: uuid("rubric_id").references(() => rubric.id), + position: integer("position").notNull().default(0), + isActive: boolean("is_active").notNull().default(true), + }, + (table) => [uniqueIndex("track_event_slug_idx").on(table.eventId, table.slug)], +); + +export const prize = pgTable( + "prize", + { + id: uuid("id").defaultRandom().primaryKey(), + trackId: uuid("track_id") + .notNull() + .references(() => track.id, { onDelete: "cascade" }), + place: integer("place").notNull(), + title: text("title").notNull(), + amount: numeric("amount", { mode: "number" }), + description: text("description"), + }, + (table) => [uniqueIndex("prize_track_place_idx").on(table.trackId, table.place)], +); + +export const zone = pgTable("zone", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + name: text("name").notNull(), + position: integer("position").notNull().default(0), +}); + +/** Floor plan. The SQL name is floor_table because `table` is reserved. */ +export const floorTable = pgTable( + "floor_table", + { + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + number: integer("number").notNull(), + zoneId: uuid("zone_id").references(() => zone.id), + x: numeric("x", { mode: "number" }), + y: numeric("y", { mode: "number" }), + }, + (table) => [primaryKey({ columns: [table.eventId, table.number] })], +); + +export const project = pgTable("project", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + externalId: text("external_id"), + name: text("name").notNull(), + description: text("description"), + teamName: text("team_name"), + members: jsonb("members").notNull().default([]), + links: jsonb("links").notNull().default({}), + tableNumber: integer("table_number"), + zoneId: uuid("zone_id").references(() => zone.id), + qrToken: uuid("qr_token").notNull().unique().defaultRandom(), + feedbackToken: uuid("feedback_token").unique(), + arrivedFirstAt: timestamp("arrived_first_at", { withTimezone: true }), + withdrawnAt: timestamp("withdrawn_at", { withTimezone: true }), +}); + +export const projectTrack = pgTable( + "project_track", + { + projectId: uuid("project_id") + .notNull() + .references(() => project.id, { onDelete: "cascade" }), + trackId: uuid("track_id") + .notNull() + .references(() => track.id, { onDelete: "cascade" }), + }, + (table) => [primaryKey({ columns: [table.projectId, table.trackId] })], +); + +export const judge = pgTable( + "panel_judge", + { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + externalId: text("external_id"), + name: text("name").notNull(), + email: text("email").notNull(), + org: text("org"), + title: text("title"), + status: judgeStatus("status").notNull().default("invited"), + trackId: uuid("track_id").references(() => track.id), + zoneId: uuid("zone_id").references(() => zone.id), + isLead: boolean("is_lead").notNull().default(false), + }, + (table) => [uniqueIndex("judge_event_email_idx").on(table.eventId, table.email)], +); + +export const visit = pgTable( + "visit", + { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + judgeId: uuid("judge_id") + .notNull() + .references(() => judge.id, { onDelete: "cascade" }), + projectId: uuid("project_id") + .notNull() + .references(() => project.id, { onDelete: "cascade" }), + judgeGroup: text("judge_group").notNull(), + handedOutAt: timestamp("handed_out_at", { withTimezone: true }).notNull(), + arrivedAt: timestamp("arrived_at", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + voidedAt: timestamp("voided_at", { withTimezone: true }), + voidReason: text("void_reason"), + }, + (table) => [ + uniqueIndex("visit_judge_project_open_idx") + .on(table.judgeId, table.projectId) + .where(sql`voided_at is null`), + uniqueIndex("visit_one_open_idx") + .on(table.judgeId) + .where(sql`voided_at is null and completed_at is null`), + ], +); + +export const vote = pgTable("vote", { + id: uuid("id").defaultRandom().primaryKey(), + visitId: uuid("visit_id") + .notNull() + .unique() + .references(() => visit.id, { onDelete: "cascade" }), + judgeId: uuid("judge_id") + .notNull() + .references(() => judge.id, { onDelete: "cascade" }), + projectId: uuid("project_id") + .notNull() + .references(() => project.id, { onDelete: "cascade" }), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + total: numeric("total", { mode: "number" }).notNull(), + comment: text("comment"), + durationSeconds: integer("duration_seconds"), + isCalibration: boolean("is_calibration").notNull().default(false), +}); + +export const voteScore = pgTable( + "vote_score", + { + voteId: uuid("vote_id") + .notNull() + .references(() => vote.id, { onDelete: "cascade" }), + criterionId: uuid("criterion_id") + .notNull() + .references(() => criterion.id), + value: numeric("value", { mode: "number" }).notNull(), + }, + (table) => [primaryKey({ columns: [table.voteId, table.criterionId] })], +); + +export const comparison = pgTable("comparison", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + judgeId: uuid("judge_id") + .notNull() + .references(() => judge.id, { onDelete: "cascade" }), + judgeGroup: text("judge_group").notNull(), + aProjectId: uuid("a_project_id") + .notNull() + .references(() => project.id), + bProjectId: uuid("b_project_id") + .notNull() + .references(() => project.id), + outcome: comparisonOutcome("outcome").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const resultRun = pgTable("result_run", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + computedAt: timestamp("computed_at", { withTimezone: true }).notNull().defaultNow(), + computedBy: uuid("computed_by").references(() => user.id), + configSnapshot: jsonb("config_snapshot").notNull(), + publishedAt: timestamp("published_at", { withTimezone: true }), + notes: text("notes"), +}); + +export const result = pgTable( + "result", + { + runId: uuid("run_id") + .notNull() + .references(() => resultRun.id, { onDelete: "cascade" }), + projectId: uuid("project_id") + .notNull() + .references(() => project.id, { onDelete: "cascade" }), + trackId: uuid("track_id") + .notNull() + .references(() => track.id, { onDelete: "cascade" }), + placement: integer("placement"), + score: numeric("score", { mode: "number" }).notNull(), + rubricComponent: numeric("rubric_component", { mode: "number" }).notNull(), + pairwiseComponent: numeric("pairwise_component", { mode: "number" }).notNull(), + voteCount: integer("vote_count").notNull(), + comparisonCount: integer("comparison_count").notNull(), + flags: text("flags").array().notNull().default([]), + }, + (table) => [ + primaryKey({ columns: [table.runId, table.projectId, table.trackId] }), + ], +); + +export const eventLog = pgTable("event_log", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + kind: text("kind").notNull(), + actor: jsonb("actor").notNull().default({}), + subject: jsonb("subject").notNull().default({}), + payload: jsonb("payload").notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const outbox = pgTable("outbox", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + topic: text("topic").notNull(), + payload: jsonb("payload").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + deliveredAt: timestamp("delivered_at", { withTimezone: true }), + attempts: integer("attempts").notNull().default(0), +}); + +export const webhook = pgTable("webhook", { + id: uuid("id").defaultRandom().primaryKey(), + orgId: uuid("org_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + url: text("url").notNull(), + secret: text("secret").notNull(), + topics: text("topics").array().notNull(), + isActive: boolean("is_active").notNull().default(true), +}); + +export const apiKey = pgTable("api_key", { + id: uuid("id").defaultRandom().primaryKey(), + orgId: uuid("org_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + hashedKey: text("hashed_key").notNull(), + scopes: text("scopes").array().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), +}); + +export const membership = pgTable( + "membership", + { + userId: uuid("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + orgId: uuid("org_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + role: membershipRole("role").notNull(), + }, + (table) => [primaryKey({ columns: [table.userId, table.orgId] })], +); + +export const judgeIdentity = pgTable("judge_identity", { + judgeId: uuid("judge_id") + .primaryKey() + .references(() => judge.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), +}); + +export const loginCode = pgTable("login_code", { + id: uuid("id").defaultRandom().primaryKey(), + email: text("email").notNull(), + codeHash: text("code_hash").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + consumedAt: timestamp("consumed_at", { withTimezone: true }), +}); diff --git a/judging/db/src/seed.ts b/judging/db/src/seed.ts new file mode 100644 index 00000000..a313315f --- /dev/null +++ b/judging/db/src/seed.ts @@ -0,0 +1,238 @@ +import { eq, sql } from "drizzle-orm"; +import { DEFAULT_TIMERS } from "@panel/core"; +import type { PanelDb, PanelTx } from "./client"; +import { + criterion, + event, + eventConfig, + floorTable, + judge, + membership, + organization, + prize, + project, + projectTrack, + rubric, + track, + user, + zone, +} from "./schema"; + +const CRITERIA = [ + ["creativity", "Creativity"], + ["impact", "Impact"], + ["scope", "Scope"], + ["clarity", "Clarity"], + ["soundness", "Soundness"], +] as const; + +/** + * One event a self-hoster can click through: 40 projects, 6 judges, three + * tracks, and a second rubric on the sponsor track. Re-running replaces the + * demo organization. + */ +export async function seedDemo(db: PanelDb): Promise<{ eventId: string }> { + return db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext('panel:seed:demo'))`); + return insertDemo(tx); + }); +} + +async function insertDemo(db: PanelTx): Promise<{ eventId: string }> { + const existing = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.slug, "demo")); + const previous = existing[0]; + if (previous) { + await db.delete(organization).where(eq(organization.id, previous.id)); + } + + const [org] = await db + .insert(organization) + .values({ + slug: "demo", + name: "Demo", + branding: { tagline: "A practice floor" }, + }) + .returning({ id: organization.id }); + if (!org) throw new Error("demo organization was not created"); + + await db.delete(user).where(eq(user.email, "organizer@demo.panel")); + const [organizer] = await db + .insert(user) + .values({ email: "organizer@demo.panel", name: "Demo organizer" }) + .returning({ id: user.id }); + if (!organizer) throw new Error("demo organizer was not created"); + + await db.insert(membership).values({ + userId: organizer.id, + orgId: org.id, + role: "owner", + }); + + const [demoEvent] = await db + .insert(event) + .values({ + orgId: org.id, + slug: "demo", + name: "Demo", + phase: "judging_live", + }) + .returning({ id: event.id }); + if (!demoEvent) throw new Error("demo event was not created"); + + await db.insert(eventConfig).values({ + eventId: demoEvent.id, + targetSeconds: DEFAULT_TIMERS.targetSeconds, + hardLimitSeconds: DEFAULT_TIMERS.hardLimitSeconds, + walkLimitSeconds: DEFAULT_TIMERS.walkLimitSeconds, + submitGraceSeconds: DEFAULT_TIMERS.submitGraceSeconds, + minLooksPerProject: 1, + dispatchStrategy: "coverage", + pairwiseEnabled: false, + pairwiseWeight: 0, + bayesianC: 2, + calibrationLooks: 0, + calibrationWeight: 1, + feedbackCardsEnabled: false, + leaderboardPublic: false, + boardPollSeconds: 5, + }); + + const [mainRubric] = await db + .insert(rubric) + .values({ eventId: demoEvent.id, name: "Main", isDefault: true }) + .returning({ id: rubric.id }); + const [sponsorRubric] = await db + .insert(rubric) + .values({ eventId: demoEvent.id, name: "Sponsor", isDefault: false }) + .returning({ id: rubric.id }); + if (!mainRubric || !sponsorRubric) throw new Error("demo rubrics were not created"); + + await db.insert(criterion).values( + CRITERIA.map(([key, label], position) => ({ + rubricId: mainRubric.id, + position, + key, + label, + min: 0, + max: 10, + weight: 1, + })), + ); + await db.insert(criterion).values([ + { + rubricId: sponsorRubric.id, + position: 0, + key: "fit", + label: "Fit", + min: 0, + max: 10, + weight: 1, + }, + { + rubricId: sponsorRubric.id, + position: 1, + key: "execution", + label: "Execution", + min: 0, + max: 10, + weight: 1, + }, + ]); + + const tracks = await db + .insert(track) + .values([ + { + eventId: demoEvent.id, + slug: "general", + name: "General", + kind: "main" as const, + judgeGroup: "main", + rubricId: mainRubric.id, + position: 0, + }, + { + eventId: demoEvent.id, + slug: "sponsor", + name: "Sponsor", + kind: "sponsor" as const, + judgeGroup: "sponsor", + rubricId: sponsorRubric.id, + position: 1, + }, + { + eventId: demoEvent.id, + slug: "beginner", + name: "Beginner", + kind: "special" as const, + judgeGroup: "beginner", + rubricId: mainRubric.id, + position: 2, + }, + ]) + .returning({ id: track.id, slug: track.slug }); + + const general = tracks.find((item) => item.slug === "general"); + const sponsor = tracks.find((item) => item.slug === "sponsor"); + const beginner = tracks.find((item) => item.slug === "beginner"); + if (!general || !sponsor || !beginner) throw new Error("demo tracks were not created"); + + await db.insert(prize).values([ + { trackId: general.id, place: 1, title: "First", amount: 1000 }, + { trackId: sponsor.id, place: 1, title: "Sponsor prize", amount: 500 }, + ]); + + const [hall] = await db + .insert(zone) + .values({ eventId: demoEvent.id, name: "Hall", position: 0 }) + .returning({ id: zone.id }); + if (!hall) throw new Error("demo zone was not created"); + + await db.insert(floorTable).values( + Array.from({ length: 40 }, (_, index) => ({ + eventId: demoEvent.id, + number: index + 1, + zoneId: hall.id, + })), + ); + + const projects = await db + .insert(project) + .values( + Array.from({ length: 40 }, (_, index) => ({ + eventId: demoEvent.id, + name: `Project ${index + 1}`, + teamName: `Team ${index + 1}`, + tableNumber: index + 1, + zoneId: hall.id, + })), + ) + .returning({ id: project.id, tableNumber: project.tableNumber }); + + await db.insert(projectTrack).values( + projects.flatMap((item) => { + const links = [{ projectId: item.id, trackId: general.id }]; + const number = item.tableNumber ?? 0; + if (number % 5 === 0) links.push({ projectId: item.id, trackId: sponsor.id }); + if (number % 4 === 0) links.push({ projectId: item.id, trackId: beginner.id }); + return links; + }), + ); + + const sponsorTrack = tracks.find((item) => item.slug === "sponsor"); + await db.insert(judge).values( + Array.from({ length: 6 }, (_, index) => ({ + eventId: demoEvent.id, + name: `Judge ${index + 1}`, + email: `judge${index + 1}@demo.panel`, + status: "approved" as const, + trackId: index === 5 ? sponsorTrack?.id : null, + zoneId: hall.id, + })), + ); + + return { eventId: demoEvent.id }; +} diff --git a/judging/db/tsconfig.json b/judging/db/tsconfig.json new file mode 100644 index 00000000..f54bde2c --- /dev/null +++ b/judging/db/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tooling/typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/judging/docs/adr/001-standalone.md b/judging/docs/adr/001-standalone.md index 6c417fb3..dd621080 100644 --- a/judging/docs/adr/001-standalone.md +++ b/judging/docs/adr/001-standalone.md @@ -6,9 +6,10 @@ Accepted ## Decision -Panel lives under `judging/` with its own packages, its own Postgres database, -and its own deploy. It does not share a schema or a module graph with the -application that hosts the first event. +Panel lives under `judging/` with its own packages and its own deploy. It does +not share a module graph with the application that hosts the first event. +ADR-003 puts the tables in that application's Postgres instead of a second +database. Internal imports are `@panel/*` only. A lint rule rejects imports from the host. The host is allowed to import `@panel/*`. That one-way edge is what @@ -19,4 +20,5 @@ rewrite. Judging welded into one event's tables cannot be given to the next event, or to anyone else, without carrying the rest of that application with it. -A separate database means the extraction has no data to untangle. +The panel tables are prefixed so they can share that database until an +extraction. diff --git a/judging/docs/adr/003-one-database.md b/judging/docs/adr/003-one-database.md new file mode 100644 index 00000000..90a8dc72 --- /dev/null +++ b/judging/docs/adr/003-one-database.md @@ -0,0 +1,26 @@ +# ADR-003: One database, one server + +## Status + +Accepted. Supersedes the separate-database sentence in ADR-001. + +## Decision + +Panel tables live in the same Postgres as the club app. The connection is +`DATABASE_URL`. `PANEL_DATABASE_URL` is read only when that is unset. The +tables that would collide with the club schema are `panel_user`, +`panel_event`, and `panel_judge`. + +There is one API process. Redis is optional. When `REDIS_URL` is unset or +Redis fails, that process keeps delivering live updates in memory. + +`judging/` stays in this monorepo. Splitting it out, and deleting the legacy +judge UI, wait until an event has run on panel. Sign-in mail waits on +`PANEL_SMTP_URL`. Neither connection is made yet. + +## Why + +The host is the existing app. A second database and a second API fleet were +the extraction plan, and they are not how this checkout runs. The replay of +the published score fixture still matches `@panel/core` `rank()` at pairwise +weight 0, so the scoring check does not need that event. diff --git a/judging/docs/api.md b/judging/docs/api.md new file mode 100644 index 00000000..dd35ed3e --- /dev/null +++ b/judging/docs/api.md @@ -0,0 +1,17 @@ +# API + +`GET /openapi.json` is the route list (OpenAPI 3.0.3). The same procedures are also mounted at `/trpc`. + +Send `Authorization: Bearer` with a JWT from `POST /v1/auth/verify`, or with an API key from `catalog.issueApiKey`. A key whose scopes include `import`, `export`, or `webhooks` acts as an organizer. A revoked key is rejected. + +These routes do not require a token: + +- `GET /healthz`, `GET /readyz`, `GET /metrics`, `GET /openapi.json` +- `POST /v1/auth/magic-link` and `POST /v1/auth/verify` +- `GET /v1/public/{orgSlug}/{eventSlug}` +- `GET /v1/live/{orgSlug}/{eventSlug}` +- `GET /v1/feedback/{token}` + +`GET /v1/results/{eventId}` requires a bearer token and returns 403 until results are published. Feedback returns 403 for an unknown token and before publish. + +Live clients connect to `/ws?channel=event:{eventId}:board`. The channels are `event:{id}:board`, `event:{id}:leaderboard`, and `judge:{id}`. diff --git a/judging/docs/deploy.md b/judging/docs/deploy.md new file mode 100644 index 00000000..5b28feed --- /dev/null +++ b/judging/docs/deploy.md @@ -0,0 +1,25 @@ +# Deploy + +Docker Compose is the deploy to start from. From the repository root: + +``` +docker compose -f judging/infra/docker/docker-compose.yml up --build +``` + +Then, against the published Postgres port: + +``` +DATABASE_URL=postgres://panel:panel@localhost:54329/panel +PANEL_JWT_SECRET=dev-only-change-me +pnpm --filter @panel/cli panel migrate +pnpm --filter @panel/cli panel seed demo +pnpm --filter @panel/cli panel doctor +``` + +`panel doctor` must report postgres, migrations, and jwt as ok. Set `DATABASE_URL` to the same Postgres the club app uses. `PANEL_DATABASE_URL` is only read when `DATABASE_URL` is unset. The stack is one API server. Leave `REDIS_URL` unset so live updates stay in that process. Clients that miss a socket message recover from `GET /v1/live`. + +The optional GCP module is `judging/infra/gcp`. It is not required to run the stack. The web image is `judging/infra/docker/Dockerfile.web`. Pass `NEXT_PUBLIC_PANEL_URL` at build time so the desk calls the API. + +Set `PANEL_SMTP_URL` to an SMTP URL when sign-in codes should be emailed. +Without it, `PANEL_DEV_AUTH=1` returns the code in the response. OIDC waits +on a provider; trusted JWT from the club site is the handoff that works now. diff --git a/judging/docs/rubric.md b/judging/docs/rubric.md new file mode 100644 index 00000000..70b42cc3 --- /dev/null +++ b/judging/docs/rubric.md @@ -0,0 +1,7 @@ +# Rubric + +A rubric is a named list of criteria. Each criterion has a key, a label, a minimum, a maximum, and a weight. The default rubric is what judges see. A track can point at a different rubric, which is how a sponsor prize asks different questions. + +Scores outside a criterion's range are rejected. The visit total is the weighted mean of the criteria. Comments are stored with the vote and shown on the team card only after results are published. + +Create one from the organizer console with the track form for a new prize group, or through `catalog.saveRubric` with the criteria in order. Anchors are the numbers a judge can tap; they are the integers from min to max. diff --git a/judging/docs/scoring.md b/judging/docs/scoring.md new file mode 100644 index 00000000..65d46488 --- /dev/null +++ b/judging/docs/scoring.md @@ -0,0 +1,9 @@ +# Scoring + +A project's rubric score is the weighted mean of its criterion values. Each judge's scores are then z-scored against that judge's own mean and spread and placed on the field's mean and spread, so a harsh judge and a lenient judge who ranked the same projects agree. + +Projects with few votes are shrunk toward the field average. The config value `bayesian_c` is how many average votes that prior is worth. At 2, two real votes and the prior weigh the same. + +Pairwise comparisons are a separate component. `pairwise_weight` is the share of the final score that comes from them. At 0 the ranking is the rubric score only, which matches a results run that never asked judges to compare tables. + +Publishing freezes one result run. A later run can be diffed against it before it is published. diff --git a/judging/infra/docker/Dockerfile b/judging/infra/docker/Dockerfile new file mode 100644 index 00000000..e40b129d --- /dev/null +++ b/judging/infra/docker/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-bookworm-slim + +WORKDIR /app +RUN corepack enable + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY tooling ./tooling +COPY judging ./judging +COPY packages ./packages +COPY sites ./sites + +RUN pnpm install --frozen-lockfile --filter @panel/server... + +EXPOSE 8787 +CMD ["pnpm", "--filter", "@panel/server", "start"] diff --git a/judging/infra/docker/Dockerfile.web b/judging/infra/docker/Dockerfile.web new file mode 100644 index 00000000..4dee1b6f --- /dev/null +++ b/judging/infra/docker/Dockerfile.web @@ -0,0 +1,30 @@ +FROM node:22-bookworm-slim AS build + +WORKDIR /app +RUN corepack enable + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY tooling ./tooling +COPY judging ./judging +COPY packages ./packages +COPY sites ./sites + +ARG NEXT_PUBLIC_PANEL_URL=http://localhost:8787 +ENV NEXT_PUBLIC_PANEL_URL=$NEXT_PUBLIC_PANEL_URL + +RUN pnpm install --frozen-lockfile --filter @panel/web... +RUN pnpm --filter @panel/web build + +FROM node:22-bookworm-slim + +WORKDIR /app +ENV NODE_ENV=production +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 + +COPY --from=build /app/judging/web/.next/standalone ./ +COPY --from=build /app/judging/web/.next/static ./judging/web/.next/static +COPY --from=build /app/judging/web/public ./judging/web/public + +EXPOSE 3000 +CMD ["node", "judging/web/server.js"] diff --git a/judging/infra/docker/docker-compose.yml b/judging/infra/docker/docker-compose.yml new file mode 100644 index 00000000..4da40215 --- /dev/null +++ b/judging/infra/docker/docker-compose.yml @@ -0,0 +1,30 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: panel + POSTGRES_PASSWORD: panel + POSTGRES_DB: panel + ports: + - "54329:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U panel"] + interval: 2s + timeout: 2s + retries: 20 + + server: + build: + context: ../../.. + dockerfile: judging/infra/docker/Dockerfile + environment: + PANEL_DATABASE_URL: postgres://panel:panel@postgres:5432/panel + DATABASE_URL: postgres://panel:panel@postgres:5432/panel + PANEL_JWT_SECRET: dev-only-change-me + PANEL_DEV_AUTH: "1" + PORT: "8787" + ports: + - "8787:8787" + depends_on: + postgres: + condition: service_healthy diff --git a/judging/infra/gcp/main.tf b/judging/infra/gcp/main.tf new file mode 100644 index 00000000..ad1f1989 --- /dev/null +++ b/judging/infra/gcp/main.tf @@ -0,0 +1,129 @@ +variable "project" { + type = string +} + +variable "region" { + type = string + default = "us-east1" +} + +variable "server_image" { + type = string +} + +variable "web_image" { + type = string +} + +variable "database_url_secret" { + type = string + description = "Secret Manager secret id holding DATABASE_URL, the shared Postgres database." +} + +resource "google_cloud_run_v2_service" "server" { + name = "panel-server" + location = var.region + project = var.project + + template { + containers { + image = var.server_image + ports { + container_port = 8787 + } + env { + name = "DATABASE_URL" + value_source { + secret_key_ref { + secret = var.database_url_secret + version = "latest" + } + } + } + } + } +} + +resource "google_cloud_run_v2_service" "web" { + name = "panel-web" + location = var.region + project = var.project + + template { + containers { + image = var.web_image + env { + name = "NEXT_PUBLIC_PANEL_URL" + value = google_cloud_run_v2_service.server.uri + } + } + } +} + +variable "alert_email" { + type = string + default = "" +} + +locals { + server_host = trimprefix(google_cloud_run_v2_service.server.uri, "https://") +} + +resource "google_monitoring_uptime_check_config" "readyz" { + display_name = "panel readyz" + timeout = "10s" + period = "60s" + project = var.project + + http_check { + path = "/readyz" + port = "443" + use_ssl = true + validate_ssl = true + } + + monitored_resource { + type = "uptime_url" + labels = { + project_id = var.project + host = local.server_host + } + } +} + +resource "google_monitoring_notification_channel" "email" { + count = var.alert_email == "" ? 0 : 1 + display_name = "panel alerts" + type = "email" + project = var.project + labels = { + email_address = var.alert_email + } +} + +resource "google_monitoring_alert_policy" "readyz" { + count = var.alert_email == "" ? 0 : 1 + display_name = "panel /readyz" + combiner = "OR" + project = var.project + + conditions { + display_name = "readyz check failed" + condition_threshold { + filter = "metric.type=\"monitoring.googleapis.com/uptime_check/check_passed\" AND resource.type=\"uptime_url\" AND metric.label.check_id=\"${google_monitoring_uptime_check_config.readyz.uptime_check_id}\"" + duration = "120s" + comparison = "COMPARISON_LT" + threshold_value = 1 + } + } + + notification_channels = [google_monitoring_notification_channel.email[0].id] +} + +output "server_uri" { + value = google_cloud_run_v2_service.server.uri +} + +output "web_uri" { + value = google_cloud_run_v2_service.web.uri +} diff --git a/judging/server/eslint.config.mjs b/judging/server/eslint.config.mjs new file mode 100644 index 00000000..d466eefb --- /dev/null +++ b/judging/server/eslint.config.mjs @@ -0,0 +1,28 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...config, + { + files: ["src/log.ts", "src/index.ts"], + rules: { "no-console": "off" }, + }, + { + files: ["src/**/*.ts"], + ignores: ["src/env.ts", "src/**/*.test.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["@query", "@query/*"], + message: + "judging/ is a standalone product. Import from @panel/* only.", + }, + ], + }, + ], + }, + }, +]; diff --git a/judging/server/package.json b/judging/server/package.json new file mode 100644 index 00000000..d260728b --- /dev/null +++ b/judging/server/package.json @@ -0,0 +1,38 @@ +{ + "name": "@panel/server", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./outbox": "./src/services/outbox.ts" + }, + "scripts": { + "start": "tsx src/index.ts", + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc --noEmit", + "test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts" + }, + "dependencies": { + "@hono/node-server": "^1.19.0", + "@panel/core": "workspace:*", + "@panel/db": "workspace:*", + "@trpc/server": "11.19.0", + "drizzle-orm": "0.45.3", + "hono": "^4.9.0", + "ioredis": "^5.8.2", + "jose": "^6.1.0", + "nodemailer": "^10.0.10", + "prom-client": "15.1.3", + "tsx": "^4.23.15", + "ws": "8.20.1", + "zod": "4.6.5" + }, + "devDependencies": { + "@query/eslint-config": "workspace:*", + "@query/tsconfig": "workspace:*", + "@types/node": "22.20.4", + "@types/ws": "^8.18.1", + "typescript": "7.0.2", + "vitest": "^5.0.1" + } +} diff --git a/judging/server/src/app.test.ts b/judging/server/src/app.test.ts new file mode 100644 index 00000000..9d445519 --- /dev/null +++ b/judging/server/src/app.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "./app"; +import { createMetrics } from "./metrics"; + +describe("health", () => { + it("answers /healthz without a database", async () => { + const app = createApp({ + db: {} as never, + metrics: createMetrics(), + jwtSecret: "test", + devAuth: false, + busMode: "memory", + }); + const response = await app.request("/healthz"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + }); +}); diff --git a/judging/server/src/app.ts b/judging/server/src/app.ts new file mode 100644 index 00000000..cdd03e78 --- /dev/null +++ b/judging/server/src/app.ts @@ -0,0 +1,391 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { TRPCError } from "@trpc/server"; +import { and, eq, isNull, sql } from "drizzle-orm"; +import { z } from "zod"; +import type { PanelDb } from "@panel/db"; +import { event, loginCode, membership, organization, user, visit } from "@panel/db"; +import { hashCode, newLoginCode, roleAtLeast, signActor, verifyActor } from "./auth"; +import { sendLoginCode } from "./mail"; +import type { Role } from "./auth"; +import type { Bus } from "./bus"; +import { InMemoryBus } from "./bus"; +import { feedbackCard, feedbackTokenForExternal, actorForApiKey } from "./services/catalog"; +import { organizerFloor } from "./services/floor"; +import { liveSnapshot } from "./services/live"; +import { inspectRun, publishedPlacements } from "./services/results"; +import type { Metrics } from "./metrics"; +import { appRouter } from "./router"; +import type { Context } from "./router"; + +const openApi = { + openapi: "3.0.3", + info: { + title: "Panel", + version: "0.0.0", + description: + "REST mirror of the tRPC procedures. Send Authorization: Bearer with a JWT or an API key, except for the public routes.", + }, + paths: { + "/healthz": { get: { summary: "Process is up." } }, + "/readyz": { get: { summary: "Postgres answers. The bus is this process unless Redis is set." } }, + "/metrics": { get: { summary: "Prometheus metrics." } }, + "/openapi.json": { get: { summary: "This document." } }, + "/v1/auth/magic-link": { post: { summary: "Email a sign-in code. Dev auth returns the code." } }, + "/v1/auth/verify": { post: { summary: "Exchange a code for a JWT." } }, + "/v1/judges/apply": { post: { summary: "Apply to judge this event with the signed-in email." } }, + "/v1/public/{orgSlug}/{eventSlug}": { get: { summary: "Event name, phase, and branding." } }, + "/v1/session/next": { post: { summary: "Hand the signed-in judge their next table." } }, + "/v1/session/arrive": { post: { summary: "Mark arrival by table number or QR token." } }, + "/v1/session/vote": { post: { summary: "Store the score for the open visit." } }, + "/v1/session/skip": { post: { summary: "Pass on the open table without a score." } }, + "/v1/session/progress": { get: { summary: "How many projects this judge has scored." } }, + "/v1/session/compare": { post: { summary: "Record which of the last two tables was better." } }, + "/v1/session/rubric": { get: { summary: "Criteria for the event's default rubric." } }, + "/v1/catalog/projects": { get: { summary: "Projects for table cards. Organizer." } }, + "/v1/catalog/logs": { get: { summary: "Recent event log rows. Organizer." } }, + "/v1/catalog/tables": { get: { summary: "Floor tables and coordinates. Organizer." } }, + "/v1/catalog/judges": { get: { summary: "Judges and their status. Organizer." } }, + "/v1/live/{orgSlug}/{eventSlug}": { get: { summary: "Board snapshot: coverage, or placements after publish." } }, + "/v1/floor/{eventId}": { get: { summary: "Organizer floor: looks per table, and idle, walking, or overtime judges." } }, + "/v1/results/run/{runId}": { get: { summary: "One run, with rubric and pairwise components. Organizer." } }, + "/v1/results/{eventId}": { get: { summary: "Published placements. 403 before publish." } }, + "/v1/feedback/{token}": { get: { summary: "A team's card. 403 for an unknown token or before publish." } }, + }, +}; + +export function createApp(options: { + db: PanelDb; + metrics: Metrics; + jwtSecret: string; + jwksUrl?: string; + devAuth: boolean; + smtpUrl?: string; + busMode: "memory" | "redis"; + bus?: Bus; +}) { + const bus = options.bus ?? new InMemoryBus(); + const app = new Hono(); + app.use( + "*", + cors({ + origin: "*", + allowHeaders: ["Authorization", "Content-Type"], + allowMethods: ["GET", "POST", "OPTIONS"], + }), + ); + + app.get("/healthz", (c) => c.json({ ok: true })); + + app.get("/readyz", async (c) => { + try { + await options.db.execute(sql`select 1`); + return c.json({ ok: true, bus: options.busMode }); + } catch (error) { + const message = error instanceof Error ? error.message : "database"; + return c.json({ ok: false, message }, 503); + } + }); + + app.get("/metrics", async (c) => { + const [open] = await options.db + .select({ n: sql`count(distinct ${visit.judgeId})::int` }) + .from(visit) + .where(and(isNull(visit.completedAt), isNull(visit.voidedAt))); + options.metrics.liveJudges.set(open?.n ?? 0); + c.header("content-type", options.metrics.register.contentType); + return c.body(await options.metrics.register.metrics()); + }); + + app.get("/openapi.json", (c) => c.json(openApi)); + + app.post("/v1/auth/magic-link", async (c) => { + const body = z.object({ email: z.string().email() }).parse(await c.req.json()); + if (!options.devAuth && !options.smtpUrl) { + return c.json({ ok: false, message: "SMTP is not configured" }, 503); + } + const code = newLoginCode(); + const email = body.email.toLowerCase(); + await options.db.insert(loginCode).values({ + email, + codeHash: hashCode(code), + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + }); + if (options.smtpUrl) { + await sendLoginCode(options.smtpUrl, email, code); + return c.json({ ok: true }); + } + return c.json({ ok: true, code }); + }); + + app.post("/v1/auth/verify", async (c) => { + const body = z + .object({ + email: z.string().email(), + code: z.string().length(6), + org: z.string().optional(), + }) + .parse(await c.req.json()); + const email = body.email.toLowerCase(); + const [row] = await options.db + .select() + .from(loginCode) + .where( + and( + eq(loginCode.email, email), + eq(loginCode.codeHash, hashCode(body.code)), + isNull(loginCode.consumedAt), + ), + ); + if (!row || row.expiresAt.getTime() < Date.now()) { + return c.json({ ok: false, message: "Code is not valid" }, 401); + } + await options.db + .update(loginCode) + .set({ consumedAt: new Date() }) + .where(eq(loginCode.id, row.id)); + + const [person] = await options.db + .select() + .from(user) + .where(eq(user.email, email)); + let role: Role | null = null; + let orgSlug: string | null = body.org ?? null; + if (person) { + const rows = await options.db + .select({ role: membership.role, slug: organization.slug }) + .from(membership) + .innerJoin(organization, eq(organization.id, membership.orgId)) + .where(eq(membership.userId, person.id)); + const chosen = orgSlug + ? rows.find((item) => item.slug === orgSlug) + : rows.length === 1 + ? rows[0] + : undefined; + if (chosen) { + role = chosen.role; + orgSlug = chosen.slug; + } + } + + if (!options.jwtSecret) { + return c.json({ ok: false, message: "PANEL_JWT_SECRET is not set" }, 503); + } + const token = await signActor( + { + sub: person?.id ?? email, + email, + name: person?.name ?? email, + org: orgSlug, + role, + judgeExternalId: null, + }, + options.jwtSecret, + 60 * 60 * 12, + ); + return c.json({ ok: true, token }); + }); + + app.post("/v1/judges/apply", async (c) => { + const body = z + .object({ eventId: z.string().uuid(), name: z.string().min(1) }) + .parse(await c.req.json()); + return rest((caller) => caller.judge.applyToEvent(body))(c); + }); + + app.get("/v1/public/:orgSlug/:eventSlug", async (c) => { + const [row] = await options.db + .select({ + eventId: event.id, + name: event.name, + phase: event.phase, + branding: organization.branding, + orgName: organization.name, + }) + .from(event) + .innerJoin(organization, eq(organization.id, event.orgId)) + .where( + and( + eq(organization.slug, c.req.param("orgSlug")), + eq(event.slug, c.req.param("eventSlug")), + ), + ); + if (!row) return c.json({ ok: false }, 404); + return c.json(row); + }); + + const contextFor = async (c: { req: { header: (name: string) => string | undefined } }): Promise => { + const header = c.req.header("authorization"); + let actor: Context["actor"] = null; + if (header?.startsWith("Bearer ")) { + const token = header.slice("Bearer ".length); + try { + actor = await verifyActor(token, { + secret: options.jwtSecret, + jwksUrl: options.jwksUrl, + }); + } catch { + actor = await actorForApiKey(options.db, token); + } + } + return { db: options.db, actor, now: new Date(), bus, metrics: options.metrics }; + }; + + const rest = (run: (caller: ReturnType) => Promise) => { + return async (c: Parameters[0] & { json: (body: unknown, status?: number) => Response }) => { + try { + return c.json(await run(appRouter.createCaller(await contextFor(c)))); + } catch (error) { + const message = error instanceof TRPCError ? error.message : "Request failed"; + const status = error instanceof TRPCError && error.code === "UNAUTHORIZED" ? 401 : 400; + return c.json({ message }, status); + } + }; + }; + + app.post("/v1/session/next", async (c) => { + const body = z.object({ eventId: z.string().uuid() }).parse(await c.req.json()); + return rest((caller) => caller.session.next(body))(c); + }); + app.post("/v1/session/arrive", async (c) => { + const body = z + .object({ + eventId: z.string().uuid(), + qrToken: z.string().uuid().optional(), + tableNumber: z.number().int().optional(), + }) + .parse(await c.req.json()); + return rest((caller) => caller.session.arrive(body))(c); + }); + app.post("/v1/session/vote", async (c) => { + const body = z + .object({ + eventId: z.string().uuid(), + visitId: z.string().uuid(), + comment: z.string().nullable().optional(), + scores: z.array(z.object({ criterionId: z.string().uuid(), value: z.number() })), + }) + .parse(await c.req.json()); + return rest((caller) => caller.session.vote({ ...body, comment: body.comment ?? null }))(c); + }); + app.get("/v1/session/rubric", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.session.rubric({ eventId }))(c); + }); + app.post("/v1/session/compare", async (c) => { + const body = z + .object({ eventId: z.string().uuid(), outcome: z.enum(["a", "b", "tie"]) }) + .parse(await c.req.json()); + return rest((caller) => caller.session.compare(body))(c); + }); + app.post("/v1/session/skip", async (c) => { + const body = z + .object({ eventId: z.string().uuid(), visitId: z.string().uuid() }) + .parse(await c.req.json()); + return rest((caller) => caller.session.skip(body))(c); + }); + app.get("/v1/session/progress", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.session.progress({ eventId }))(c); + }); + + app.get("/v1/catalog/projects", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.catalog.projects({ eventId }))(c); + }); + app.get("/v1/catalog/logs", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.catalog.logs({ eventId }))(c); + }); + app.get("/v1/catalog/tables", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.catalog.tables({ eventId }))(c); + }); + app.get("/v1/catalog/judges", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.catalog.judges({ eventId }))(c); + }); + + app.get("/v1/floor/:eventId", async (c) => { + const ctx = await contextFor(c); + if (!ctx.actor || !roleAtLeast(ctx.actor.role, "organizer")) { + return c.json({ message: "Unauthorized" }, 401); + } + const eventId = z.string().uuid().parse(c.req.param("eventId")); + const floor = await organizerFloor(options.db, eventId, ctx.now); + if (!floor) return c.json({ message: "Not found" }, 404); + return c.json(floor); + }); + + app.get("/v1/live/:orgSlug/:eventSlug", async (c) => { + const snapshot = await liveSnapshot( + options.db, + c.req.param("orgSlug"), + c.req.param("eventSlug"), + ); + if (!snapshot) return c.json({ ok: false }, 404); + return c.json(snapshot); + }); + + app.get("/v1/results/run/:runId", async (c) => { + const header = c.req.header("authorization"); + if (!header?.startsWith("Bearer ")) return c.json({ message: "Unauthorized" }, 401); + try { + await verifyActor(header.slice("Bearer ".length), { + secret: options.jwtSecret, + jwksUrl: options.jwksUrl, + }); + } catch { + return c.json({ message: "Unauthorized" }, 401); + } + const runId = z.string().uuid().parse(c.req.param("runId")); + return c.json(await inspectRun(options.db, runId)); + }); + + app.get("/v1/results/:eventId", async (c) => { + const header = c.req.header("authorization"); + if (!header?.startsWith("Bearer ")) return c.json({ message: "Unauthorized" }, 401); + try { + await verifyActor(header.slice("Bearer ".length), { + secret: options.jwtSecret, + jwksUrl: options.jwksUrl, + }); + } catch { + return c.json({ message: "Unauthorized" }, 401); + } + const eventId = z.string().uuid().parse(c.req.param("eventId")); + const placements = await publishedPlacements(options.db, eventId); + if (!placements) return c.json({ message: "Not found" }, 404); + if (!placements.published) return c.json({ message: "Forbidden" }, 403); + return c.json(placements); + }); + + app.get("/v1/feedback/external/:eventId/:externalId", async (c) => { + const ctx = await contextFor(c); + if (!ctx.actor || !roleAtLeast(ctx.actor.role, "organizer")) { + return c.json({ message: "Unauthorized" }, 401); + } + const eventId = z.string().uuid().parse(c.req.param("eventId")); + const token = await feedbackTokenForExternal(options.db, eventId, c.req.param("externalId")); + if (!token) return c.json({ message: "Forbidden" }, 403); + return c.json({ token }); + }); + + app.get("/v1/feedback/:token", async (c) => { + const card = await feedbackCard(options.db, c.req.param("token")); + if (card.status === "forbidden") return c.json({ message: "Forbidden" }, 403); + return c.json(card); + }); + + app.all("/trpc/*", (c) => + fetchRequestHandler({ + endpoint: "/trpc", + req: c.req.raw, + router: appRouter, + createContext: () => contextFor(c), + }), + ); + + return app; +} diff --git a/judging/server/src/auth.test.ts b/judging/server/src/auth.test.ts new file mode 100644 index 00000000..33babd47 --- /dev/null +++ b/judging/server/src/auth.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { hashApiKey, roleForScopes } from "./auth"; + +describe("api keys", () => { + it("hashes a token the same way every time", () => { + expect(hashApiKey("panel_live_key")).toBe(hashApiKey("panel_live_key")); + expect(hashApiKey("panel_live_key")).not.toBe(hashApiKey("panel_live_other")); + }); + + it("uses the strongest scope as the role", () => { + expect(roleForScopes(["volunteer", "organizer"])).toBe("organizer"); + expect(roleForScopes(["admin"])).toBe("admin"); + expect(roleForScopes(["import"])).toBe("organizer"); + expect(roleForScopes(["read"])).toBe(null); + }); +}); diff --git a/judging/server/src/auth.ts b/judging/server/src/auth.ts new file mode 100644 index 00000000..8a51e635 --- /dev/null +++ b/judging/server/src/auth.ts @@ -0,0 +1,100 @@ +import { createHash, randomInt } from "node:crypto"; +import { SignJWT, createRemoteJWKSet, jwtVerify } from "jose"; + +export const ROLES = ["volunteer", "organizer", "admin", "owner"] as const; +export type Role = (typeof ROLES)[number]; + +export type Actor = { + sub: string; + email: string; + name: string; + org: string | null; + role: Role | null; + judgeExternalId: string | null; +}; + +const ROLE_RANK: Record = { + volunteer: 0, + organizer: 1, + admin: 2, + owner: 3, +}; + +export function roleAtLeast(actual: Role | null, minimum: Role): boolean { + if (!actual) return false; + return ROLE_RANK[actual] >= ROLE_RANK[minimum]; +} + +export function hashCode(code: string): string { + return createHash("sha256").update(code).digest("hex"); +} + +export function newLoginCode(): string { + return String(randomInt(0, 1_000_000)).padStart(6, "0"); +} + +export async function signActor( + actor: Actor, + secret: string, + ttlSeconds: number, +): Promise { + return new SignJWT({ + email: actor.email, + name: actor.name, + org: actor.org, + role: actor.role, + judge_external_id: actor.judgeExternalId, + }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(actor.sub) + .setIssuedAt() + .setExpirationTime(`${ttlSeconds}s`) + .sign(new TextEncoder().encode(secret)); +} + +export async function verifyActor( + token: string, + options: { secret?: string; jwksUrl?: string }, +): Promise { + const verified = options.jwksUrl + ? await jwtVerify(token, createRemoteJWKSet(new URL(options.jwksUrl))) + : await jwtVerify(token, new TextEncoder().encode(options.secret ?? "")); + const payload = verified.payload; + const email = payload.email; + const name = payload.name; + if (typeof email !== "string" || typeof payload.sub !== "string") { + throw new Error("Token is missing sub or email"); + } + const role = ROLES.find((item) => item === payload.role) ?? null; + return { + sub: payload.sub, + email, + name: typeof name === "string" ? name : email, + org: typeof payload.org === "string" ? payload.org : null, + role, + judgeExternalId: + typeof payload.judge_external_id === "string" + ? payload.judge_external_id + : null, + }; +} + +export function hashApiKey(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +/** The strongest role named in the key's scopes. Import and export are organizer work. */ +export function roleForScopes(scopes: readonly string[]): Role | null { + if (scopes.includes("owner")) return "owner"; + if (scopes.includes("admin")) return "admin"; + if ( + scopes.includes("organizer") || + scopes.includes("import") || + scopes.includes("export") || + scopes.includes("webhooks") + ) { + return "organizer"; + } + if (scopes.includes("volunteer")) return "volunteer"; + return null; +} diff --git a/judging/server/src/bus.test.ts b/judging/server/src/bus.test.ts new file mode 100644 index 00000000..b35b71f4 --- /dev/null +++ b/judging/server/src/bus.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { FallbackBus, InMemoryBus } from "./bus"; +import type { Bus } from "./bus"; + +describe("InMemoryBus", () => { + it("delivers a message only to subscribers of that channel", () => { + const bus = new InMemoryBus(); + const seen: unknown[] = []; + const stop = bus.subscribe("event:1:board", (message) => seen.push(message)); + bus.publish("event:1:board", { kind: "vote.cast" }); + bus.publish("event:2:board", { kind: "other" }); + stop(); + bus.publish("event:1:board", { kind: "again" }); + expect(seen).toEqual([{ kind: "vote.cast" }]); + }); +}); + +class DownableBus implements Bus { + readonly seen: unknown[] = []; + private readonly down: (() => void)[] = []; + + publish(_channel: string, message: unknown) { + this.seen.push(message); + } + + subscribe() { + return () => undefined; + } + + onDown(listener: () => void) { + this.down.push(listener); + } + + stop() { + for (const listener of this.down) listener(); + } +} + +describe("FallbackBus", () => { + it("keeps delivering on this process after Redis reports down", () => { + const redis = new DownableBus(); + const bus = new FallbackBus(redis); + const seen: unknown[] = []; + bus.subscribe("event:1:board", (message) => seen.push(message)); + bus.publish("event:1:board", { kind: "before" }); + redis.stop(); + bus.publish("event:1:board", { kind: "after" }); + expect(redis.seen).toEqual([{ kind: "before" }]); + expect(seen).toEqual([{ kind: "after" }]); + }); +}); diff --git a/judging/server/src/bus.ts b/judging/server/src/bus.ts new file mode 100644 index 00000000..1cc6fe2b --- /dev/null +++ b/judging/server/src/bus.ts @@ -0,0 +1,131 @@ +import { Redis } from "ioredis"; +import { log } from "./log"; + +type Listener = (message: unknown) => void; + +export type Bus = { + publish(channel: string, message: unknown): void; + subscribe(channel: string, listener: Listener): () => void; + onDown(listener: () => void): void; +}; + +/** In-process fan-out. Used when REDIS_URL is unset. */ +export class InMemoryBus implements Bus { + private readonly channels = new Map>(); + + publish(channel: string, message: unknown) { + for (const listener of this.channels.get(channel) ?? []) listener(message); + } + + subscribe(channel: string, listener: Listener) { + const listeners = this.channels.get(channel) ?? new Set(); + listeners.add(listener); + this.channels.set(channel, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) this.channels.delete(channel); + }; + } + + onDown() { + // This process is the bus. + } +} + +export function createRedisBus(url: string): Bus { + const options = { maxRetriesPerRequest: null }; + const pub = new Redis(url, options); + const sub = new Redis(url, options); + const listeners = new Map>(); + const downListeners = new Set<() => void>(); + let down = false; + const fail = () => { + if (down) return; + down = true; + for (const listener of downListeners) listener(); + }; + pub.on("error", fail); + sub.on("error", fail); + sub.on("message", (channel: string, raw: string) => { + const message = JSON.parse(raw) as unknown; + for (const listener of listeners.get(channel) ?? []) listener(message); + }); + return { + publish(channel, message) { + void pub.publish(channel, JSON.stringify(message)); + }, + subscribe(channel, listener) { + const set = listeners.get(channel) ?? new Set(); + const first = set.size === 0; + set.add(listener); + listeners.set(channel, set); + if (first) void sub.subscribe(channel); + return () => { + set.delete(listener); + if (set.size === 0) void sub.unsubscribe(channel); + }; + }, + onDown(listener) { + downListeners.add(listener); + }, + }; +} + +/** Redis when REDIS_URL is set, otherwise the in-process bus. */ +export function createBus(redisUrl: string | undefined): Bus { + const url = redisUrl?.trim(); + if (!url) return new InMemoryBus(); + return new FallbackBus(createRedisBus(url)); +} + +/** + * Uses Redis until the connection errors, then this process fans out in memory. + * Clients still reload state from the live snapshot, so a second server does + * not need Redis for correctness. + */ +export class FallbackBus implements Bus { + private readonly memory = new InMemoryBus(); + private redis: Bus | null; + private failed = false; + private readonly subs: { channel: string; listener: Listener; unsubscribe: () => void }[] = []; + + constructor(redis: Bus) { + this.redis = redis; + redis.onDown(() => this.fail()); + } + + private fail() { + if (this.failed) return; + this.failed = true; + for (const sub of this.subs) { + sub.unsubscribe(); + sub.unsubscribe = this.memory.subscribe(sub.channel, sub.listener); + } + this.redis = null; + log({ + level: "warn", + message: "Redis is unavailable. This process is using the in-process bus.", + }); + } + + publish(channel: string, message: unknown) { + const target = this.failed ? this.memory : this.redis; + target?.publish(channel, message); + } + + subscribe(channel: string, listener: Listener) { + const target = this.failed ? this.memory : this.redis; + const unsubscribe = target?.subscribe(channel, listener) ?? (() => undefined); + const sub = { channel, listener, unsubscribe }; + this.subs.push(sub); + return () => { + sub.unsubscribe(); + const index = this.subs.indexOf(sub); + if (index >= 0) this.subs.splice(index, 1); + }; + } + + onDown() { + // Callers keep their sockets. This process still delivers messages. + } +} diff --git a/judging/server/src/cross-server.check.ts b/judging/server/src/cross-server.check.ts new file mode 100644 index 00000000..85c4120c --- /dev/null +++ b/judging/server/src/cross-server.check.ts @@ -0,0 +1,111 @@ +import { WebSocket } from "ws"; +import { eq } from "drizzle-orm"; +import { createDb, event, judge, organization } from "@panel/db"; +import { signActor } from "./auth"; +import { log } from "./log"; + +const databaseUrl = process.env.DATABASE_URL ?? process.env.PANEL_DATABASE_URL; +const secret = process.env.PANEL_JWT_SECRET; +const portA = process.env.PORT_A ?? "8787"; +const portB = process.env.PORT_B ?? portA; +if (!databaseUrl || !secret) { + throw new Error("DATABASE_URL and PANEL_JWT_SECRET are required"); +} + +const { db, pool } = createDb(databaseUrl); +const [demo] = await db + .select({ eventId: event.id, org: organization.slug }) + .from(event) + .innerJoin(organization, eq(organization.id, event.orgId)) + .where(eq(organization.slug, "demo")); +if (!demo) throw new Error("demo event missing"); + +const judges = await db.select().from(judge).where(eq(judge.eventId, demo.eventId)); +await pool.end(); + +const baseA = `http://127.0.0.1:${portA}`; +const socket = new WebSocket( + `ws://127.0.0.1:${portB}/ws?channel=event:${demo.eventId}:board`, +); +await new Promise((resolve, reject) => { + socket.once("open", () => resolve()); + socket.once("error", reject); +}); + +let accept = false; +const heard = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("no vote on the other server within 1s")), 15000); + socket.on("message", (data) => { + const text = String(data); + if (!accept || !text.includes("vote.cast")) return; + clearTimeout(timer); + resolve(text); + }); +}); + +let voted = false; +for (const row of judges) { + const token = await signActor( + { + sub: row.id, + email: row.email, + name: row.name, + org: demo.org, + role: null, + judgeExternalId: null, + }, + secret, + 600, + ); + const headers = { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }; + const next = await fetch(`${baseA}/v1/session/next`, { + method: "POST", + headers, + body: JSON.stringify({ eventId: demo.eventId }), + }); + const visit = (await next.json()) as { + done?: boolean; + visitId?: string; + tableNumber?: number | null; + message?: string; + }; + if (!next.ok || visit.done || !visit.visitId) continue; + await fetch(`${baseA}/v1/session/arrive`, { + method: "POST", + headers, + body: JSON.stringify({ eventId: demo.eventId, tableNumber: visit.tableNumber }), + }); + const rubricResponse = await fetch( + `${baseA}/v1/session/rubric?eventId=${demo.eventId}`, + { headers }, + ); + const rubric = (await rubricResponse.json()) as { id: string; min: number }[]; + if (!Array.isArray(rubric) || rubric.length === 0) continue; + accept = true; + const vote = await fetch(`${baseA}/v1/session/vote`, { + method: "POST", + headers, + body: JSON.stringify({ + eventId: demo.eventId, + visitId: visit.visitId, + comment: null, + scores: rubric.map((item) => ({ criterionId: item.id, value: item.min })), + }), + }); + if (!vote.ok) continue; + voted = true; + break; +} + +if (!voted) throw new Error("no judge could cast a vote"); +const message = await Promise.race([ + heard, + new Promise((_, reject) => { + setTimeout(() => reject(new Error("no vote on the other server within 1s")), 1000); + }), +]); +log({ message }); +socket.close(); diff --git a/judging/server/src/env.ts b/judging/server/src/env.ts new file mode 100644 index 00000000..4240ad98 --- /dev/null +++ b/judging/server/src/env.ts @@ -0,0 +1,11 @@ +export function serverEnv() { + return { + databaseUrl: process.env.DATABASE_URL ?? process.env.PANEL_DATABASE_URL ?? "", + jwtSecret: process.env.PANEL_JWT_SECRET ?? "", + jwksUrl: process.env.PANEL_JWKS_URL, + devAuth: process.env.PANEL_DEV_AUTH === "1", + smtpUrl: process.env.PANEL_SMTP_URL, + port: Number(process.env.PORT ?? 8787), + redisUrl: process.env.REDIS_URL, + }; +} diff --git a/judging/server/src/hub.ts b/judging/server/src/hub.ts new file mode 100644 index 00000000..3c97c38a --- /dev/null +++ b/judging/server/src/hub.ts @@ -0,0 +1,31 @@ +import type { Server } from "node:http"; +import { WebSocketServer } from "ws"; +import type { Bus } from "./bus"; +import type { Metrics } from "./metrics"; + +/** Clients subscribe with `?channel=event:{id}:board`. The bus is the only fan-out. */ +export function attachHub(server: Server, bus: Bus, metrics: Metrics) { + const sockets = new WebSocketServer({ server, path: "/ws" }); + sockets.on("connection", (socket, request) => { + const channel = new URL(request.url ?? "/", "http://localhost").searchParams.get( + "channel", + ); + metrics.wsConnections.inc(); + if (!channel) { + socket.close(); + metrics.wsConnections.dec(); + return; + } + const unsubscribe = bus.subscribe(channel, (message) => { + if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message)); + }); + socket.on("close", () => { + unsubscribe(); + metrics.wsConnections.dec(); + }); + }); + bus.onDown(() => { + for (const client of sockets.clients) client.close(); + }); + return sockets; +} diff --git a/judging/server/src/index.ts b/judging/server/src/index.ts new file mode 100644 index 00000000..b5ad4d8b --- /dev/null +++ b/judging/server/src/index.ts @@ -0,0 +1,73 @@ +import { serve } from "@hono/node-server"; +import type { Server } from "node:http"; +import { sql } from "drizzle-orm"; +import { boardChannel, judgeChannel } from "@panel/core"; +import { createDb } from "@panel/db"; +import { createBus } from "./bus"; +import { attachHub } from "./hub"; +import { createApp } from "./app"; +import { serverEnv } from "./env"; +import { log } from "./log"; +import { createMetrics } from "./metrics"; +import { drainOutbox } from "./services/outbox"; +import { loadOpenVisits, visitsOverTarget } from "./services/overtime"; + +const env = serverEnv(); +if (!env.databaseUrl) { + log({ level: "error", message: "DATABASE_URL is not set" }); + process.exit(1); +} + +const { db } = createDb(env.databaseUrl); +const metrics = createMetrics(); +const bus = createBus(env.redisUrl); +const app = createApp({ + db, + metrics, + jwtSecret: env.jwtSecret, + jwksUrl: env.jwksUrl, + devAuth: env.devAuth, + smtpUrl: env.smtpUrl, + busMode: env.redisUrl ? "redis" : "memory", + bus, +}); + +const notedOvertime = new Set(); +const timer = setInterval(() => { + drainOutbox(db) + .then(async () => { + const pending = await db.execute<{ count: string }>( + sql`select count(*)::text as count from outbox where delivered_at is null`, + ); + const count = Number(pending.rows[0]?.count ?? 0); + metrics.outboxPending.set(Number.isFinite(count) ? count : 0); + }) + .catch((error: unknown) => { + log({ + level: "error", + message: error instanceof Error ? error.message : "outbox", + }); + }); + loadOpenVisits(db) + .then((rows) => { + for (const row of visitsOverTarget(rows, new Date())) { + if (notedOvertime.has(row.visitId)) continue; + notedOvertime.add(row.visitId); + const message = { kind: "judge.overtime", eventId: row.eventId, visitId: row.visitId }; + bus.publish(judgeChannel(row.judgeId), message); + bus.publish(boardChannel(row.eventId), message); + } + }) + .catch((error: unknown) => { + log({ + level: "error", + message: error instanceof Error ? error.message : "overtime", + }); + }); +}, 2000); +timer.unref(); + +const server = serve({ fetch: app.fetch, port: env.port }, () => { + log({ level: "info", message: "listening", port: env.port }); +}); +attachHub(server as Server, bus, metrics); diff --git a/judging/server/src/log.ts b/judging/server/src/log.ts new file mode 100644 index 00000000..7f80e178 --- /dev/null +++ b/judging/server/src/log.ts @@ -0,0 +1,3 @@ +export function log(fields: Record) { + console.log(JSON.stringify({ time: new Date().toISOString(), ...fields })); +} diff --git a/judging/server/src/mail.ts b/judging/server/src/mail.ts new file mode 100644 index 00000000..89d84954 --- /dev/null +++ b/judging/server/src/mail.ts @@ -0,0 +1,12 @@ +import nodemailer from "nodemailer"; + +/** Sends the sign-in code when PANEL_SMTP_URL is set. */ +export async function sendLoginCode(smtpUrl: string, to: string, code: string) { + const transport = nodemailer.createTransport(smtpUrl); + await transport.sendMail({ + from: process.env.PANEL_SMTP_FROM ?? "panel@localhost", + to, + subject: "Your panel sign-in code", + text: `Your sign-in code is ${code}. It expires in 10 minutes.`, + }); +} diff --git a/judging/server/src/metrics.ts b/judging/server/src/metrics.ts new file mode 100644 index 00000000..5089489a --- /dev/null +++ b/judging/server/src/metrics.ts @@ -0,0 +1,40 @@ +import { Counter, Gauge, Histogram, Registry } from "prom-client"; + +export function createMetrics() { + const register = new Registry(); + const dispatchSeconds = new Histogram({ + name: "panel_dispatch_seconds", + help: "Time to choose and record the next table", + registers: [register], + }); + const votes = new Counter({ + name: "panel_votes_total", + help: "Votes stored", + registers: [register], + }); + const outboxPending = new Gauge({ + name: "panel_outbox_pending", + help: "Outbox rows not yet delivered", + registers: [register], + }); + const wsConnections = new Gauge({ + name: "panel_ws_connections", + help: "Open websocket clients", + registers: [register], + }); + const liveJudges = new Gauge({ + name: "panel_live_judges", + help: "Judges with a visit that is still open", + registers: [register], + }); + const coverage = new Histogram({ + name: "panel_project_looks", + help: "How many scores a project had when the latest one landed", + buckets: [1, 2, 3, 4, 5, 8, 13], + labelNames: ["event_id"], + registers: [register], + }); + return { register, dispatchSeconds, votes, outboxPending, wsConnections, liveJudges, coverage }; +} + +export type Metrics = ReturnType; diff --git a/judging/server/src/router.ts b/judging/server/src/router.ts new file mode 100644 index 00000000..8e7806bc --- /dev/null +++ b/judging/server/src/router.ts @@ -0,0 +1,652 @@ +import { initTRPC, TRPCError } from "@trpc/server"; +import { and, eq, sql } from "drizzle-orm"; +import { z } from "zod"; +import { assertTransition, boardChannel, judgeChannel, leaderboardChannel } from "@panel/core"; +import type { PanelDb } from "@panel/db"; +import { event, eventLog, judge, criterion, rubric, visit, vote } from "@panel/db"; +import type { Actor, Role } from "./auth"; +import { roleAtLeast } from "./auth"; +import type { Bus } from "./bus"; +import type { Metrics } from "./metrics"; +import { log } from "./log"; +import { arrive } from "./services/arrive"; +import { dispatchNext, judgeProgress, skipVisit } from "./services/dispatch"; +import { + assignJudge, + applyAsJudge, + recallJudge, + setJudgeStatus, + upsertJudge, + upsertProject, + voidVisit, + withdrawProject, + rotateQr, + assignEventTables, +} from "./services/records"; +import { + computeResults, + diffPlacements, + inspectRun, + placementsForRun, + publishedPlacements, + publishResults, + unpublishResults, +} from "./services/results"; +import { castComparison } from "./services/compare"; +import { + issueApiKey, + listProjects, + listLogs, + listJudges, + listTables, + listTracks, + placeTable, + savePrize, + saveRubric, + saveTrack, + saveWebhook, + saveZone, + saveConfig, +} from "./services/catalog"; +import { castVote } from "./services/vote"; + +export type Context = { + db: PanelDb; + actor: Actor | null; + now: Date; + bus: Bus; + metrics: Metrics; +}; + +function tell( + ctx: Context, + eventId: string, + judgeId: string | null, + kind: string, + visitId?: string, +) { + log({ + level: "info", + message: kind, + event_id: eventId, + judge_id: judgeId, + visit_id: visitId ?? null, + }); + const message = { kind, eventId }; + ctx.bus.publish(boardChannel(eventId), message); + ctx.bus.publish(leaderboardChannel(eventId), message); + if (judgeId) ctx.bus.publish(judgeChannel(judgeId), message); +} + +const t = initTRPC.context().create(); + +function asTrpc(error: unknown): never { + const message = error instanceof Error ? error.message : "Request failed"; + const code = message.includes("not allowed") + ? "PRECONDITION_FAILED" + : message.includes("not found") || message.includes("not approved") + ? "NOT_FOUND" + : message.includes("hard limit") + ? "CONFLICT" + : "BAD_REQUEST"; + throw new TRPCError({ code, message }); +} + +const authed = t.procedure.use(({ ctx, next }) => { + if (!ctx.actor) throw new TRPCError({ code: "UNAUTHORIZED" }); + return next({ ctx: { ...ctx, actor: ctx.actor } }); +}); + +function atLeast(role: Role) { + return authed.use(({ ctx, next }) => { + if (!roleAtLeast(ctx.actor.role, role)) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + return next(); + }); +} + +async function requireJudge(ctx: Context, eventId: string) { + if (!ctx.actor) throw new TRPCError({ code: "UNAUTHORIZED" }); + const rows = await ctx.db + .select() + .from(judge) + .where(eq(judge.eventId, eventId)); + const match = rows.find( + (row) => + (ctx.actor?.judgeExternalId && + row.externalId === ctx.actor.judgeExternalId) || + row.email.toLowerCase() === ctx.actor?.email.toLowerCase(), + ); + if (!match || match.status !== "approved") { + throw new TRPCError({ code: "FORBIDDEN", message: "This judge is not approved" }); + } + return match; +} + +const uuid = z.string().uuid(); + +export const appRouter = t.router({ + session: t.router({ + next: authed + .input(z.object({ eventId: uuid })) + .mutation(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + const stop = ctx.metrics.dispatchSeconds.startTimer(); + try { + const outcome = await dispatchNext(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + now: ctx.now, + }); + tell( + ctx, + input.eventId, + row.id, + "visit.handed_out", + outcome.done ? undefined : outcome.visitId, + ); + return outcome; + } catch (error) { + asTrpc(error); + } finally { + stop(); + } + }), + arrive: authed + .input( + z.object({ + eventId: uuid, + qrToken: uuid.optional(), + tableNumber: z.number().int().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + const outcome = await arrive(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + qrToken: input.qrToken, + tableNumber: input.tableNumber, + now: ctx.now, + }); + tell(ctx, input.eventId, row.id, "visit.arrived", outcome.visitId); + return outcome; + } catch (error) { + asTrpc(error); + } + }), + vote: authed + .input( + z.object({ + eventId: uuid, + visitId: uuid, + comment: z.string().nullable().default(null), + scores: z.array( + z.object({ criterionId: uuid, value: z.number() }), + ), + }), + ) + .mutation(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + const outcome = await castVote(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + visitId: input.visitId, + scores: input.scores, + comment: input.comment, + now: ctx.now, + }); + tell(ctx, input.eventId, row.id, "vote.cast", input.visitId); + ctx.metrics.votes.inc(); + const [current] = await ctx.db + .select({ projectId: visit.projectId }) + .from(visit) + .where(eq(visit.id, input.visitId)); + if (current) { + const [looks] = await ctx.db + .select({ n: sql`count(*)::int` }) + .from(vote) + .where( + and(eq(vote.eventId, input.eventId), eq(vote.projectId, current.projectId)), + ); + ctx.metrics.coverage.observe({ event_id: input.eventId }, looks?.n ?? 1); + } + return outcome; + } catch (error) { + asTrpc(error); + } + }), + rubric: authed + .input(z.object({ eventId: uuid })) + .query(async ({ ctx, input }) => { + await requireJudge(ctx, input.eventId); + const [rub] = await ctx.db + .select({ id: rubric.id }) + .from(rubric) + .where(and(eq(rubric.eventId, input.eventId), eq(rubric.isDefault, true))); + if (!rub) return []; + return ctx.db + .select({ + id: criterion.id, + label: criterion.label, + min: criterion.min, + max: criterion.max, + anchors: criterion.anchors, + }) + .from(criterion) + .where(eq(criterion.rubricId, rub.id)); + }), + compare: authed + .input(z.object({ eventId: uuid, outcome: z.enum(["a", "b", "tie"]) })) + .mutation(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + const outcome = await castComparison(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + outcome: input.outcome, + now: ctx.now, + }); + tell(ctx, input.eventId, row.id, "comparison.cast"); + return outcome; + } catch (error) { + asTrpc(error); + } + }), + skip: authed + .input(z.object({ eventId: uuid, visitId: uuid })) + .mutation(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + return await skipVisit(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + visitId: input.visitId, + now: ctx.now, + }); + } catch (error) { + asTrpc(error); + } + }), + progress: authed + .input(z.object({ eventId: uuid })) + .query(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + return await judgeProgress(ctx.db, { eventId: input.eventId, judgeId: row.id }); + } catch (error) { + asTrpc(error); + } + }), + }), + event: t.router({ + setPhase: atLeast("organizer") + .input(z.object({ eventId: uuid, phase: z.enum([ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", + ]) })) + .mutation(async ({ ctx, input }) => { + const [current] = await ctx.db + .select({ phase: event.phase, id: event.id }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!current) throw new TRPCError({ code: "NOT_FOUND" }); + try { + assertTransition(current.phase, input.phase); + } catch (error) { + asTrpc(error); + } + await ctx.db.transaction(async (tx) => { + await tx + .update(event) + .set({ phase: input.phase }) + .where(eq(event.id, input.eventId)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "phase.changed", + actor: { email: ctx.actor.email }, + subject: { eventId: input.eventId }, + payload: { from: current.phase, to: input.phase }, + }); + }); + tell(ctx, input.eventId, null, "phase.changed"); + return { phase: input.phase }; + }), + }), + project: t.router({ + upsert: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + externalId: z.string().min(1), + name: z.string().min(1), + teamName: z.string().nullable().default(null), + tableNumber: z.number().int().nullable().default(null), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await upsertProject(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + withdraw: atLeast("organizer") + .input(z.object({ eventId: uuid, projectId: uuid })) + .mutation(async ({ ctx, input }) => { + try { + await withdrawProject(ctx.db, { ...input, now: ctx.now, actorEmail: ctx.actor.email }); + return { ok: true }; + } catch (error) { + asTrpc(error); + } + }), + rotateQr: atLeast("organizer") + .input(z.object({ eventId: uuid, projectId: uuid })) + .mutation(async ({ ctx, input }) => { + try { + return await rotateQr(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + assignTables: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .mutation(async ({ ctx, input }) => { + return assignEventTables(ctx.db, { + eventId: input.eventId, + actorEmail: ctx.actor.email, + }); + }), + }), + judge: t.router({ + applyToEvent: authed + .input(z.object({ eventId: uuid, name: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + try { + return await applyAsJudge(ctx.db, { + eventId: input.eventId, + email: ctx.actor.email, + name: input.name, + }); + } catch (error) { + asTrpc(error); + } + }), + upsert: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + email: z.string().email(), + name: z.string().min(1), + externalId: z.string().nullable().default(null), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await upsertJudge(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + assign: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + judgeId: uuid, + trackId: uuid.nullable(), + zoneId: uuid.nullable(), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await assignJudge(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + setStatus: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + judgeId: uuid, + status: z.enum(["approved", "suspended"]), + }), + ) + .mutation(async ({ ctx, input }) => { + await setJudgeStatus(ctx.db, { ...input, actorEmail: ctx.actor.email }); + return { ok: true }; + }), + recall: atLeast("organizer") + .input(z.object({ eventId: uuid, judgeId: uuid })) + .mutation(async ({ ctx, input }) => { + await recallJudge(ctx.db, { ...input, actorEmail: ctx.actor.email }); + tell(ctx, input.eventId, input.judgeId, "judge.recalled"); + return { ok: true }; + }), + voidVisit: atLeast("organizer") + .input(z.object({ eventId: uuid, visitId: uuid, reason: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + try { + const voided = await voidVisit(ctx.db, { ...input, now: ctx.now, actorEmail: ctx.actor.email }); + tell(ctx, input.eventId, voided.judgeId, "visit.voided", input.visitId); + return { ok: true }; + } catch (error) { + asTrpc(error); + } + }), + }), + results: t.router({ + compute: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .mutation(async ({ ctx, input }) => { + try { + return await computeResults(ctx.db, { + eventId: input.eventId, + actorEmail: ctx.actor.email, + now: ctx.now, + }); + } catch (error) { + asTrpc(error); + } + }), + publish: atLeast("organizer") + .input(z.object({ eventId: uuid, runId: uuid })) + .mutation(async ({ ctx, input }) => { + try { + const outcome = await publishResults(ctx.db, { + ...input, + actorEmail: ctx.actor.email, + now: ctx.now, + }); + tell(ctx, input.eventId, null, "results.published"); + return outcome; + } catch (error) { + asTrpc(error); + } + }), + inspect: atLeast("organizer") + .input(z.object({ runId: uuid })) + .query(({ ctx, input }) => inspectRun(ctx.db, input.runId)), + diff: atLeast("organizer") + .input(z.object({ runA: uuid, runB: uuid })) + .query(async ({ ctx, input }) => { + const [left, right] = await Promise.all([ + placementsForRun(ctx.db, input.runA), + placementsForRun(ctx.db, input.runB), + ]); + return diffPlacements(left, right); + }), + unpublish: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .mutation(async ({ ctx, input }) => { + try { + const outcome = await unpublishResults(ctx.db, { + eventId: input.eventId, + actorEmail: ctx.actor.email, + }); + tell(ctx, input.eventId, null, "results.unpublished"); + return outcome; + } catch (error) { + asTrpc(error); + } + }), + export: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => publishedPlacements(ctx.db, input.eventId)), + }), + catalog: t.router({ + tracks: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => listTracks(ctx.db, input.eventId)), + projects: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => listProjects(ctx.db, input.eventId)), + logs: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => listLogs(ctx.db, input.eventId)), + tables: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => listTables(ctx.db, input.eventId)), + judges: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => listJudges(ctx.db, input.eventId)), + saveTrack: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + slug: z.string().min(1), + name: z.string().min(1), + kind: z.enum(["main", "sponsor", "special"]), + judgeGroup: z.string().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await saveTrack(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + savePrize: atLeast("organizer") + .input( + z.object({ + trackId: uuid, + place: z.number().int(), + title: z.string().min(1), + amount: z.number().nullable().default(null), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await savePrize(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + saveZone: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + name: z.string().min(1), + position: z.number().int().default(0), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await saveZone(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + placeTable: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + number: z.number().int(), + zoneId: uuid.nullable().default(null), + x: z.number().nullable().default(null), + y: z.number().nullable().default(null), + }), + ) + .mutation(async ({ ctx, input }) => { + await placeTable(ctx.db, { ...input, actorEmail: ctx.actor.email }); + return { ok: true }; + }), + saveRubric: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + name: z.string().min(1), + isDefault: z.boolean().default(false), + criteria: z.array( + z.object({ + key: z.string().min(1), + label: z.string().min(1), + min: z.number(), + max: z.number(), + weight: z.number(), + }), + ), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await saveRubric(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + saveWebhook: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + url: z.string().url(), + secret: z.string().min(8), + topics: z.array(z.string()).min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await saveWebhook(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + issueApiKey: atLeast("admin") + .input(z.object({ eventId: uuid, scopes: z.array(z.string()).min(1) })) + .mutation(async ({ ctx, input }) => { + try { + return await issueApiKey(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + saveConfig: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + hardLimitSeconds: z.number().int().positive(), + dispatchStrategy: z.enum(["coverage", "uncertainty"]), + pairwiseEnabled: z.boolean(), + pairwiseWeight: z.number().min(0).max(1), + leaderboardPublic: z.boolean(), + boardPollSeconds: z.number().int().positive(), + }), + ) + .mutation(async ({ ctx, input }) => { + await saveConfig(ctx.db, { ...input, actorEmail: ctx.actor.email }); + return { ok: true }; + }), + }), +}); + +export type AppRouter = typeof appRouter; diff --git a/judging/server/src/services/arrive.ts b/judging/server/src/services/arrive.ts new file mode 100644 index 00000000..bd213f61 --- /dev/null +++ b/judging/server/src/services/arrive.ts @@ -0,0 +1,76 @@ +import { and, eq, isNull } from "drizzle-orm"; +import type { PanelDb } from "@panel/db"; +import { event, eventLog, outbox, project, visit } from "@panel/db"; +import { phaseAllows } from "@panel/core"; + +export async function arrive( + db: PanelDb, + input: { + eventId: string; + judgeId: string; + qrToken?: string; + tableNumber?: number; + now: Date; + }, +): Promise<{ visitId: string; projectId: string }> { + return db.transaction(async (tx) => { + const [evt] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + if (!phaseAllows(evt.phase, "dispatch")) { + throw new Error(`dispatch is not allowed while the event is ${evt.phase}`); + } + + const [row] = await tx + .select() + .from(visit) + .where( + and( + eq(visit.judgeId, input.judgeId), + eq(visit.eventId, input.eventId), + isNull(visit.completedAt), + isNull(visit.voidedAt), + ), + ); + if (!row) throw new Error("No table is in hand"); + + const [current] = await tx + .select() + .from(project) + .where(eq(project.id, row.projectId)); + if (!current) throw new Error("Project not found"); + + const qrOk = input.qrToken !== undefined && input.qrToken === current.qrToken; + const tableOk = + input.tableNumber !== undefined && input.tableNumber === current.tableNumber; + if (!qrOk && !tableOk) throw new Error("That is not the table you were sent to"); + + if (row.arrivedAt) return { visitId: row.id, projectId: row.projectId }; + + await tx + .update(visit) + .set({ arrivedAt: input.now }) + .where(eq(visit.id, row.id)); + if (!current.arrivedFirstAt) { + await tx + .update(project) + .set({ arrivedFirstAt: input.now }) + .where(eq(project.id, current.id)); + } + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "visit.arrived", + actor: { judgeId: input.judgeId }, + subject: { visitId: row.id, projectId: row.projectId }, + payload: {}, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "visit.arrived", + payload: { visitId: row.id, judgeId: input.judgeId }, + }); + return { visitId: row.id, projectId: row.projectId }; + }); +} diff --git a/judging/server/src/services/catalog.ts b/judging/server/src/services/catalog.ts new file mode 100644 index 00000000..9bef9d5d --- /dev/null +++ b/judging/server/src/services/catalog.ts @@ -0,0 +1,454 @@ +import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; +import type { PanelDb } from "@panel/db"; +import { + apiKey, + criterion, + event, + eventConfig, + eventLog, + floorTable, + judge, + organization, + prize, + project, + projectTrack, + result, + resultRun, + rubric, + track, + vote, + voteScore, + webhook, + zone, +} from "@panel/db"; +import { randomBytes } from "node:crypto"; +import type { Actor } from "../auth"; +import { hashApiKey, roleForScopes } from "../auth"; +import { feedbackStatus, median } from "./feedback"; + +async function placementsForProject(db: PanelDb, projectId: string) { + const links = await db + .select({ trackId: track.id, track: track.name }) + .from(projectTrack) + .innerJoin(track, eq(track.id, projectTrack.trackId)) + .where(eq(projectTrack.projectId, projectId)); + const placed = await db + .select({ trackId: result.trackId, placement: result.placement }) + .from(result) + .innerJoin(resultRun, eq(resultRun.id, result.runId)) + .where(and(eq(result.projectId, projectId), isNotNull(resultRun.publishedAt))); + const placeOf = new Map(placed.map((row) => [row.trackId, row.placement])); + return links.map((link) => ({ + track: link.track, + placement: placeOf.get(link.trackId) ?? null, + })); +} + +/** The published card token for a project the club already knows by id. */ +export async function feedbackTokenForExternal(db: PanelDb, eventId: string, externalId: string) { + const [row] = await db + .select({ token: project.feedbackToken, phase: event.phase }) + .from(project) + .innerJoin(event, eq(event.id, project.eventId)) + .where(and(eq(project.eventId, eventId), eq(project.externalId, externalId))); + if (!row?.token || row.phase !== "published") return null; + return row.token; +} + +export async function feedbackCard(db: PanelDb, token: string) { + const [row] = await db + .select({ + projectId: project.id, + name: project.name, + eventId: project.eventId, + phase: event.phase, + stored: project.feedbackToken, + }) + .from(project) + .innerJoin(event, eq(event.id, project.eventId)) + .where(eq(project.feedbackToken, token)); + if ( + feedbackStatus({ + published: row?.phase === "published", + tokenMatches: row?.stored === token, + }) === "forbidden" || + !row + ) { + return { status: "forbidden" as const }; + } + + const scores = await db + .select({ + projectId: vote.projectId, + criterionId: voteScore.criterionId, + label: criterion.label, + value: voteScore.value, + }) + .from(voteScore) + .innerJoin(vote, eq(vote.id, voteScore.voteId)) + .innerJoin(criterion, eq(criterion.id, voteScore.criterionId)) + .where(eq(vote.eventId, row.eventId)); + + const byCriterion = new Map(); + for (const score of scores) { + const bucket = byCriterion.get(score.criterionId) ?? { + label: score.label, + mine: [], + all: [], + }; + bucket.all.push(score.value); + if (score.projectId === row.projectId) bucket.mine.push(score.value); + byCriterion.set(score.criterionId, bucket); + } + + return { + status: "ok" as const, + name: row.name, + criteria: [...byCriterion.values()].map((bucket) => ({ + label: bucket.label, + mean: + bucket.mine.length === 0 + ? 0 + : bucket.mine.reduce((sum, value) => sum + value, 0) / bucket.mine.length, + median: median(bucket.all), + })), + places: await placementsForProject(db, row.projectId), + comments: ( + await db + .select({ comment: vote.comment }) + .from(vote) + .where(and(eq(vote.projectId, row.projectId), isNotNull(vote.comment))) + ) + .map((item) => item.comment) + .filter((comment): comment is string => Boolean(comment)), + }; +} + +export async function saveTrack( + db: PanelDb, + input: { + eventId: string; + slug: string; + name: string; + kind: "main" | "sponsor" | "special"; + judgeGroup: string; + actorEmail: string; + }, +) { + const [created] = await db + .insert(track) + .values(input) + .returning({ id: track.id }); + if (!created) throw new Error("Track was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "track.saved", + actor: { email: input.actorEmail }, + subject: { trackId: created.id }, + payload: { slug: input.slug }, + }); + return created; +} + +export async function listTracks(db: PanelDb, eventId: string) { + return db.select().from(track).where(eq(track.eventId, eventId)); +} + +export async function savePrize( + db: PanelDb, + input: { + trackId: string; + place: number; + title: string; + amount: number | null; + actorEmail: string; + }, +) { + const [created] = await db + .insert(prize) + .values({ + trackId: input.trackId, + place: input.place, + title: input.title, + amount: input.amount, + }) + .returning({ id: prize.id }); + if (!created) throw new Error("Prize was not stored"); + const [owner] = await db + .select({ eventId: track.eventId }) + .from(track) + .where(eq(track.id, input.trackId)); + if (owner) { + await db.insert(eventLog).values({ + eventId: owner.eventId, + kind: "prize.saved", + actor: { email: input.actorEmail }, + subject: { prizeId: created.id, trackId: input.trackId }, + payload: { title: input.title, place: input.place }, + }); + } + return created; +} + +export async function saveZone( + db: PanelDb, + input: { eventId: string; name: string; position: number; actorEmail: string }, +) { + const [created] = await db + .insert(zone) + .values({ eventId: input.eventId, name: input.name, position: input.position }) + .returning({ id: zone.id }); + if (!created) throw new Error("Zone was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "zone.saved", + actor: { email: input.actorEmail }, + subject: { zoneId: created.id }, + payload: { name: input.name }, + }); + return created; +} + +export async function placeTable( + db: PanelDb, + input: { + eventId: string; + number: number; + zoneId: string | null; + x: number | null; + y: number | null; + actorEmail: string; + }, +) { + await db + .insert(floorTable) + .values(input) + .onConflictDoUpdate({ + target: [floorTable.eventId, floorTable.number], + set: { zoneId: input.zoneId, x: input.x, y: input.y }, + }); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "table.placed", + actor: { email: input.actorEmail }, + subject: { number: input.number }, + payload: { x: input.x, y: input.y }, + }); +} + +export async function saveRubric( + db: PanelDb, + input: { + eventId: string; + name: string; + isDefault: boolean; + criteria: { key: string; label: string; min: number; max: number; weight: number }[]; + actorEmail: string; + }, +) { + const [created] = await db + .insert(rubric) + .values({ + eventId: input.eventId, + name: input.name, + isDefault: input.isDefault, + }) + .returning({ id: rubric.id }); + if (!created) throw new Error("Rubric was not stored"); + if (input.criteria.length > 0) { + await db.insert(criterion).values( + input.criteria.map((item, position) => ({ + rubricId: created.id, + position, + key: item.key, + label: item.label, + min: item.min, + max: item.max, + weight: item.weight, + })), + ); + } + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "rubric.saved", + actor: { email: input.actorEmail }, + subject: { rubricId: created.id }, + payload: {}, + }); + return created; +} + +export async function saveWebhook( + db: PanelDb, + input: { + eventId: string; + url: string; + secret: string; + topics: string[]; + actorEmail: string; + }, +) { + const [evt] = await db + .select({ orgId: event.orgId }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + const [created] = await db + .insert(webhook) + .values({ + orgId: evt.orgId, + url: input.url, + secret: input.secret, + topics: input.topics, + }) + .returning({ id: webhook.id }); + if (!created) throw new Error("Webhook was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "webhook.saved", + actor: { email: input.actorEmail }, + subject: { webhookId: created.id }, + payload: { url: input.url }, + }); + return created; +} + +export async function issueApiKey( + db: PanelDb, + input: { eventId: string; scopes: string[]; actorEmail: string }, +) { + const [evt] = await db + .select({ orgId: organization.id }) + .from(event) + .innerJoin(organization, eq(organization.id, event.orgId)) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + const token = randomBytes(24).toString("hex"); + const [created] = await db + .insert(apiKey) + .values({ + orgId: evt.orgId, + hashedKey: hashApiKey(token), + scopes: input.scopes, + }) + .returning({ id: apiKey.id }); + if (!created) throw new Error("API key was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "api_key.created", + actor: { email: input.actorEmail }, + subject: { apiKeyId: created.id }, + payload: {}, + }); + return { id: created.id, token }; +} + +export async function actorForApiKey(db: PanelDb, token: string): Promise { + const [row] = await db + .select() + .from(apiKey) + .where(and(eq(apiKey.hashedKey, hashApiKey(token)), isNull(apiKey.revokedAt))); + if (!row) return null; + const granted = roleForScopes(row.scopes); + if (!granted) return null; + const [org] = await db + .select({ slug: organization.slug }) + .from(organization) + .where(eq(organization.id, row.orgId)); + if (!org) return null; + return { + sub: row.id, + email: `key:${row.id}`, + name: "API key", + org: org.slug, + role: granted, + judgeExternalId: null, + }; +} + +export async function listTables(db: PanelDb, eventId: string) { + return db + .select({ + number: floorTable.number, + zoneId: floorTable.zoneId, + x: floorTable.x, + y: floorTable.y, + }) + .from(floorTable) + .where(eq(floorTable.eventId, eventId)); +} + +export async function listJudges(db: PanelDb, eventId: string) { + return db + .select({ + id: judge.id, + name: judge.name, + email: judge.email, + status: judge.status, + }) + .from(judge) + .where(eq(judge.eventId, eventId)); +} + +export async function listProjects(db: PanelDb, eventId: string) { + return db + .select({ + id: project.id, + name: project.name, + tableNumber: project.tableNumber, + zoneId: project.zoneId, + zoneName: zone.name, + }) + .from(project) + .leftJoin(zone, eq(zone.id, project.zoneId)) + .where(eq(project.eventId, eventId)); +} + +export async function listLogs(db: PanelDb, eventId: string) { + return db + .select({ + id: eventLog.id, + kind: eventLog.kind, + createdAt: eventLog.createdAt, + }) + .from(eventLog) + .where(eq(eventLog.eventId, eventId)) + .orderBy(desc(eventLog.createdAt)) + .limit(40); +} + +export async function saveConfig( + db: PanelDb, + input: { + eventId: string; + hardLimitSeconds: number; + dispatchStrategy: "coverage" | "uncertainty"; + pairwiseEnabled: boolean; + pairwiseWeight: number; + leaderboardPublic: boolean; + boardPollSeconds: number; + actorEmail: string; + }, +) { + await db + .update(eventConfig) + .set({ + hardLimitSeconds: input.hardLimitSeconds, + dispatchStrategy: input.dispatchStrategy, + pairwiseEnabled: input.pairwiseEnabled, + pairwiseWeight: input.pairwiseWeight, + leaderboardPublic: input.leaderboardPublic, + boardPollSeconds: input.boardPollSeconds, + }) + .where(eq(eventConfig.eventId, input.eventId)); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "config.saved", + actor: { email: input.actorEmail }, + subject: { eventId: input.eventId }, + payload: { + dispatchStrategy: input.dispatchStrategy, + hardLimitSeconds: input.hardLimitSeconds, + }, + }); +} diff --git a/judging/server/src/services/compare.ts b/judging/server/src/services/compare.ts new file mode 100644 index 00000000..953314ce --- /dev/null +++ b/judging/server/src/services/compare.ts @@ -0,0 +1,63 @@ +import { and, desc, eq, isNull } from "drizzle-orm"; +import { phaseAllows } from "@panel/core"; +import type { PanelDb } from "@panel/db"; +import { comparison, event, eventLog, visit } from "@panel/db"; + +/** The judge's previous table against the one they are at. */ +export async function castComparison( + db: PanelDb, + input: { + eventId: string; + judgeId: string; + outcome: "a" | "b" | "tie"; + now: Date; + }, +) { + const [evt] = await db + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + if (!phaseAllows(evt.phase, "vote")) { + throw new Error(`vote is not allowed while the event is ${evt.phase}`); + } + + const visits = await db + .select() + .from(visit) + .where( + and( + eq(visit.eventId, input.eventId), + eq(visit.judgeId, input.judgeId), + isNull(visit.voidedAt), + ), + ) + .orderBy(desc(visit.handedOutAt)); + const current = visits.find((row) => !row.completedAt); + const earlier = visits.find((row) => row.completedAt); + if (!current) throw new Error("No table is in hand"); + if (!earlier) throw new Error("Compare after a finished table"); + + const [row] = await db + .insert(comparison) + .values({ + eventId: input.eventId, + judgeId: input.judgeId, + judgeGroup: current.judgeGroup, + aProjectId: earlier.projectId, + bProjectId: current.projectId, + outcome: input.outcome, + createdAt: input.now, + }) + .returning({ id: comparison.id }); + if (!row) throw new Error("Comparison was not stored"); + + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "comparison.cast", + actor: { judgeId: input.judgeId }, + subject: { comparisonId: row.id }, + payload: { outcome: input.outcome }, + }); + return { id: row.id }; +} diff --git a/judging/server/src/services/dispatch.concurrent.test.ts b/judging/server/src/services/dispatch.concurrent.test.ts new file mode 100644 index 00000000..71eb9e52 --- /dev/null +++ b/judging/server/src/services/dispatch.concurrent.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { dispatchNext } from "./dispatch"; +import { createDb, databaseUrl, migrate } from "@panel/db"; + +const url = databaseUrl(); + +describe.skipIf(!url)("concurrent dispatch", () => { + it("does not hand two judges the same project", async () => { + const { db, pool } = createDb(url as string); + try { + await migrate(pool); + const { seedDemo, judge, project } = await import("@panel/db"); + const { eq } = await import("drizzle-orm"); + const { eventId } = await seedDemo(db); + const judges = await db.select().from(judge).where(eq(judge.eventId, eventId)); + const first = judges[0]; + const second = judges[1]; + if (!first || !second) throw new Error("demo judges missing"); + const now = new Date(); + const [a, b] = await Promise.all([ + dispatchNext(db, { eventId, judgeId: first.id, now }), + dispatchNext(db, { eventId, judgeId: second.id, now }), + ]); + expect(a.done).toBe(false); + expect(b.done).toBe(false); + if (!a.done && !b.done) expect(a.projectId).not.toBe(b.projectId); + const rows = await db.select().from(project).where(eq(project.eventId, eventId)); + expect(rows.length).toBeGreaterThan(1); + } finally { + await pool.end(); + } + }); +}); diff --git a/judging/server/src/services/dispatch.ts b/judging/server/src/services/dispatch.ts new file mode 100644 index 00000000..9210b081 --- /dev/null +++ b/judging/server/src/services/dispatch.ts @@ -0,0 +1,413 @@ +import { randomInt } from "node:crypto"; +import { and, eq, isNull, sql } from "drizzle-orm"; +import { isLive, pickNext } from "@panel/core"; +import type { TimerConfig } from "@panel/core"; +import type { PanelDb, PanelTx } from "@panel/db"; +import { + event, + eventConfig, + eventLog, + judge, + outbox, + project, + projectTrack, + track, + visit, + vote, +} from "@panel/db"; +import { isDeliveredTopic, logsForHandout } from "./logs"; + +export type DispatchOutcome = + | { done: true } + | { + done: false; + reused: boolean; + visitId: string; + projectId: string; + projectName: string; + tableNumber: number | null; + zoneId: string | null; + handedOutAt: Date; + arrivedAt: Date | null; + }; + +type VisitRow = typeof visit.$inferSelect; +type ProjectRow = typeof project.$inferSelect; + +export async function dispatchNext( + db: PanelDb, + input: { eventId: string; judgeId: string; now: Date }, +): Promise { + return db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${`dispatch:${input.eventId}`}))`, + ); + + const phase = await loadPhase(tx, input.eventId); + if (phase !== "judging_live") { + throw new Error(`dispatch is not allowed while the event is ${phase}`); + } + const config = await loadConfig(tx, input.eventId); + const judgeRow = await loadJudge(tx, input.judgeId, input.eventId); + if (judgeRow.status !== "approved") { + throw new Error("This judge is not approved"); + } + + const projects = await loadPool(tx, input.eventId, judgeRow.trackId); + const poolIds = new Set(projects.map((item) => item.id)); + const mine = await tx + .select() + .from(visit) + .where( + and(eq(visit.judgeId, input.judgeId), eq(visit.eventId, input.eventId)), + ); + + const lapsed = mine.filter( + (row) => + !row.completedAt && + !row.voidedAt && + (row.handedOutAt || row.arrivedAt) && + (!isLive(clock(row), input.now, config) || !poolIds.has(row.projectId)), + ); + if (lapsed.length > 0) { + for (const row of lapsed) { + await tx + .update(visit) + .set({ + voidedAt: input.now, + voidReason: poolIds.has(row.projectId) ? "lapsed" : "withdrawn", + }) + .where(eq(visit.id, row.id)); + } + } + + const open = mine.find( + (row) => + !lapsed.includes(row) && + !row.completedAt && + !row.voidedAt && + isLive(clock(row), input.now, config), + ); + if (open) { + const current = projects.find((item) => item.id === open.projectId); + return { + done: false as const, + reused: true, + visitId: open.id, + projectId: open.projectId, + projectName: current?.name ?? "", + tableNumber: current?.tableNumber ?? null, + zoneId: current?.zoneId ?? null, + handedOutAt: open.handedOutAt, + arrivedAt: open.arrivedAt, + }; + } + + const seen = new Set( + mine.filter((row) => !row.voidedAt).map((row) => row.projectId), + ); + for (const row of lapsed) seen.add(row.projectId); + const unseen = projects.filter((item) => !seen.has(item.id)); + const myGroup = await groupOf(tx, judgeRow.trackId); + if (unseen.length === 0) { + await record( + tx, + input, + lapsed.map((row) => row.id), + null, + ); + return { done: true as const }; + } + + const picked = await choose( + tx, + input, + unseen, + mine, + projects, + myGroup, + config, + ); + const choice = picked ? unseen.find((item) => item.id === picked.id) : undefined; + if (!choice) { + await record( + tx, + input, + lapsed.map((row) => row.id), + null, + ); + return { done: true as const }; + } + + const [created] = await tx + .insert(visit) + .values({ + eventId: input.eventId, + judgeId: input.judgeId, + projectId: choice.id, + judgeGroup: myGroup, + handedOutAt: input.now, + }) + .returning(); + if (!created) throw new Error("visit was not created"); + + await record(tx, input, lapsed.map((row) => row.id), { + visitId: created.id, + projectId: choice.id, + }); + + return { + done: false as const, + reused: false, + visitId: created.id, + projectId: choice.id, + projectName: choice.name, + tableNumber: choice.tableNumber, + zoneId: choice.zoneId, + handedOutAt: created.handedOutAt, + arrivedAt: null, + }; + }); +} + +async function record( + tx: PanelTx, + input: { eventId: string; judgeId: string }, + voidedVisitIds: readonly string[], + handout: { visitId: string; projectId: string } | null, +) { + const drafts = handout + ? logsForHandout(voidedVisitIds) + : voidedVisitIds.map((visitId) => ({ + kind: "visit.voided" as const, + subject: { visitId } as Record, + payload: {} as Record, + })); + if (handout) { + const handed = drafts[drafts.length - 1]; + if (handed) handed.subject = handout; + } + if (drafts.length === 0) return; + + await tx.insert(eventLog).values( + drafts.map((draft) => ({ + eventId: input.eventId, + kind: draft.kind, + actor: { judgeId: input.judgeId }, + subject: draft.subject, + payload: draft.payload, + })), + ); + const topics = drafts.filter((draft) => isDeliveredTopic(draft.kind)); + if (topics.length === 0) return; + await tx.insert(outbox).values( + topics.map((draft) => ({ + eventId: input.eventId, + topic: draft.kind, + payload: { ...draft.subject, judgeId: input.judgeId }, + })), + ); +} + +function clock(row: VisitRow) { + return { + handedOutAt: row.handedOutAt, + arrivedAt: row.arrivedAt, + completedAt: row.completedAt ?? row.voidedAt, + }; +} + +async function loadPhase(tx: PanelDb | PanelTx, eventId: string) { + const [row] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, eventId)); + if (!row) throw new Error("Event not found"); + return row.phase; +} + +async function loadConfig(tx: PanelTx, eventId: string): Promise { + const [row] = await tx + .select() + .from(eventConfig) + .where(eq(eventConfig.eventId, eventId)); + if (!row) throw new Error("Event config not found"); + return { + targetSeconds: row.targetSeconds, + hardLimitSeconds: row.hardLimitSeconds, + walkLimitSeconds: row.walkLimitSeconds, + submitGraceSeconds: row.submitGraceSeconds, + strategy: row.dispatchStrategy, + minLooksPerProject: row.minLooksPerProject, + }; +} + +async function loadJudge(tx: PanelDb | PanelTx, judgeId: string, eventId: string) { + const [row] = await tx + .select() + .from(judge) + .where(and(eq(judge.id, judgeId), eq(judge.eventId, eventId))); + if (!row) throw new Error("Judge not found"); + return row; +} + +async function loadPool(tx: PanelDb | PanelTx, eventId: string, trackId: string | null) { + const projects = await tx + .select() + .from(project) + .where(and(eq(project.eventId, eventId), isNull(project.withdrawnAt))); + if (!trackId) return projects; + const links = await tx + .select({ projectId: projectTrack.projectId }) + .from(projectTrack) + .where(eq(projectTrack.trackId, trackId)); + const allowed = new Set(links.map((link) => link.projectId)); + return projects.filter((item) => allowed.has(item.id)); +} + +async function groupOf(tx: PanelTx, trackId: string | null) { + if (!trackId) return "main"; + const [row] = await tx + .select({ judgeGroup: track.judgeGroup }) + .from(track) + .where(eq(track.id, trackId)); + return row?.judgeGroup ?? "main"; +} + +async function choose( + tx: PanelTx, + input: { eventId: string; judgeId: string; now: Date }, + unseen: ProjectRow[], + mine: VisitRow[], + projects: ProjectRow[], + myGroup: string, + config: TimerConfig & { + strategy: "coverage" | "uncertainty"; + minLooksPerProject: number; + }, +) { + const judges = await tx + .select({ id: judge.id, trackId: judge.trackId }) + .from(judge) + .where(eq(judge.eventId, input.eventId)); + const tracks = await tx + .select({ id: track.id, judgeGroup: track.judgeGroup }) + .from(track) + .where(eq(track.eventId, input.eventId)); + const groupByJudge = new Map( + judges.map((item) => [ + item.id, + tracks.find((entry) => entry.id === item.trackId)?.judgeGroup ?? "main", + ]), + ); + + const coverage = new Map(); + const bump = (projectId: string) => + coverage.set(projectId, (coverage.get(projectId) ?? 0) + 1); + + const votes = await tx + .select({ judgeId: vote.judgeId, projectId: vote.projectId }) + .from(vote) + .where(eq(vote.eventId, input.eventId)); + for (const row of votes) { + if (groupByJudge.get(row.judgeId) === myGroup) bump(row.projectId); + } + + const open = await tx + .select() + .from(visit) + .where(and(eq(visit.eventId, input.eventId), isNull(visit.completedAt), isNull(visit.voidedAt))); + const claimedAt = new Map(); + for (const row of open) { + if (row.judgeId === input.judgeId) continue; + if (!isLive(clock(row), input.now, config)) continue; + if (groupByJudge.get(row.judgeId) !== myGroup) continue; + bump(row.projectId); + const at = (row.arrivedAt ?? row.handedOutAt).getTime(); + claimedAt.set(row.projectId, Math.max(claimedAt.get(row.projectId) ?? 0, at)); + } + + const byId = new Map(projects.map((item) => [item.id, item])); + const last = [...mine].sort( + (a, b) => b.handedOutAt.getTime() - a.handedOutAt.getTime(), + )[0]; + const lastProject = last ? byId.get(last.projectId) : undefined; + + return pickNext( + unseen.map((item) => ({ + id: item.id, + tableNumber: item.tableNumber ?? 0, + zoneId: item.zoneId, + coverage: coverage.get(item.id) ?? 0, + claimedAt: claimedAt.get(item.id) ?? null, + })), + { + lastTable: lastProject?.tableNumber ?? null, + zoneId: lastProject?.zoneId ?? null, + }, + { + strategy: config.strategy, + minLooksPerProject: config.minLooksPerProject, + }, + (n) => randomInt(0, n), + ); +} + +/** Pass on the open table without a score. It stays seen, so this judge is not sent back. */ +export async function skipVisit( + db: PanelDb, + input: { eventId: string; judgeId: string; visitId: string; now: Date }, +) { + return db.transaction(async (tx) => { + const phase = await loadPhase(tx, input.eventId); + if (phase !== "judging_live") { + throw new Error(`skip is not allowed while the event is ${phase}`); + } + const [row] = await tx + .select() + .from(visit) + .where( + and( + eq(visit.id, input.visitId), + eq(visit.judgeId, input.judgeId), + eq(visit.eventId, input.eventId), + ), + ); + if (!row || row.voidedAt || row.completedAt) { + throw new Error("This visit cannot be skipped"); + } + await tx.update(visit).set({ completedAt: input.now }).where(eq(visit.id, row.id)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "visit.skipped", + actor: { judgeId: input.judgeId }, + subject: { visitId: row.id, projectId: row.projectId }, + payload: {}, + }); + return { projectId: row.projectId }; + }); +} + +export async function judgeProgress( + db: PanelDb, + input: { eventId: string; judgeId: string }, +) { + const person = await loadJudge(db, input.judgeId, input.eventId); + if (person.status !== "approved") throw new Error("This judge is not approved"); + const pool = await loadPool(db, input.eventId, person.trackId); + const [scored] = await db + .select({ n: sql`count(*)::int` }) + .from(vote) + .where(and(eq(vote.judgeId, input.judgeId), eq(vote.eventId, input.eventId))); + const total = pool.length; + const completed = scored?.n ?? 0; + return { + judgeId: input.judgeId, + total, + completed, + percentage: total > 0 ? Math.round((completed / total) * 100) : 0, + }; +} diff --git a/judging/server/src/services/feedback.test.ts b/judging/server/src/services/feedback.test.ts new file mode 100644 index 00000000..6f897277 --- /dev/null +++ b/judging/server/src/services/feedback.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { feedbackStatus, median } from "./feedback"; + +describe("feedbackStatus", () => { + it("refuses before publish and for the wrong token", () => { + expect(feedbackStatus({ published: false, tokenMatches: true })).toBe("forbidden"); + expect(feedbackStatus({ published: true, tokenMatches: false })).toBe("forbidden"); + }); + + it("allows the project's token after publish", () => { + expect(feedbackStatus({ published: true, tokenMatches: true })).toBe("ok"); + }); + + it("puts the event median between the middle scores", () => { + expect(median([1, 3, 9])).toBe(3); + expect(median([1, 2, 8, 9])).toBe(5); + }); +}); diff --git a/judging/server/src/services/feedback.ts b/judging/server/src/services/feedback.ts new file mode 100644 index 00000000..d12107fc --- /dev/null +++ b/judging/server/src/services/feedback.ts @@ -0,0 +1,17 @@ +export function feedbackStatus(input: { + published: boolean; + tokenMatches: boolean; +}): "ok" | "forbidden" { + if (!input.published || !input.tokenMatches) return "forbidden"; + return "ok"; +} + +export function median(values: readonly number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const upper = sorted[mid] ?? 0; + if (sorted.length % 2 === 1) return upper; + const lower = sorted[mid - 1] ?? upper; + return (lower + upper) / 2; +} diff --git a/judging/server/src/services/floor.test.ts b/judging/server/src/services/floor.test.ts new file mode 100644 index 00000000..1da5647e --- /dev/null +++ b/judging/server/src/services/floor.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_TIMERS } from "@panel/core"; +import { judgeFloorState } from "./floor"; + +const arrived = new Date("2027-02-28T13:00:00Z"); + +describe("judgeFloorState", () => { + it("marks a judge with no table as idle", () => { + expect(judgeFloorState(null, arrived, DEFAULT_TIMERS)).toEqual({ + state: "idle", + overtime: false, + }); + }); + + it("marks a judge who has not arrived as walking", () => { + expect(judgeFloorState({ arrivedAt: null }, arrived, DEFAULT_TIMERS).state).toBe("walking"); + }); + + it("marks overtime only after the target time at the table", () => { + const early = judgeFloorState({ arrivedAt: arrived }, new Date(arrived.getTime() + 1000), DEFAULT_TIMERS); + const late = judgeFloorState( + { arrivedAt: arrived }, + new Date(arrived.getTime() + DEFAULT_TIMERS.targetSeconds * 1000 + 1000), + DEFAULT_TIMERS, + ); + expect(early).toEqual({ state: "at table", overtime: false }); + expect(late).toEqual({ state: "at table", overtime: true }); + }); +}); diff --git a/judging/server/src/services/floor.ts b/judging/server/src/services/floor.ts new file mode 100644 index 00000000..0358e071 --- /dev/null +++ b/judging/server/src/services/floor.ts @@ -0,0 +1,79 @@ +import { and, eq, isNull, sql } from "drizzle-orm"; +import { isOverTarget } from "@panel/core"; +import type { TimerConfig } from "@panel/core"; +import type { PanelDb } from "@panel/db"; +import { eventConfig, judge, project, visit, vote } from "@panel/db"; + +export type FloorState = "idle" | "walking" | "at table"; + +export function judgeFloorState( + open: { arrivedAt: Date | null } | null, + now: Date, + config: TimerConfig, +): { state: FloorState; overtime: boolean } { + if (!open) return { state: "idle", overtime: false }; + if (!open.arrivedAt) return { state: "walking", overtime: false }; + return { + state: "at table", + overtime: isOverTarget(open.arrivedAt, now, config), + }; +} + +export async function organizerFloor(db: PanelDb, eventId: string, now: Date) { + const [config] = await db + .select({ + targetSeconds: eventConfig.targetSeconds, + hardLimitSeconds: eventConfig.hardLimitSeconds, + walkLimitSeconds: eventConfig.walkLimitSeconds, + submitGraceSeconds: eventConfig.submitGraceSeconds, + }) + .from(eventConfig) + .where(eq(eventConfig.eventId, eventId)); + if (!config) return null; + + const projects = await db + .select({ id: project.id, tableNumber: project.tableNumber }) + .from(project) + .where(and(eq(project.eventId, eventId), isNull(project.withdrawnAt))); + const looks = await db + .select({ projectId: vote.projectId, count: sql`count(*)::int` }) + .from(vote) + .where(eq(vote.eventId, eventId)) + .groupBy(vote.projectId); + const lookOf = new Map(looks.map((item) => [item.projectId, item.count])); + + const open = await db + .select({ + judgeId: visit.judgeId, + arrivedAt: visit.arrivedAt, + tableNumber: project.tableNumber, + }) + .from(visit) + .innerJoin(project, eq(project.id, visit.projectId)) + .where(and(eq(visit.eventId, eventId), isNull(visit.completedAt), isNull(visit.voidedAt))); + const openOf = new Map(open.map((item) => [item.judgeId, item])); + + const people = await db + .select({ id: judge.id, name: judge.name }) + .from(judge) + .where(and(eq(judge.eventId, eventId), eq(judge.status, "approved"))); + + return { + tables: projects + .map((item) => ({ + tableNumber: item.tableNumber, + looks: lookOf.get(item.id) ?? 0, + })) + .sort((a, b) => (a.tableNumber ?? 0) - (b.tableNumber ?? 0)), + judges: people.map((person) => { + const current = openOf.get(person.id) ?? null; + const state = judgeFloorState(current, now, config); + return { + name: person.name, + state: state.state, + overtime: state.overtime, + tableNumber: current?.tableNumber ?? null, + }; + }), + }; +} diff --git a/judging/server/src/services/flow.integration.test.ts b/judging/server/src/services/flow.integration.test.ts new file mode 100644 index 00000000..91f3a024 --- /dev/null +++ b/judging/server/src/services/flow.integration.test.ts @@ -0,0 +1,110 @@ +import { and, eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { + createDb, + criterion, + databaseUrl, + event, + eventLog, + migrate, + project, + rubric, + seedDemo, +} from "@panel/db"; +import { arrive } from "./arrive"; +import { dispatchNext } from "./dispatch"; +import { feedbackCard } from "./catalog"; +import { computeResults, publishResults } from "./results"; +import { castVote } from "./vote"; + +const url = databaseUrl(); + +describe.skipIf(!url)("a full visit", () => { + it("scores a table, publishes, and opens that project's feedback", async () => { + const { db, pool } = createDb(url as string); + try { + await migrate(pool); + const { eventId } = await seedDemo(db); + const { judge } = await import("@panel/db"); + const [person] = await db.select().from(judge).where(eq(judge.eventId, eventId)); + if (!person) throw new Error("demo judge missing"); + const now = new Date("2027-02-28T13:00:00Z"); + + const handed = await dispatchNext(db, { eventId, judgeId: person.id, now }); + expect(handed.done).toBe(false); + if (handed.done) return; + + await arrive(db, { + eventId, + judgeId: person.id, + tableNumber: handed.tableNumber ?? undefined, + now: new Date(now.getTime() + 30_000), + }); + + const [rub] = await db + .select({ id: rubric.id }) + .from(rubric) + .where(and(eq(rubric.eventId, eventId), eq(rubric.isDefault, true))); + if (!rub) throw new Error("demo rubric missing"); + const criteria = await db + .select() + .from(criterion) + .where(eq(criterion.rubricId, rub.id)); + + await castVote(db, { + eventId, + judgeId: person.id, + visitId: handed.visitId, + scores: criteria.map((item) => ({ criterionId: item.id, value: 8 })), + comment: null, + now: new Date(now.getTime() + 60_000), + }); + + await db.update(event).set({ phase: "judging_closed" }).where(eq(event.id, eventId)); + const run = await computeResults(db, { + eventId, + actorEmail: "organizer@demo.panel", + now: new Date(now.getTime() + 120_000), + }); + await publishResults(db, { + eventId, + runId: run.runId, + actorEmail: "organizer@demo.panel", + now: new Date(now.getTime() + 130_000), + }); + + const [scored] = await db + .select({ token: project.feedbackToken }) + .from(project) + .where(eq(project.id, handed.projectId)); + if (!scored?.token) throw new Error("feedback token was not minted"); + + const card = await feedbackCard(db, scored.token); + expect(card.status).toBe("ok"); + if (card.status === "ok") { + expect(card.criteria.length).toBeGreaterThan(0); + expect(card.criteria[0]?.mean).toBe(8); + } + expect((await feedbackCard(db, "00000000-0000-0000-0000-000000000000")).status).toBe( + "forbidden", + ); + + const kinds = [ + "visit.handed_out", + "visit.arrived", + "vote.cast", + "results.computed", + "results.published", + ] as const; + for (const kind of kinds) { + const rows = await db + .select({ id: eventLog.id }) + .from(eventLog) + .where(and(eq(eventLog.eventId, eventId), eq(eventLog.kind, kind))); + expect(rows, kind).toHaveLength(1); + } + } finally { + await pool.end(); + } + }); +}); diff --git a/judging/server/src/services/live.ts b/judging/server/src/services/live.ts new file mode 100644 index 00000000..750c7481 --- /dev/null +++ b/judging/server/src/services/live.ts @@ -0,0 +1,122 @@ +import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"; +import type { PanelDb } from "@panel/db"; +import { event, eventConfig, judge, organization, prize, project, result, resultRun, track, visit, vote } from "@panel/db"; + +export async function liveSnapshot(db: PanelDb, orgSlug: string, eventSlug: string) { + const [row] = await db + .select({ + eventId: event.id, + phase: event.phase, + name: event.name, + leaderboardPublic: eventConfig.leaderboardPublic, + pollSeconds: eventConfig.boardPollSeconds, + }) + .from(event) + .innerJoin(organization, eq(organization.id, event.orgId)) + .innerJoin(eventConfig, eq(eventConfig.eventId, event.id)) + .where(and(eq(organization.slug, orgSlug), eq(event.slug, eventSlug))); + if (!row) return null; + + const prizes = await db + .select({ + title: prize.title, + place: prize.place, + amount: prize.amount, + track: track.name, + }) + .from(prize) + .innerJoin(track, eq(track.id, prize.trackId)) + .where(eq(track.eventId, row.eventId)) + .orderBy(track.position, prize.place); + + const published = row.phase === "published"; + const showFloor = published || row.leaderboardPublic; + if (!showFloor) { + return { + eventId: row.eventId, + name: row.name, + phase: row.phase, + pollSeconds: row.pollSeconds, + projects: [], + placements: [], + prizes, + }; + } + + const projects = await db + .select({ + id: project.id, + name: project.name, + tableNumber: project.tableNumber, + }) + .from(project) + .where(and(eq(project.eventId, row.eventId), isNull(project.withdrawnAt))); + + const looks = await db + .select({ + projectId: vote.projectId, + count: sql`count(*)::int`, + }) + .from(vote) + .where(eq(vote.eventId, row.eventId)) + .groupBy(vote.projectId); + const lookOf = new Map(looks.map((item) => [item.projectId, item.count])); + + const open = await db + .select({ judgeId: visit.judgeId, projectId: visit.projectId, arrivedAt: visit.arrivedAt }) + .from(visit) + .where( + and(eq(visit.eventId, row.eventId), isNull(visit.completedAt), isNull(visit.voidedAt)), + ); + const judges = await db + .select({ id: judge.id, name: judge.name }) + .from(judge) + .where(eq(judge.eventId, row.eventId)); + const nameOf = new Map(judges.map((item) => [item.id, item.name])); + + let placements: { + projectId: string; + projectName: string; + trackId: string; + placement: number | null; + }[] = []; + if (published) { + const [run] = await db + .select({ id: resultRun.id }) + .from(resultRun) + .where(and(eq(resultRun.eventId, row.eventId), isNotNull(resultRun.publishedAt))); + if (run) { + placements = await db + .select({ + projectId: result.projectId, + projectName: project.name, + trackId: result.trackId, + placement: result.placement, + }) + .from(result) + .innerJoin(project, eq(project.id, result.projectId)) + .where(eq(result.runId, run.id)); + } + } + + return { + eventId: row.eventId, + name: row.name, + phase: row.phase, + pollSeconds: row.pollSeconds, + projects: projects.map((item) => ({ + id: item.id, + name: published ? item.name : null, + tableNumber: item.tableNumber, + looks: lookOf.get(item.id) ?? 0, + })), + floor: open.map((item) => ({ + judgeId: item.judgeId, + judgeName: nameOf.get(item.judgeId) ?? "", + projectId: item.projectId, + arrived: item.arrivedAt !== null, + })), + placements, + prizes, + }; +} diff --git a/judging/server/src/services/logs.test.ts b/judging/server/src/services/logs.test.ts new file mode 100644 index 00000000..2be074f6 --- /dev/null +++ b/judging/server/src/services/logs.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { logsForHandout } from "./logs"; + +describe("logsForHandout", () => { + it("writes exactly one row when nothing has lapsed", () => { + expect(logsForHandout([]).map((row) => row.kind)).toEqual(["visit.handed_out"]); + }); + + it("writes one void row per lapsed visit, then the hand-out", () => { + expect(logsForHandout(["a", "b"]).map((row) => row.kind)).toEqual([ + "visit.voided", + "visit.voided", + "visit.handed_out", + ]); + }); +}); diff --git a/judging/server/src/services/logs.ts b/judging/server/src/services/logs.ts new file mode 100644 index 00000000..7c288047 --- /dev/null +++ b/judging/server/src/services/logs.ts @@ -0,0 +1,28 @@ +import { isOutboxTopic } from "@panel/core"; +import type { LogKind } from "@panel/core"; + +export type LogDraft = { + kind: LogKind; + subject: Record; + payload: Record; +}; + +/** One log row per void, then one for the hand-out. A reload writes none. */ +export function logsForHandout(voidedVisitIds: readonly string[]): LogDraft[] { + return [ + ...voidedVisitIds.map((visitId) => ({ + kind: "visit.voided" as const, + subject: { visitId }, + payload: {}, + })), + { + kind: "visit.handed_out" as const, + subject: {}, + payload: {}, + }, + ]; +} + +export function isDeliveredTopic(kind: LogKind): boolean { + return isOutboxTopic(kind); +} diff --git a/judging/server/src/services/outbox.ts b/judging/server/src/services/outbox.ts new file mode 100644 index 00000000..0844039b --- /dev/null +++ b/judging/server/src/services/outbox.ts @@ -0,0 +1,52 @@ +import { createHmac } from "node:crypto"; +import { eq, isNull } from "drizzle-orm"; +import type { PanelDb } from "@panel/db"; +import { outbox, webhook } from "@panel/db"; + +/** Delivers pending outbox rows. A topic with no active webhook is delivered. */ +export async function drainOutbox(db: PanelDb, now = new Date()): Promise { + const pending = await db + .select() + .from(outbox) + .where(isNull(outbox.deliveredAt)) + .limit(50); + if (pending.length === 0) return 0; + + const hooks = await db.select().from(webhook).where(eq(webhook.isActive, true)); + let delivered = 0; + + for (const row of pending) { + const targets = hooks.filter((hook) => hook.topics?.includes(row.topic)); + let ok = true; + for (const hook of targets) { + const body = JSON.stringify(row.payload); + const signature = createHmac("sha256", hook.secret).update(body).digest("hex"); + try { + const response = await fetch(hook.url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-panel-signature": signature, + }, + body, + }); + if (!response.ok) ok = false; + } catch { + ok = false; + } + } + if (!ok) { + await db + .update(outbox) + .set({ attempts: row.attempts + 1 }) + .where(eq(outbox.id, row.id)); + continue; + } + await db + .update(outbox) + .set({ deliveredAt: now, attempts: row.attempts + 1 }) + .where(eq(outbox.id, row.id)); + delivered += 1; + } + return delivered; +} diff --git a/judging/server/src/services/overtime.test.ts b/judging/server/src/services/overtime.test.ts new file mode 100644 index 00000000..f39cfaef --- /dev/null +++ b/judging/server/src/services/overtime.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_TIMERS } from "@panel/core"; +import { visitsOverTarget } from "./overtime"; + +const arrived = new Date("2027-02-28T13:00:00Z"); + +describe("visitsOverTarget", () => { + it("includes a judge who has stayed past the target", () => { + const rows = visitsOverTarget( + [ + { + visitId: "late", + eventId: "event", + judgeId: "judge", + arrivedAt: arrived, + config: DEFAULT_TIMERS, + }, + { + visitId: "early", + eventId: "event", + judgeId: "other", + arrivedAt: new Date(arrived.getTime() + 10_000), + config: DEFAULT_TIMERS, + }, + ], + new Date(arrived.getTime() + DEFAULT_TIMERS.targetSeconds * 1000 + 1000), + ); + expect(rows.map((row) => row.visitId)).toEqual(["late"]); + }); +}); diff --git a/judging/server/src/services/overtime.ts b/judging/server/src/services/overtime.ts new file mode 100644 index 00000000..dad634b7 --- /dev/null +++ b/judging/server/src/services/overtime.ts @@ -0,0 +1,49 @@ +import { and, eq, isNotNull, isNull } from "drizzle-orm"; +import { isOverTarget } from "@panel/core"; +import type { TimerConfig } from "@panel/core"; +import type { PanelDb } from "@panel/db"; +import { eventConfig, visit } from "@panel/db"; + +export type OpenVisit = { + visitId: string; + eventId: string; + judgeId: string; + arrivedAt: Date | null; + config: TimerConfig; +}; + +/** Visits where the judge has arrived and is past the target time. */ +export function visitsOverTarget(rows: readonly OpenVisit[], now: Date): OpenVisit[] { + return rows.filter( + (row) => row.arrivedAt !== null && isOverTarget(row.arrivedAt, now, row.config), + ); +} + +export async function loadOpenVisits(db: PanelDb): Promise { + const rows = await db + .select({ + visitId: visit.id, + eventId: visit.eventId, + judgeId: visit.judgeId, + arrivedAt: visit.arrivedAt, + targetSeconds: eventConfig.targetSeconds, + hardLimitSeconds: eventConfig.hardLimitSeconds, + walkLimitSeconds: eventConfig.walkLimitSeconds, + submitGraceSeconds: eventConfig.submitGraceSeconds, + }) + .from(visit) + .innerJoin(eventConfig, eq(eventConfig.eventId, visit.eventId)) + .where(and(isNull(visit.completedAt), isNull(visit.voidedAt), isNotNull(visit.arrivedAt))); + return rows.map((row) => ({ + visitId: row.visitId, + eventId: row.eventId, + judgeId: row.judgeId, + arrivedAt: row.arrivedAt, + config: { + targetSeconds: row.targetSeconds, + hardLimitSeconds: row.hardLimitSeconds, + walkLimitSeconds: row.walkLimitSeconds, + submitGraceSeconds: row.submitGraceSeconds, + }, + })); +} diff --git a/judging/server/src/services/records.ts b/judging/server/src/services/records.ts new file mode 100644 index 00000000..39f865cd --- /dev/null +++ b/judging/server/src/services/records.ts @@ -0,0 +1,358 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, isNull } from "drizzle-orm"; +import { assignTables, phaseAllows } from "@panel/core"; +import type { PanelDb } from "@panel/db"; +import { event, eventLog, floorTable, judge, outbox, project, projectTrack, visit } from "@panel/db"; + +async function phaseOf(db: PanelDb, eventId: string) { + const [row] = await db + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, eventId)); + if (!row) throw new Error("Event not found"); + return row.phase; +} + +export async function upsertProject( + db: PanelDb, + input: { + eventId: string; + externalId: string; + name: string; + teamName: string | null; + tableNumber: number | null; + actorEmail: string; + }, +) { + const phase = await phaseOf(db, input.eventId); + if (phase === "archived" || phase === "published") { + throw new Error(`submit is not allowed while the event is ${phase}`); + } + const [existing] = await db + .select({ id: project.id }) + .from(project) + .where(and(eq(project.eventId, input.eventId), eq(project.externalId, input.externalId))); + + if (existing) { + await db + .update(project) + .set({ + name: input.name, + teamName: input.teamName, + tableNumber: input.tableNumber, + }) + .where(eq(project.id, existing.id)); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "project.upserted", + actor: { email: input.actorEmail }, + subject: { projectId: existing.id }, + payload: { externalId: input.externalId }, + }); + return { id: existing.id }; + } + + const [created] = await db + .insert(project) + .values({ + eventId: input.eventId, + externalId: input.externalId, + name: input.name, + teamName: input.teamName, + tableNumber: input.tableNumber, + }) + .returning({ id: project.id }); + if (!created) throw new Error("Project was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "project.upserted", + actor: { email: input.actorEmail }, + subject: { projectId: created.id }, + payload: { externalId: input.externalId }, + }); + return { id: created.id }; +} + +export async function withdrawProject( + db: PanelDb, + input: { eventId: string; projectId: string; now: Date; actorEmail: string }, +) { + const phase = await phaseOf(db, input.eventId); + if (!phaseAllows(phase, "submit") && phase !== "judging_live" && phase !== "submissions_closed") { + throw new Error(`submit is not allowed while the event is ${phase}`); + } + await db + .update(project) + .set({ withdrawnAt: input.now }) + .where(and(eq(project.id, input.projectId), eq(project.eventId, input.eventId))); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "project.withdrawn", + actor: { email: input.actorEmail }, + subject: { projectId: input.projectId }, + payload: {}, + }); +} + +export async function upsertJudge( + db: PanelDb, + input: { + eventId: string; + email: string; + name: string; + externalId: string | null; + actorEmail: string; + }, +) { + const phase = await phaseOf(db, input.eventId); + if (!phaseAllows(phase, "apply") && phase !== "judging_live") { + throw new Error(`apply is not allowed while the event is ${phase}`); + } + const email = input.email.toLowerCase(); + const [existing] = await db + .select({ id: judge.id }) + .from(judge) + .where(and(eq(judge.eventId, input.eventId), eq(judge.email, email))); + if (existing) { + await db + .update(judge) + .set({ name: input.name, externalId: input.externalId }) + .where(eq(judge.id, existing.id)); + return { id: existing.id }; + } + const [created] = await db + .insert(judge) + .values({ + eventId: input.eventId, + email, + name: input.name, + externalId: input.externalId, + status: "invited", + }) + .returning({ id: judge.id }); + if (!created) throw new Error("Judge was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "judge.upserted", + actor: { email: input.actorEmail }, + subject: { judgeId: created.id }, + payload: {}, + }); + return { id: created.id }; +} + +export async function applyAsJudge( + db: PanelDb, + input: { eventId: string; email: string; name: string }, +) { + const phase = await phaseOf(db, input.eventId); + if (!phaseAllows(phase, "apply")) { + throw new Error(`apply is not allowed while the event is ${phase}`); + } + const email = input.email.toLowerCase(); + const [existing] = await db + .select({ id: judge.id, status: judge.status }) + .from(judge) + .where(and(eq(judge.eventId, input.eventId), eq(judge.email, email))); + if (existing) { + if (existing.status === "invited") { + await db.update(judge).set({ status: "applied", name: input.name }).where(eq(judge.id, existing.id)); + } + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "judge.upserted", + actor: { email }, + subject: { judgeId: existing.id }, + payload: { status: existing.status === "invited" ? "applied" : existing.status }, + }); + return { id: existing.id, status: existing.status === "invited" ? "applied" : existing.status }; + } + const [created] = await db + .insert(judge) + .values({ + eventId: input.eventId, + email, + name: input.name, + status: "applied", + }) + .returning({ id: judge.id }); + if (!created) throw new Error("Judge was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "judge.upserted", + actor: { email }, + subject: { judgeId: created.id }, + payload: { status: "applied" }, + }); + return { id: created.id, status: "applied" as const }; +} + +export async function setJudgeStatus( + db: PanelDb, + input: { + eventId: string; + judgeId: string; + status: "approved" | "suspended"; + actorEmail: string; + }, +) { + await db + .update(judge) + .set({ status: input.status }) + .where(and(eq(judge.id, input.judgeId), eq(judge.eventId, input.eventId))); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: input.status === "approved" ? "judge.approved" : "judge.suspended", + actor: { email: input.actorEmail }, + subject: { judgeId: input.judgeId }, + payload: { status: input.status }, + }); +} + +export async function voidVisit( + db: PanelDb, + input: { eventId: string; visitId: string; now: Date; actorEmail: string; reason: string }, +) { + return db.transaction(async (tx) => { + const [row] = await tx + .select({ id: visit.id, judgeId: visit.judgeId }) + .from(visit) + .where(and(eq(visit.id, input.visitId), eq(visit.eventId, input.eventId), isNull(visit.voidedAt))); + if (!row) throw new Error("Visit not found"); + await tx + .update(visit) + .set({ voidedAt: input.now, voidReason: input.reason }) + .where(eq(visit.id, row.id)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "visit.voided", + actor: { email: input.actorEmail }, + subject: { visitId: row.id }, + payload: { reason: input.reason }, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "visit.voided", + payload: { visitId: row.id, judgeId: row.judgeId }, + }); + return { judgeId: row.judgeId }; + }); +} + +export async function recallJudge( + db: PanelDb, + input: { eventId: string; judgeId: string; actorEmail: string }, +) { + await db.transaction(async (tx) => { + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "judge.recalled", + actor: { email: input.actorEmail }, + subject: { judgeId: input.judgeId }, + payload: {}, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "judge.recalled", + payload: { judgeId: input.judgeId }, + }); + }); +} + +export async function assignJudge( + db: PanelDb, + input: { + eventId: string; + judgeId: string; + trackId: string | null; + zoneId: string | null; + actorEmail: string; + }, +) { + const [row] = await db + .update(judge) + .set({ trackId: input.trackId, zoneId: input.zoneId }) + .where(and(eq(judge.id, input.judgeId), eq(judge.eventId, input.eventId))) + .returning({ id: judge.id }); + if (!row) throw new Error("Judge not found"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "judge.upserted", + actor: { email: input.actorEmail }, + subject: { judgeId: input.judgeId }, + payload: { trackId: input.trackId, zoneId: input.zoneId }, + }); + return { id: row.id }; +} + +export async function assignProjectTrack( + db: PanelDb, + input: { projectId: string; trackId: string }, +) { + await db + .insert(projectTrack) + .values(input) + .onConflictDoNothing(); +} + +export async function rotateQr( + db: PanelDb, + input: { eventId: string; projectId: string; actorEmail: string }, +) { + const token = randomUUID(); + const [row] = await db + .update(project) + .set({ qrToken: token }) + .where(and(eq(project.id, input.projectId), eq(project.eventId, input.eventId))) + .returning({ qrToken: project.qrToken }); + if (!row) throw new Error("Project not found"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "project.qr_rotated", + actor: { email: input.actorEmail }, + subject: { projectId: input.projectId }, + payload: {}, + }); + return { qrToken: row.qrToken }; +} + +export async function assignEventTables( + db: PanelDb, + input: { eventId: string; actorEmail: string }, +) { + const projects = await db + .select({ id: project.id }) + .from(project) + .where(and(eq(project.eventId, input.eventId), isNull(project.withdrawnAt))); + const links = await db + .select({ projectId: projectTrack.projectId, trackId: projectTrack.trackId }) + .from(projectTrack) + .innerJoin(project, eq(project.id, projectTrack.projectId)) + .where(eq(project.eventId, input.eventId)); + const trackOf = new Map(); + for (const link of links) { + if (!trackOf.has(link.projectId)) trackOf.set(link.projectId, link.trackId); + } + const tables = await db + .select({ number: floorTable.number, zoneId: floorTable.zoneId }) + .from(floorTable) + .where(eq(floorTable.eventId, input.eventId)); + const seated = assignTables( + projects.map((row) => ({ id: row.id, trackId: trackOf.get(row.id) ?? null })), + tables.map((row) => ({ number: row.number, zoneId: row.zoneId })), + ); + for (const seat of seated) { + await db + .update(project) + .set({ tableNumber: seat.tableNumber, zoneId: seat.zoneId }) + .where(eq(project.id, seat.projectId)); + } + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "project.table_assigned", + actor: { email: input.actorEmail }, + subject: { eventId: input.eventId }, + payload: { assigned: seated.length }, + }); + return { assigned: seated.length }; +} diff --git a/judging/server/src/services/redis.integration.test.ts b/judging/server/src/services/redis.integration.test.ts new file mode 100644 index 00000000..561545b7 --- /dev/null +++ b/judging/server/src/services/redis.integration.test.ts @@ -0,0 +1,41 @@ +import { Redis } from "ioredis"; +import { describe, expect, it } from "vitest"; +import { createRedisBus } from "../bus"; + +const url = process.env.REDIS_URL; + +async function redisAnswers(address: string): Promise { + const client = new Redis(address, { + maxRetriesPerRequest: 0, + connectTimeout: 500, + lazyConnect: true, + retryStrategy: () => null, + }); + client.on("error", () => undefined); + try { + await client.connect(); + return (await client.ping()) === "PONG"; + } catch { + return false; + } finally { + client.disconnect(); + } +} + +describe.skipIf(!url)("redis bus", () => { + it("delivers a message from one connection to another", async (ctx) => { + if (!(await redisAnswers(url as string))) ctx.skip(); + const left = createRedisBus(url as string); + const right = createRedisBus(url as string); + const seen = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("no redis message")), 4000); + right.subscribe("event:test:board", (message) => { + clearTimeout(timer); + resolve(message); + }); + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + left.publish("event:test:board", { kind: "vote.cast" }); + await expect(seen).resolves.toEqual({ kind: "vote.cast" }); + }); +}); diff --git a/judging/server/src/services/results.test.ts b/judging/server/src/services/results.test.ts new file mode 100644 index 00000000..552c88d4 --- /dev/null +++ b/judging/server/src/services/results.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { diffPlacements } from "./results"; + +const quiet = { rubricComponent: 8, pairwiseComponent: 0 }; + +describe("diffPlacements", () => { + it("reports a place move with both blend components", () => { + const left = [ + { projectId: "a", trackId: "t", placement: 1, score: 9, ...quiet }, + { projectId: "b", trackId: "t", placement: 2, score: 8, ...quiet }, + ]; + const right = [ + { projectId: "a", trackId: "t", placement: 2, score: 7, rubricComponent: 7, pairwiseComponent: 1 }, + { projectId: "b", trackId: "t", placement: 1, score: 9, ...quiet }, + ]; + expect(diffPlacements(left, right)).toEqual([ + { + projectId: "a", + trackId: "t", + from: 1, + to: 2, + score: 7, + rubricFrom: 8, + rubricTo: 7, + pairwiseFrom: 0, + pairwiseTo: 1, + }, + { + projectId: "b", + trackId: "t", + from: 2, + to: 1, + score: 9, + rubricFrom: 8, + rubricTo: 8, + pairwiseFrom: 0, + pairwiseTo: 0, + }, + ]); + }); + + it("reports a pairwise change when the place stays put", () => { + const left = [{ projectId: "a", trackId: "t", placement: 1, score: 8, ...quiet }]; + const right = [ + { projectId: "a", trackId: "t", placement: 1, score: 8.4, rubricComponent: 8, pairwiseComponent: 2 }, + ]; + expect(diffPlacements(left, right)).toEqual([ + { + projectId: "a", + trackId: "t", + from: 1, + to: 1, + score: 8.4, + rubricFrom: 8, + rubricTo: 8, + pairwiseFrom: 0, + pairwiseTo: 2, + }, + ]); + }); +}); diff --git a/judging/server/src/services/results.ts b/judging/server/src/services/results.ts new file mode 100644 index 00000000..486c5d32 --- /dev/null +++ b/judging/server/src/services/results.ts @@ -0,0 +1,328 @@ +import { and, desc, eq, isNotNull, isNull, sql } from "drizzle-orm"; +import { phaseAllows, rank } from "@panel/core"; +import type { PanelDb } from "@panel/db"; +import { + comparison, + event, + eventConfig, + eventLog, + judge, + outbox, + project, + projectTrack, + result, + resultRun, + track, + vote, +} from "@panel/db"; + +export type Placement = { + projectId: string; + trackId: string; + placement: number | null; + score: number; + rubricComponent: number; + pairwiseComponent: number; +}; + +/** Projects whose place or blend components changed between two runs. */ +export function diffPlacements(left: readonly Placement[], right: readonly Placement[]) { + const later = new Map(right.map((row) => [`${row.trackId}:${row.projectId}`, row])); + return left.flatMap((row) => { + const other = later.get(`${row.trackId}:${row.projectId}`); + const to = other?.placement ?? null; + const score = other?.score ?? row.score; + const rubricTo = other?.rubricComponent ?? row.rubricComponent; + const pairwiseTo = other?.pairwiseComponent ?? row.pairwiseComponent; + if ( + row.placement === to && + row.score === score && + row.rubricComponent === rubricTo && + row.pairwiseComponent === pairwiseTo + ) { + return []; + } + return [ + { + projectId: row.projectId, + trackId: row.trackId, + from: row.placement, + to, + score, + rubricFrom: row.rubricComponent, + rubricTo, + pairwiseFrom: row.pairwiseComponent, + pairwiseTo, + }, + ]; + }); +} + +export async function computeResults( + db: PanelDb, + input: { eventId: string; actorEmail: string; now: Date }, +) { + return db.transaction(async (tx) => { + const [evt] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + const [config] = await tx + .select() + .from(eventConfig) + .where(eq(eventConfig.eventId, input.eventId)); + if (!evt || !config) throw new Error("Event not found"); + if (!phaseAllows(evt.phase, "compute")) { + throw new Error(`compute is not allowed while the event is ${evt.phase}`); + } + + const projects = await tx + .select() + .from(project) + .where(and(eq(project.eventId, input.eventId), isNull(project.withdrawnAt))); + const links = await tx + .select({ projectId: projectTrack.projectId, trackId: projectTrack.trackId }) + .from(projectTrack) + .innerJoin(project, eq(project.id, projectTrack.projectId)) + .where(eq(project.eventId, input.eventId)); + const tracks = await tx + .select({ id: track.id, judgeGroup: track.judgeGroup }) + .from(track) + .where(eq(track.eventId, input.eventId)); + const judges = await tx + .select({ id: judge.id, trackId: judge.trackId }) + .from(judge) + .where(eq(judge.eventId, input.eventId)); + const groupOf = new Map( + judges.map((row) => [ + row.id, + tracks.find((item) => item.id === row.trackId)?.judgeGroup ?? "main", + ]), + ); + const votes = await tx + .select() + .from(vote) + .where(eq(vote.eventId, input.eventId)); + const comparisons = await tx + .select() + .from(comparison) + .where(eq(comparison.eventId, input.eventId)); + + const byProject = new Map(); + for (const link of links) { + const list = byProject.get(link.projectId) ?? []; + list.push(link.trackId); + byProject.set(link.projectId, list); + } + + const ranked = rank( + projects.map((row) => ({ + id: row.id, + trackIds: byProject.get(row.id) ?? [], + })), + votes.map((row) => ({ + judgeId: row.judgeId, + projectId: row.projectId, + judgeGroup: groupOf.get(row.judgeId) ?? "main", + total: row.total, + isCalibration: row.isCalibration, + })), + comparisons.map((row) => ({ + judgeId: row.judgeId, + judgeGroup: row.judgeGroup, + a: row.aProjectId, + b: row.bProjectId, + outcome: row.outcome, + })), + { + bayesianC: config.bayesianC, + pairwiseWeight: config.pairwiseEnabled ? config.pairwiseWeight : 0, + calibrationWeight: config.calibrationWeight, + }, + ); + + const [run] = await tx + .insert(resultRun) + .values({ + eventId: input.eventId, + computedAt: input.now, + configSnapshot: { + bayesianC: config.bayesianC, + pairwiseWeight: config.pairwiseWeight, + pairwiseEnabled: config.pairwiseEnabled, + calibrationWeight: config.calibrationWeight, + }, + }) + .returning({ id: resultRun.id }); + if (!run) throw new Error("Result run was not stored"); + + const placed = ranked.filter((row) => row.placement !== null); + if (placed.length > 0) { + await tx.insert(result).values( + placed.map((row) => ({ + runId: run.id, + projectId: row.projectId, + trackId: row.trackId, + placement: row.placement, + score: row.score, + rubricComponent: row.rubricComponent, + pairwiseComponent: row.pairwiseComponent, + voteCount: row.voteCount, + comparisonCount: row.comparisonCount, + flags: [], + })), + ); + } + + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "results.computed", + actor: { email: input.actorEmail }, + subject: { runId: run.id }, + payload: { rows: placed.length }, + }); + + return { runId: run.id, rows: placed.length }; + }); +} + +export async function publishResults( + db: PanelDb, + input: { eventId: string; runId: string; actorEmail: string; now: Date }, +) { + return db.transaction(async (tx) => { + const [evt] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + if (!phaseAllows(evt.phase, "publish")) { + throw new Error(`publish is not allowed while the event is ${evt.phase}`); + } + const [run] = await tx + .select({ id: resultRun.id }) + .from(resultRun) + .where(and(eq(resultRun.id, input.runId), eq(resultRun.eventId, input.eventId))); + if (!run) throw new Error("Result run not found"); + + await tx + .update(resultRun) + .set({ publishedAt: input.now }) + .where(eq(resultRun.id, run.id)); + await tx.update(event).set({ phase: "published" }).where(eq(event.id, input.eventId)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "results.published", + actor: { email: input.actorEmail }, + subject: { runId: run.id }, + payload: { from: evt.phase, to: "published" }, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "results.published", + payload: { runId: run.id }, + }); + await tx.execute(sql` + update project + set feedback_token = gen_random_uuid() + where event_id = ${input.eventId} and feedback_token is null + `); + return { runId: run.id }; + }); +} + +export async function unpublishResults( + db: PanelDb, + input: { eventId: string; actorEmail: string }, +) { + return db.transaction(async (tx) => { + const [evt] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + if (evt.phase !== "published") { + throw new Error(`unpublish is not allowed while the event is ${evt.phase}`); + } + const [run] = await tx + .select({ id: resultRun.id }) + .from(resultRun) + .where(and(eq(resultRun.eventId, input.eventId), isNotNull(resultRun.publishedAt))) + .orderBy(desc(resultRun.publishedAt)) + .limit(1); + if (!run) throw new Error("Nothing is published"); + await tx.update(resultRun).set({ publishedAt: null }).where(eq(resultRun.id, run.id)); + await tx.update(event).set({ phase: "judging_closed" }).where(eq(event.id, input.eventId)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "results.unpublished", + actor: { email: input.actorEmail }, + subject: { runId: run.id }, + payload: { from: "published", to: "judging_closed" }, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "results.unpublished", + payload: { runId: run.id }, + }); + return { runId: run.id }; + }); +} + +export async function placementsForRun(db: PanelDb, runId: string): Promise { + const rows = await db + .select({ + projectId: result.projectId, + trackId: result.trackId, + placement: result.placement, + score: result.score, + rubricComponent: result.rubricComponent, + pairwiseComponent: result.pairwiseComponent, + }) + .from(result) + .where(eq(result.runId, runId)) + .orderBy(desc(result.score)); + return rows; +} + +export async function inspectRun(db: PanelDb, runId: string) { + return db + .select({ + projectId: result.projectId, + projectName: project.name, + trackId: result.trackId, + placement: result.placement, + score: result.score, + rubricComponent: result.rubricComponent, + pairwiseComponent: result.pairwiseComponent, + voteCount: result.voteCount, + comparisonCount: result.comparisonCount, + }) + .from(result) + .innerJoin(project, eq(project.id, result.projectId)) + .where(eq(result.runId, runId)) + .orderBy(desc(result.score)); +} + +export async function publishedPlacements(db: PanelDb, eventId: string) { + const [evt] = await db + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, eventId)); + if (!evt) return null; + if (evt.phase !== "published") return { published: false as const, rows: [] }; + const rows = await db + .select({ + externalId: project.externalId, + placement: result.placement, + score: result.score, + track: track.slug, + voteCount: result.voteCount, + }) + .from(result) + .innerJoin(resultRun, eq(resultRun.id, result.runId)) + .innerJoin(project, eq(project.id, result.projectId)) + .innerJoin(track, eq(track.id, result.trackId)) + .where(and(eq(resultRun.eventId, eventId), isNotNull(resultRun.publishedAt))); + return { published: true as const, rows }; +} diff --git a/judging/server/src/services/vote.test.ts b/judging/server/src/services/vote.test.ts new file mode 100644 index 00000000..739f5334 --- /dev/null +++ b/judging/server/src/services/vote.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_TIMERS } from "@panel/core"; +import { assessVote } from "./vote"; + +const criteria = [{ id: "creativity", min: 0, max: 10, weight: 1 }]; +const arrived = new Date("2027-02-28T13:00:00Z"); +const at = (seconds: number) => new Date(arrived.getTime() + seconds * 1000); + +describe("assessVote", () => { + it("refuses a vote outside judging_live", () => { + const decision = assessVote({ + phase: "setup", + arrivedAt: arrived, + now: at(10), + config: DEFAULT_TIMERS, + criteria, + scores: [{ criterionId: "creativity", value: 5 }], + }); + expect(decision.ok).toBe(false); + if (!decision.ok) expect(decision.message).toMatch(/setup/); + }); + + it("names the configured hard limit when the score is late", () => { + const config = { ...DEFAULT_TIMERS, hardLimitSeconds: 90, submitGraceSeconds: 0 }; + const decision = assessVote({ + phase: "judging_live", + arrivedAt: arrived, + now: at(91), + config, + criteria, + scores: [{ criterionId: "creativity", value: 5 }], + }); + expect(decision.ok).toBe(false); + if (!decision.ok) expect(decision.message).toContain("90s"); + }); + + it("accepts a score inside the window and derives the total", () => { + const decision = assessVote({ + phase: "judging_live", + arrivedAt: arrived, + now: at(10), + config: DEFAULT_TIMERS, + criteria, + scores: [{ criterionId: "creativity", value: 8 }], + }); + expect(decision).toEqual({ ok: true, total: 8 }); + }); +}); diff --git a/judging/server/src/services/vote.ts b/judging/server/src/services/vote.ts new file mode 100644 index 00000000..3d0dfd89 --- /dev/null +++ b/judging/server/src/services/vote.ts @@ -0,0 +1,175 @@ +import { and, eq } from "drizzle-orm"; +import { isPastCutoff, phaseAllows, validateScores, weightedTotal } from "@panel/core"; +import type { Criterion, Phase, ScoreInput, TimerConfig } from "@panel/core"; +import type { PanelDb } from "@panel/db"; +import { + criterion, + event, + eventConfig, + eventLog, + outbox, + rubric, + track, + visit, + vote, + voteScore, +} from "@panel/db"; + +export type VoteDecision = + | { ok: true; total: number } + | { ok: false; message: string }; + +/** + * Whether this score can be stored. The cutoff message names the configured + * hard limit so the phone can show the number the event actually used. + */ +export function assessVote(input: { + phase: Phase; + arrivedAt: Date | null; + now: Date; + config: TimerConfig; + criteria: readonly Criterion[]; + scores: readonly ScoreInput[]; +}): VoteDecision { + if (!phaseAllows(input.phase, "vote")) { + return { + ok: false, + message: `vote is not allowed while the event is ${input.phase}`, + }; + } + if (!input.arrivedAt) { + return { ok: false, message: "Arrive at the table before scoring" }; + } + if (isPastCutoff(input.arrivedAt, input.now, input.config)) { + return { + ok: false, + message: `Vote is past the ${input.config.hardLimitSeconds}s hard limit`, + }; + } + const check = validateScores(input.criteria, input.scores); + if (!check.ok) return { ok: false, message: check.reason }; + return { ok: true, total: weightedTotal(input.criteria, input.scores) }; +} + +export async function castVote( + db: PanelDb, + input: { + eventId: string; + judgeId: string; + visitId: string; + scores: readonly ScoreInput[]; + comment: string | null; + now: Date; + }, +): Promise<{ total: number }> { + return db.transaction(async (tx) => { + const [evt] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + const [config] = await tx + .select() + .from(eventConfig) + .where(eq(eventConfig.eventId, input.eventId)); + const [row] = await tx + .select() + .from(visit) + .where(and(eq(visit.id, input.visitId), eq(visit.judgeId, input.judgeId))); + if (!evt || !config || !row) throw new Error("Visit not found"); + if (row.eventId !== input.eventId) throw new Error("Visit not found"); + if (row.voidedAt || row.completedAt) throw new Error("This visit is closed"); + + const criteria = await loadCriteria(tx, input.eventId, row.judgeGroup); + const decision = assessVote({ + phase: evt.phase, + arrivedAt: row.arrivedAt, + now: input.now, + config: { + targetSeconds: config.targetSeconds, + hardLimitSeconds: config.hardLimitSeconds, + walkLimitSeconds: config.walkLimitSeconds, + submitGraceSeconds: config.submitGraceSeconds, + }, + criteria, + scores: input.scores, + }); + if (!decision.ok) throw new Error(decision.message); + + const duration = + row.arrivedAt === null + ? null + : Math.round((input.now.getTime() - row.arrivedAt.getTime()) / 1000); + + const [stored] = await tx + .insert(vote) + .values({ + visitId: row.id, + judgeId: input.judgeId, + projectId: row.projectId, + eventId: input.eventId, + total: decision.total, + comment: input.comment, + durationSeconds: duration, + isCalibration: false, + }) + .returning({ id: vote.id }); + if (!stored) throw new Error("Vote was not stored"); + + if (input.scores.length > 0) { + await tx.insert(voteScore).values( + input.scores.map((score) => ({ + voteId: stored.id, + criterionId: score.criterionId, + value: score.value, + })), + ); + } + await tx + .update(visit) + .set({ completedAt: input.now }) + .where(eq(visit.id, row.id)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "vote.cast", + actor: { judgeId: input.judgeId }, + subject: { visitId: row.id, projectId: row.projectId }, + payload: { total: decision.total }, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "vote.cast", + payload: { visitId: row.id, judgeId: input.judgeId, total: decision.total }, + }); + return { total: decision.total }; + }); +} + +async function loadCriteria( + tx: Parameters[0]>[0], + eventId: string, + judgeGroup: string, +): Promise { + const [groupTrack] = await tx + .select({ rubricId: track.rubricId }) + .from(track) + .where(and(eq(track.eventId, eventId), eq(track.judgeGroup, judgeGroup))); + const rubricId = groupTrack?.rubricId + ? groupTrack.rubricId + : ( + await tx + .select({ id: rubric.id }) + .from(rubric) + .where(and(eq(rubric.eventId, eventId), eq(rubric.isDefault, true))) + )[0]?.id; + if (!rubricId) return []; + const rows = await tx + .select() + .from(criterion) + .where(eq(criterion.rubricId, rubricId)); + return rows.map((row) => ({ + id: row.id, + min: row.min, + max: row.max, + weight: row.weight, + })); +} diff --git a/judging/server/tsconfig.json b/judging/server/tsconfig.json new file mode 100644 index 00000000..f54bde2c --- /dev/null +++ b/judging/server/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tooling/typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/judging/server/vitest.config.ts b/judging/server/vitest.config.ts new file mode 100644 index 00000000..f62398d7 --- /dev/null +++ b/judging/server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + fileParallelism: false, + }, +}); diff --git a/judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx b/judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx new file mode 100644 index 00000000..1e88cc3d --- /dev/null +++ b/judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { apiBase } from "../../../../lib/api"; + +type Snapshot = { + eventId: string; + phase: string; + pollSeconds: number; + projects: { id: string; tableNumber: number | null; looks: number }[]; + placements: { projectId: string; projectName?: string; placement: number | null }[]; + prizes: { title: string; place: number; track: string; amount: number | null }[]; +}; + +export function LiveBoard({ orgSlug, eventSlug }: { orgSlug: string; eventSlug: string }) { + const [snapshot, setSnapshot] = useState(null); + const [live, setLive] = useState(false); + + useEffect(() => { + let stop = false; + let timer: ReturnType | undefined; + let socket: WebSocket | undefined; + + const pull = async () => { + const response = await fetch(`${apiBase}/v1/live/${orgSlug}/${eventSlug}`); + if (!response.ok || stop) return; + const next = (await response.json()) as Snapshot; + setSnapshot(next); + if (!socket) { + socket = new WebSocket( + `${apiBase.replace(/^http/, "ws")}/ws?channel=event:${next.eventId}:board`, + ); + socket.onopen = () => setLive(true); + socket.onclose = () => { + setLive(false); + socket = undefined; + }; + socket.onmessage = () => { + void pull(); + }; + } + clearTimeout(timer); + timer = setTimeout(() => void pull(), (next.pollSeconds || 5) * 1000); + }; + + void pull(); + return () => { + stop = true; + clearTimeout(timer); + socket?.close(); + }; + }, [orgSlug, eventSlug]); + + if (!snapshot) return

Loading the floor.

; + return ( +
+

+ {live ? "Live" : "Polling"} · {snapshot.phase} +

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

Prizes

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

{card.name}

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

{comment}

+ ))} +
+ ); +} diff --git a/judging/web/app/e/[orgSlug]/[eventSlug]/page.tsx b/judging/web/app/e/[orgSlug]/[eventSlug]/page.tsx new file mode 100644 index 00000000..3761eb51 --- /dev/null +++ b/judging/web/app/e/[orgSlug]/[eventSlug]/page.tsx @@ -0,0 +1,20 @@ +import { LiveBoard } from "./board"; +import { loadEvent } from "../../../../lib/api"; + +export default async function PublicBoard({ + params, +}: { + params: Promise<{ orgSlug: string; eventSlug: string }>; +}) { + const { orgSlug, eventSlug } = await params; + const event = await loadEvent(orgSlug, eventSlug); + if (!event) return
This event is not available.
; + return ( +
+

{event.orgName}

+

{event.name}

+

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

+ +
+ ); +} diff --git a/judging/web/app/j/[orgSlug]/[eventSlug]/desk.tsx b/judging/web/app/j/[orgSlug]/[eventSlug]/desk.tsx new file mode 100644 index 00000000..95cc9083 --- /dev/null +++ b/judging/web/app/j/[orgSlug]/[eventSlug]/desk.tsx @@ -0,0 +1,474 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { CSSProperties } from "react"; +import { apiBase } from "../../../../lib/api"; +import type { PublicEvent } from "../../../../lib/api"; +import { flushHeld, holdVote } from "../../../../lib/held"; + +type Visit = { + done: boolean; + reused?: boolean; + visitId?: string; + projectName?: string; + tableNumber?: number | null; + arrivedAt?: string | null; + handedOutAt?: string; +}; + +type Anchor = { label?: string; value?: number }; +type CriterionRow = { id: string; label: string; min: number; max: number; anchors?: Anchor[] }; + +export function JudgeDesk({ + orgSlug, + event, + ticket, +}: { + orgSlug: string; + event: PublicEvent; + ticket?: string; +}) { + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [token, setToken] = useState(""); + const [visit, setVisit] = useState(null); + const [criteria, setCriteria] = useState([]); + const [scores, setScores] = useState>({}); + const [tableNumber, setTableNumber] = useState(""); + const [message, setMessage] = useState(""); + const [progress, setProgress] = useState(""); + const [judgeId, setJudgeId] = useState(""); + const [elapsed, setElapsed] = useState(""); + const [scanning, setScanning] = useState(false); + const videoRef = useRef(null); + const accent = event.branding.colors?.accent ?? "#1c1917"; + + useEffect(() => { + if (!ticket) return; + setToken(ticket); + void fetch(`${apiBase}/v1/session/rubric?eventId=${event.eventId}`, { + headers: { authorization: `Bearer ${ticket}` }, + }) + .then((response) => response.json()) + .then(async (rows: CriterionRow[]) => { + if (!Array.isArray(rows)) { + setMessage("The handoff ticket was not accepted."); + return; + } + setCriteria(rows); + const progressResponse = await fetch( + `${apiBase}/v1/session/progress?eventId=${event.eventId}`, + { headers: { authorization: `Bearer ${ticket}` } }, + ); + if (!progressResponse.ok) return; + const row = (await progressResponse.json()) as { + completed: number; + total: number; + judgeId?: string; + }; + setProgress(`${row.completed} of ${row.total} scored`); + if (row.judgeId) setJudgeId(row.judgeId); + }) + .catch(() => setMessage("The handoff ticket was not accepted.")); + }, [ticket, event.eventId]); + + async function call(path: string, body?: unknown) { + const response = await fetch(`${apiBase}${path}`, { + method: body ? "POST" : "GET", + headers: { + "content-type": "application/json", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const payload = (await response.json()) as { message?: string }; + if (!response.ok) throw new Error(payload.message ?? "Request failed"); + return payload; + } + + async function compare(outcome: "a" | "b" | "tie") { + try { + await call("/v1/session/compare", { eventId: event.eventId, outcome }); + setMessage( + outcome === "b" + ? "This table is better." + : outcome === "a" + ? "The previous table was better." + : "Recorded as about the same.", + ); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Could not compare"); + } + } + + useEffect(() => { + if (!token) return; + const send = () => { + void flushHeld((path, payload) => + fetch(`${apiBase}${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }).then((response) => { + if (!response.ok) throw new Error("still offline"); + }), + ).then((sent) => { + if (sent > 0) { + setMessage(`Sent ${sent} score${sent === 1 ? "" : "s"} held on this phone.`); + } + }); + }; + window.addEventListener("online", send); + return () => window.removeEventListener("online", send); + }, [token]); + + useEffect(() => { + if (!visit?.handedOutAt || visit.arrivedAt || visit.done) { + setElapsed(""); + return; + } + const started = new Date(visit.handedOutAt).getTime(); + const tick = () => { + const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000)); + setElapsed(`${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} walking`); + }; + tick(); + const timer = setInterval(tick, 1000); + return () => clearInterval(timer); + }, [visit?.handedOutAt, visit?.arrivedAt, visit?.done]); + + useEffect(() => { + if (!token || !judgeId) return; + const socket = new WebSocket( + `${apiBase.replace(/^http/, "ws")}/ws?channel=judge:${judgeId}`, + ); + socket.onmessage = (incoming) => { + let payload: { kind?: string }; + try { + payload = JSON.parse(String(incoming.data)) as { kind?: string }; + } catch { + return; + } + if (payload.kind === "judge.recalled") { + setVisit(null); + setMessage("Come back to the desk."); + } + if (payload.kind === "visit.voided") { + setVisit(null); + setMessage("This visit was voided."); + } + if (payload.kind === "judge.overtime") { + setMessage("Past the target time at this table."); + } + }; + return () => socket.close(); + }, [token, judgeId]); + + async function loadProgress(bearer: string) { + const response = await fetch( + `${apiBase}/v1/session/progress?eventId=${event.eventId}`, + { headers: { authorization: `Bearer ${bearer}` } }, + ); + if (!response.ok) return; + const row = (await response.json()) as { completed: number; total: number; judgeId?: string }; + setProgress(`${row.completed} of ${row.total} scored`); + if (row.judgeId) setJudgeId(row.judgeId); + } + + return ( +
+

{event.orgName}

+

{event.name}

+

{event.branding.tagline}

+

Phase: {event.phase}

+ + {!token ? ( +
{ + submitEvent.preventDefault(); + try { + if (!code) { + const issued = (await call("/v1/auth/magic-link", { email })) as { code?: string }; + setCode(issued.code ?? ""); + setMessage(issued.code ? "Code filled in for this demo." : "Check your email."); + return; + } + const verified = (await call("/v1/auth/verify", { email, code, org: orgSlug })) as { + token: string; + }; + setToken(verified.token); + const rows = (await fetch( + `${apiBase}/v1/session/rubric?eventId=${event.eventId}`, + { headers: { authorization: `Bearer ${verified.token}` } }, + ).then((response) => response.json())) as CriterionRow[]; + setCriteria(rows); + await loadProgress(verified.token); + const sent = await flushHeld((path, payload) => + fetch(`${apiBase}${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${verified.token}`, + }, + body: JSON.stringify(payload), + }).then((response) => { + if (!response.ok) throw new Error("still offline"); + }), + ); + setMessage(sent > 0 ? `Sent ${sent} score${sent === 1 ? "" : "s"} held on this phone.` : ""); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Sign-in failed"); + } + }} + > + + + +
+ ) : ( +
+ + + {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/judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx b/judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx new file mode 100644 index 00000000..f1db4caa --- /dev/null +++ b/judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx @@ -0,0 +1,16 @@ +import { JudgeDesk } from "./desk"; +import { loadEvent } from "../../../../lib/api"; + +export default async function JudgePage({ + params, + searchParams, +}: { + params: Promise<{ orgSlug: string; eventSlug: string }>; + searchParams: Promise<{ ticket?: string }>; +}) { + const { orgSlug, eventSlug } = await params; + const { ticket } = await searchParams; + const event = await loadEvent(orgSlug, eventSlug); + if (!event) return
This event is not available.
; + return ; +} diff --git a/judging/web/app/layout.tsx b/judging/web/app/layout.tsx new file mode 100644 index 00000000..0737340c --- /dev/null +++ b/judging/web/app/layout.tsx @@ -0,0 +1,30 @@ +import type { Metadata, Viewport } from "next"; + +export const metadata: Metadata = { + title: "Panel", + description: "Hackathon judging", + manifest: "/manifest.webmanifest", +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + themeColor: "#111111", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx b/judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx new file mode 100644 index 00000000..cda7ce18 --- /dev/null +++ b/judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx @@ -0,0 +1,408 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { apiBase } from "../../../../lib/api"; +import type { PublicEvent } from "../../../../lib/api"; +import { Editors } from "./editors"; + +const phases = [ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", +] as const; + +export function Console({ event, ticket }: { event: PublicEvent; ticket?: string }) { + const [token, setToken] = useState(ticket ?? ""); + const [message, setMessage] = useState(event.phase); + const [floor, setFloor] = useState<{ + tables: { tableNumber: number | null; looks: number }[]; + judges: { name: string; state: string; overtime: boolean; tableNumber: number | null }[]; + } | null>(null); + const [cards, setCards] = useState< + { id: string; name: string; tableNumber: number | null; zoneName: string | null }[] + >([]); + const [logs, setLogs] = useState<{ id: string; kind: string }[]>([]); + const [runId, setRunId] = useState(""); + const [inspected, setInspected] = useState< + { + projectId: string; + projectName: string; + trackId: string; + placement: number | null; + score: number; + rubricComponent: number; + pairwiseComponent: number; + }[] + >([]); + const [trackName, setTrackName] = useState(""); + const [judgeId, setJudgeId] = useState(""); + + async function post(path: string, body: unknown) { + const response = await fetch(`${apiBase}/trpc/${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + const payload = (await response.json()) as { + error?: { message?: string }; + result?: { data?: { runId?: string; assigned?: number } }; + }; + if (!response.ok || payload.error) { + throw new Error(payload.error?.message ?? "Request failed"); + } + return payload.result?.data; + } + + useEffect(() => { + if (!token) return; + let stop = false; + const pull = async () => { + const response = await fetch(`${apiBase}/v1/floor/${event.eventId}`, { + headers: { authorization: `Bearer ${token}` }, + }); + if (!response.ok || stop) return; + setFloor( + (await response.json()) as { + tables: { tableNumber: number | null; looks: number }[]; + judges: { name: string; state: string; overtime: boolean; tableNumber: number | null }[]; + }, + ); + }; + void pull(); + const timer = setInterval(() => void pull(), 5000); + return () => { + stop = true; + clearInterval(timer); + }; + }, [token, event.eventId]); + + return ( +
+

{event.name}

+

Current phase: {message}

+ {floor ? ( +
+

Floor

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

Rubric

+ + + + +

Zone and floor

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

Prize

+ + + + +

Judges

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

Withdraw a project

+ + +
+ ); +} + +const field: CSSProperties = { display: "block", width: "100%", margin: "0.25rem 0 0.75rem" }; + +const floor: CSSProperties = { + position: "relative", + height: 420, + margin: "1rem 0", + background: "#f5f5f4", + border: "1px solid #d6d3d1", +}; + +const chip: CSSProperties = { + position: "absolute", + width: 64, + height: 48, + background: "white", + border: "1px solid #1c1917", +}; diff --git a/judging/web/app/o/[orgSlug]/[eventSlug]/page.tsx b/judging/web/app/o/[orgSlug]/[eventSlug]/page.tsx new file mode 100644 index 00000000..df8e1ec7 --- /dev/null +++ b/judging/web/app/o/[orgSlug]/[eventSlug]/page.tsx @@ -0,0 +1,16 @@ +import { Console } from "./console"; +import { loadEvent } from "../../../../lib/api"; + +export default async function OrganizerPage({ + params, + searchParams, +}: { + params: Promise<{ orgSlug: string; eventSlug: string }>; + searchParams: Promise<{ ticket?: string }>; +}) { + const { orgSlug, eventSlug } = await params; + const { ticket } = await searchParams; + const event = await loadEvent(orgSlug, eventSlug); + if (!event) return
This event is not available.
; + return ; +} diff --git a/judging/web/app/page.tsx b/judging/web/app/page.tsx new file mode 100644 index 00000000..66db153a --- /dev/null +++ b/judging/web/app/page.tsx @@ -0,0 +1,8 @@ +export default function HomePage() { + return ( +
+

Panel

+

Judging for one event. Open a judge desk, the organizer console, or the public board.

+
+ ); +} diff --git a/judging/web/e2e/visit.spec.ts b/judging/web/e2e/visit.spec.ts new file mode 100644 index 00000000..f15aaee8 --- /dev/null +++ b/judging/web/e2e/visit.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from "@playwright/test"; + +test.use({ viewport: { width: 390, height: 844 } }); + +test("a phone visit holds one offline score, then the board shows the published order", async ({ + page, + request, +}) => { + await page.goto("/j/demo/demo"); + await page.getByLabel("Email").fill("judge3@demo.panel"); + await page.getByRole("button", { name: "Email me a code" }).click(); + await page.getByRole("button", { name: "Sign in" }).click(); + await page.getByRole("button", { name: "Next table" }).click(); + const heading = page.getByRole("heading", { level: 2 }); + await expect(heading).toBeVisible(); + const table = (await heading.innerText()).match(/table (\d+)/)?.[1]; + expect(table).toBeTruthy(); + await page.getByLabel("Table number").fill(table ?? ""); + await page.getByRole("button", { name: "I'm here" }).click(); + await expect(page.getByText("Arrived.")).toBeVisible(); + for (const name of ["Creativity", "Impact", "Scope", "Clarity", "Soundness"]) { + await page.getByRole("spinbutton", { name }).fill("6"); + } + + await page.context().setOffline(true); + await page.getByRole("button", { name: "Submit score" }).click(); + await expect(page.getByText("Held on this phone")).toBeVisible(); + await page.context().setOffline(false); + await page.evaluate(() => window.dispatchEvent(new Event("online"))); + await expect(page.getByText("Sent 1 score")).toBeVisible(); + + const issued = await request.post("http://localhost:8787/v1/auth/magic-link", { + data: { email: "organizer@demo.panel" }, + }); + const code = ((await issued.json()) as { code?: string }).code; + expect(code).toBeTruthy(); + const verified = await request.post("http://localhost:8787/v1/auth/verify", { + data: { email: "organizer@demo.panel", code, org: "demo" }, + }); + const token = ((await verified.json()) as { token?: string }).token; + expect(token).toBeTruthy(); + + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto("/o/demo/demo"); + await page.getByLabel("Organizer token").fill(token ?? ""); + await page.getByRole("button", { name: "judging_closed" }).click(); + await expect(page.getByText("judging_closed")).toBeVisible(); + await page.getByRole("button", { name: "Compute results" }).click(); + await expect(page.getByText("Results computed")).toBeVisible(); + await page.getByRole("button", { name: "Publish", exact: true }).click(); + await expect(page.getByText("Published.")).toBeVisible(); + + await page.goto("/e/demo/demo"); + await expect(page.getByRole("listitem").first()).toBeVisible(); + + await page.goto("/o/demo/demo"); + await page.getByLabel("Organizer token").fill(token ?? ""); + await page.getByRole("button", { name: "judging_closed" }).click(); + await page.getByRole("button", { name: "judging_live" }).click(); + await expect(page.getByText("judging_live")).toBeVisible(); +}); diff --git a/judging/web/eslint.config.mjs b/judging/web/eslint.config.mjs new file mode 100644 index 00000000..1236da8c --- /dev/null +++ b/judging/web/eslint.config.mjs @@ -0,0 +1,23 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...config, + { + files: ["**/*.ts", "**/*.tsx"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["@query", "@query/*"], + message: + "judging/ is a standalone product. Import from @panel/* only.", + }, + ], + }, + ], + }, + }, +]; diff --git a/judging/web/lib/api.ts b/judging/web/lib/api.ts new file mode 100644 index 00000000..4fbb377d --- /dev/null +++ b/judging/web/lib/api.ts @@ -0,0 +1,15 @@ +export const apiBase = process.env.NEXT_PUBLIC_PANEL_URL ?? "http://localhost:8787"; + +export type PublicEvent = { + eventId: string; + name: string; + phase: string; + branding: { tagline?: string; colors?: { accent?: string } }; + orgName: string; +}; + +export async function loadEvent(orgSlug: string, eventSlug: string): Promise { + const response = await fetch(`${apiBase}/v1/public/${orgSlug}/${eventSlug}`); + if (!response.ok) return null; + return response.json() as Promise; +} diff --git a/judging/web/lib/held.ts b/judging/web/lib/held.ts new file mode 100644 index 00000000..40c3811b --- /dev/null +++ b/judging/web/lib/held.ts @@ -0,0 +1,59 @@ +type HeldVote = { + id: string; + path: string; + body: unknown; +}; + +const DB_NAME = "panel"; +const STORE = "votes"; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => { + request.result.createObjectStore(STORE, { keyPath: "id" }); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +export async function holdVote(body: { visitId?: string; eventId: string }) { + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).put({ + id: body.visitId ?? crypto.randomUUID(), + path: "/v1/session/vote", + body, + } satisfies HeldVote); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); +} + +export async function flushHeld( + send: (path: string, body: unknown) => Promise, +): Promise { + const db = await openDb(); + const rows = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readonly"); + const request = tx.objectStore(STORE).getAll(); + request.onsuccess = () => resolve(request.result as HeldVote[]); + request.onerror = () => reject(request.error); + }); + let sent = 0; + for (const row of rows) { + await send(row.path, row.body); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).delete(row.id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + sent += 1; + } + db.close(); + return sent; +} diff --git a/judging/web/next.config.ts b/judging/web/next.config.ts new file mode 100644 index 00000000..68a6c64d --- /dev/null +++ b/judging/web/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", +}; + +export default nextConfig; diff --git a/judging/web/package.json b/judging/web/package.json new file mode 100644 index 00000000..42b7da58 --- /dev/null +++ b/judging/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "@panel/web", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "next dev --port 3005", + "build": "next build", + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc --noEmit", + "e2e": "playwright test" + }, + "dependencies": { + "next": "16.3.7", + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "@query/eslint-config": "workspace:*", + "@query/tsconfig": "workspace:*", + "@types/node": "22.20.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "typescript": "7.0.2", + "@playwright/test": "^1.58.2" + } +} diff --git a/judging/web/playwright.config.ts b/judging/web/playwright.config.ts new file mode 100644 index 00000000..7d21aa52 --- /dev/null +++ b/judging/web/playwright.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "e2e", + timeout: 60_000, + use: { + baseURL: "http://localhost:3005", + }, +}); diff --git a/judging/web/public/manifest.webmanifest b/judging/web/public/manifest.webmanifest new file mode 100644 index 00000000..cc5c32f8 --- /dev/null +++ b/judging/web/public/manifest.webmanifest @@ -0,0 +1,8 @@ +{ + "name": "Panel", + "short_name": "Panel", + "start_url": "/", + "display": "standalone", + "background_color": "#f6f5f2", + "theme_color": "#111111" +} diff --git a/judging/web/test-results/.last-run.json b/judging/web/test-results/.last-run.json new file mode 100644 index 00000000..cbcc1fba --- /dev/null +++ b/judging/web/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/judging/web/tsconfig.json b/judging/web/tsconfig.json new file mode 100644 index 00000000..ea5d5185 --- /dev/null +++ b/judging/web/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tooling/typescript/nextjs.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "plugins": [{ "name": "next" }], + "jsx": "preserve", + "noEmit": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules", "e2e", "playwright.config.ts"] +} diff --git a/package.json b/package.json index f3ddfc8d..6d7ed480 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint": "turbo run lint", "format": "prettier --write .", "typecheck": "turbo run typecheck", - "test": "vitest run packages/api packages/auth packages/db sites/mainweb/lib judging/core" + "test": "vitest run packages/api packages/auth packages/db sites/mainweb/lib judging/core judging/server judging/cli" }, "dependencies": { "next": "16.3.7", diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index bfa2e79d..c03e4ffc 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -21,6 +21,7 @@ import { import { CacheKeys, invalidatePortalContext } from "../../middleware/cache"; import type { DrizzleDB } from "@query/db"; import { isLive, loadGroups, loadPool } from "./dispatch"; +import { syncJudgeToPanel, syncProjectsToPanel } from "../../services/panel-sync"; // Judges draw tables from a shared pool (dispatch.ts) instead of holding a // list built in advance. Clears rows an earlier version built that the judge @@ -263,7 +264,7 @@ export const judgeAdminRouter = createTRPCRouter({ promoteSubmissions: isAdmin .input(z.object({ hackathonId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { - return await (ctx.db as DrizzleDB).transaction(async (tx) => { + const promoted = await (ctx.db as DrizzleDB).transaction(async (tx) => { // Serializes concurrent promotions, so two organisers pressing the button // together cannot both read the same max table number and duplicate it. await tx @@ -350,6 +351,8 @@ export const judgeAdminRouter = createTRPCRouter({ total: submissions.length, }; }); + await syncProjectsToPanel(ctx.db as DrizzleDB, input.hackathonId); + return promoted; }), setActive: isAdmin @@ -467,6 +470,12 @@ export const judgeAdminRouter = createTRPCRouter({ } } + if (input.isActive) { + await syncJudgeToPanel(ctx.db as DrizzleDB, { + hackathonId: updated.hackathonId, + email: updated.email, + }); + } return { success: true, isActive: input.isActive, queuedProjects }; }), diff --git a/packages/api/src/routers/judge/portal.ts b/packages/api/src/routers/judge/portal.ts index b0afda0a..89dc0d40 100644 --- a/packages/api/src/routers/judge/portal.ts +++ b/packages/api/src/routers/judge/portal.ts @@ -21,6 +21,7 @@ import { lockDispatch, } from "./dispatch"; import type { DrizzleDB } from "@query/db"; +import { panelConsoleUrl, panelDeskUrl } from "../../services/panel-sync"; export const judgePortalRouter = createTRPCRouter({ @@ -68,6 +69,25 @@ export const judgePortalRouter = createTRPCRouter({ return result; }), + /** A short-lived ticket into the panel desk, only for an edition that opted in. */ + panelDesk: protectedProcedure.query(async ({ ctx }) => { + return panelDeskUrl(ctx.db as DrizzleDB, ctx.userId as string); + }), + + /** Organizer handoff into the panel console when this edition uses panel. */ + panelConsole: isAdmin + .input(z.object({ hackathonId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const email = ctx.session?.user?.email; + if (!email) return { url: null as string | null }; + return panelConsoleUrl(ctx.db as DrizzleDB, { + hackathonId: input.hackathonId, + userId: ctx.userId as string, + email, + name: ctx.session?.user?.name ?? email, + }); + }), + getMyAssignments: protectedProcedure.query(async ({ ctx }) => { const db = ctx.db as DrizzleDB; diff --git a/packages/api/src/routers/judge/rankings.ts b/packages/api/src/routers/judge/rankings.ts index 69f72da8..05957556 100644 --- a/packages/api/src/routers/judge/rankings.ts +++ b/packages/api/src/routers/judge/rankings.ts @@ -6,7 +6,8 @@ import { and, eq, isNotNull, sql , isNull } from "drizzle-orm"; import { isAdmin } from "../../middleware/procedures"; import { recordAdminAction } from "../../middleware/audit"; import type { DrizzleDB } from "@query/db"; -import { zNormalize } from "./helpers"; +import { weightProjects } from "./weight"; +import { pullPanelResults } from "../../services/panel-sync"; // The whole ranking pipeline in one place, so the live view and the frozen // snapshot cannot drift: two implementations of a scoring formula are two @@ -34,75 +35,26 @@ async function computeRanking(db: DrizzleDB, hackathonId: string) { }); const round2 = (n: number) => Math.round(n * 100) / 100; - - // Step 1: raw scores grouped by judge. Per-judge distributions are what - // Z-score normalization needs to cancel harsh-judge / lenient-judge bias. type VoteWithJudge = (typeof projects)[number]["votes"][number]; - const scoresByJudge = new Map(); - for (const project of projects) { - for (const v of project.votes) { - const existing = scoresByJudge.get(v.judgeId) ?? []; - existing.push(v.score); - scoresByJudge.set(v.judgeId, existing); - } - } - - // ─── Step 2: Compute global score distribution ───────────────────────── - const allRawScores = [...scoresByJudge.values()].flat(); - const globalMean = - allRawScores.length > 0 - ? allRawScores.reduce((a, b) => a + b, 0) / allRawScores.length - : 0; - const globalVariance = - allRawScores.length > 0 - ? allRawScores.reduce((s, v) => s + (v - globalMean) ** 2, 0) / - allRawScores.length - : 1; - const globalStd = Math.sqrt(globalVariance) || 1; - - // Step 3: per judge, map their raw score index to a Z-normalized score. - const normalizedScoreLookup = new Map>(); - for (const [judgeId, rawScores] of scoresByJudge.entries()) { - const normalized = zNormalize(rawScores, globalMean, globalStd); - // Map raw score value -> normalized value (index-based, preserves order) - const lookup = new Map(); - rawScores.forEach((raw, i) => { - // If same raw score appears multiple times, average the normalized values - const existing = lookup.get(raw); - lookup.set( - raw, - existing !== undefined - ? (existing + normalized[i]!) / 2 - : normalized[i]!, - ); - }); - normalizedScoreLookup.set(judgeId, lookup); - } - - const getNormalized = (judgeId: string, rawScore: number): number => { - const lookup = normalizedScoreLookup.get(judgeId); - return lookup?.get(rawScore) ?? rawScore; - }; - - // ─── Step 4: Build raw + normalized stats per project ───────────────── - const C = 2; // Bayesian confidence weight + const weighted = new Map( + weightProjects( + projects.map((project) => ({ + id: project.id, + votes: project.votes.map((vote) => ({ judgeId: vote.judgeId, score: vote.score })), + })), + ).map((row) => [row.id, row]), + ); const rawRankings = projects.map((project) => { + const scored = weighted.get(project.id); const voteCount = project.votes.length; + const normalizedScores = scored?.normalizedScores ?? []; + const normalizedAvg = scored?.normalizedAvg ?? 0; // Raw scores (unadjusted) const totalScore = project.votes.reduce((sum, v) => sum + v.score, 0); const avgScore = voteCount > 0 ? totalScore / voteCount : 0; - // Z-score normalized scores (bias-corrected) - const normalizedScores = project.votes.map((v) => - getNormalized(v.judgeId, v.score), - ); - const normalizedAvg = - voteCount > 0 - ? round2(normalizedScores.reduce((a, b) => a + b, 0) / voteCount) - : 0; - // Per-category averages (raw) const sumCat = { creativity: 0, @@ -182,26 +134,9 @@ async function computeRanking(db: DrizzleDB, hackathonId: string) { }; }); - // ─── Step 5: Compute global normalized average for Bayesian prior ────── - const votedProjects = rawRankings.filter((r) => r.voteCount > 0); - const globalAvg = - votedProjects.length > 0 - ? round2( - votedProjects.reduce((sum, r) => sum + r.normalizedAvg, 0) / - votedProjects.length, - ) - : 0; - - // Step 6: Bayesian + Z-score final. weightedScore blends the normalized - // average toward the global mean when few judges voted. const rankings = rawRankings.map((r) => { const n = r.voteCount; - const weightedScore = - n > 0 - ? round2( - (n / (n + C)) * r.normalizedAvg + (C / (n + C)) * globalAvg, - ) - : 0; + const weightedScore = weighted.get(r.project.id)?.weightedScore ?? 0; const confidenceLevel: "NONE" | "LOW" | "MEDIUM" | "HIGH" = n === 0 ? "NONE" : n === 1 ? "LOW" : n === 2 ? "MEDIUM" : "HIGH"; const scoreShift = round2(r.normalizedAvg - r.avgScore); // how much bias-correction shifted this project @@ -311,6 +246,12 @@ async function computeRanking(db: DrizzleDB, hackathonId: string) { }); } + const votedRows = [...weighted.values()].filter((row) => row.voteCount > 0); + const globalAvg = + votedRows.length > 0 + ? round2(votedRows.reduce((sum, row) => sum + row.normalizedAvg, 0) / votedRows.length) + : 0; + const result = { rankings, globalAvg, @@ -481,6 +422,7 @@ export const judgeRankingsRouter = createTRPCRouter({ publishResults: isAdmin .input(z.object({ hackathonId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { + await pullPanelResults(ctx.db as DrizzleDB, input.hackathonId); const rows = await (ctx.db as DrizzleDB) .update(hackathonResults) .set({ publishedAt: new Date() }) diff --git a/packages/api/src/routers/judge/replay.test.ts b/packages/api/src/routers/judge/replay.test.ts new file mode 100644 index 00000000..5a3fa563 --- /dev/null +++ b/packages/api/src/routers/judge/replay.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { rank } from "../../../../../judging/core/src/index"; +import { weightProjects } from "./weight"; + +const projects = [ + { + id: "A", + votes: [ + { judgeId: "j1", score: 8 }, + { judgeId: "j2", score: 9 }, + ], + }, + { + id: "B", + votes: [ + { judgeId: "j1", score: 5 }, + { judgeId: "j2", score: 9 }, + ], + }, + { + id: "C", + votes: [ + { judgeId: "j1", score: 2 }, + { judgeId: "j2", score: 6 }, + ], + }, +]; + +describe("legacy rankings and panel rank", () => { + it("places the published fixture the same way at pairwise weight 0", () => { + const legacy = weightProjects(projects) + .filter((row) => row.voteCount > 0) + .sort((a, b) => b.weightedScore - a.weightedScore || a.id.localeCompare(b.id)); + const panel = rank( + projects.map((project) => ({ id: project.id, trackIds: ["overall"] })), + projects.flatMap((project) => + project.votes.map((vote) => ({ + judgeId: vote.judgeId, + projectId: project.id, + judgeGroup: "main", + total: vote.score, + })), + ), + [], + { bayesianC: 2, pairwiseWeight: 0, calibrationWeight: 1 }, + ) + .filter((row) => row.placement !== null) + .sort((a, b) => (a.placement ?? 0) - (b.placement ?? 0)); + + expect(panel.map((row) => [row.projectId, row.score])).toEqual( + legacy.map((row) => [row.id, row.weightedScore]), + ); + expect(panel.map((row) => row.score)).toEqual([7.71, 6.94, 4.85]); + }); +}); diff --git a/packages/api/src/routers/judge/weight.ts b/packages/api/src/routers/judge/weight.ts new file mode 100644 index 00000000..3f9ada92 --- /dev/null +++ b/packages/api/src/routers/judge/weight.ts @@ -0,0 +1,84 @@ +import { zNormalize } from "./helpers"; + +const round2 = (n: number) => Math.round(n * 100) / 100; +const C = 2; + +export type WeightVote = { judgeId: string; score: number }; +export type WeightProject = { id: string; votes: readonly WeightVote[] }; + +export type WeightedProject = { + id: string; + voteCount: number; + normalizedAvg: number; + weightedScore: number; + normalizedScores: number[]; +}; + +/** + * The ranking the club site publishes: z-score each judge, average per + * project, shrink toward the mean of those averages with c = 2. Panel's + * `rank()` at pairwise weight 0 is checked against this function. + */ +export function weightProjects(projects: readonly WeightProject[]): WeightedProject[] { + const scoresByJudge = new Map(); + for (const project of projects) { + for (const vote of project.votes) { + const existing = scoresByJudge.get(vote.judgeId) ?? []; + existing.push(vote.score); + scoresByJudge.set(vote.judgeId, existing); + } + } + + const allRawScores = [...scoresByJudge.values()].flat(); + const globalMean = + allRawScores.length > 0 + ? allRawScores.reduce((a, b) => a + b, 0) / allRawScores.length + : 0; + const globalVariance = + allRawScores.length > 0 + ? allRawScores.reduce((sum, value) => sum + (value - globalMean) ** 2, 0) / + allRawScores.length + : 1; + const globalStd = Math.sqrt(globalVariance) || 1; + + const normalizedScoreLookup = new Map>(); + for (const [judgeId, rawScores] of scoresByJudge.entries()) { + const normalized = zNormalize(rawScores, globalMean, globalStd); + const lookup = new Map(); + rawScores.forEach((raw, index) => { + const existing = lookup.get(raw); + const value = normalized[index]!; + lookup.set(raw, existing !== undefined ? (existing + value) / 2 : value); + }); + normalizedScoreLookup.set(judgeId, lookup); + } + + const raw = projects.map((project) => { + const voteCount = project.votes.length; + const normalizedScores = project.votes.map( + (vote) => normalizedScoreLookup.get(vote.judgeId)?.get(vote.score) ?? vote.score, + ); + const normalizedAvg = + voteCount > 0 + ? round2(normalizedScores.reduce((sum, value) => sum + value, 0) / voteCount) + : 0; + return { id: project.id, voteCount, normalizedAvg, normalizedScores }; + }); + + const voted = raw.filter((row) => row.voteCount > 0); + const globalAvg = + voted.length > 0 + ? round2(voted.reduce((sum, row) => sum + row.normalizedAvg, 0) / voted.length) + : 0; + + return raw.map((row) => ({ + ...row, + weightedScore: + row.voteCount > 0 + ? round2( + (row.voteCount / (row.voteCount + C)) * row.normalizedAvg + + (C / (row.voteCount + C)) * globalAvg, + ) + : 0, + })); +} diff --git a/packages/api/src/services/panel-sync.ts b/packages/api/src/services/panel-sync.ts new file mode 100644 index 00000000..b1f17fac --- /dev/null +++ b/packages/api/src/services/panel-sync.ts @@ -0,0 +1,225 @@ +import { and, eq, inArray, sql } from "drizzle-orm"; +import { hackathonProjects, hackathonResults, hackathons, judges, judgingProjects } from "@query/db"; +import { createHmac } from "node:crypto"; +import type { DrizzleDB } from "@query/db"; + +/** + * Copies an edition into the panel service when that edition has opted in. + * A failure here does not undo the club write. The portal stays the source + * of registration, and the same action can be run again. + */ +export async function syncProjectsToPanel(db: DrizzleDB, hackathonId: string) { + try { + const target = await panelTarget(db, hackathonId); + if (!target) return; + const rows = await db + .select({ + id: hackathonProjects.id, + name: hackathonProjects.name, + }) + .from(hackathonProjects) + .where( + and( + eq(hackathonProjects.hackathonId, hackathonId), + inArray(hackathonProjects.status, ["submitted", "judging"]), + ), + ); + for (const row of rows) { + await post(target, "project.upsert", { + eventId: target.eventId, + externalId: row.id, + name: row.name, + teamName: null, + tableNumber: null, + }); + } + } catch { + return; + } +} + +export async function syncJudgeToPanel( + db: DrizzleDB, + judge: { hackathonId: string; email: string | null }, +) { + try { + if (!judge.email) return; + const target = await panelTarget(db, judge.hackathonId); + if (!target) return; + await post(target, "judge.upsert", { + eventId: target.eventId, + email: judge.email, + name: judge.email, + externalId: null, + }); + } catch { + return; + } +} + +async function panelTarget(db: DrizzleDB, hackathonId: string) { + const url = process.env.PANEL_URL; + const token = process.env.PANEL_SERVICE_TOKEN; + const eventId = process.env.PANEL_EVENT_ID; + if (!url || !token || !eventId) return null; + const [row] = await db + .select({ backend: hackathons.judgingBackend }) + .from(hackathons) + .where(eq(hackathons.id, hackathonId)); + if (row?.backend !== "panel") return null; + return { url, token, eventId }; +} + +async function post( + target: { url: string; token: string }, + procedure: string, + body: unknown, +) { + const response = await fetch(`${target.url.replace(/\/$/, "")}/trpc/${procedure}`, { + method: "POST", + headers: { + authorization: `Bearer ${target.token}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`panel ${procedure} returned ${response.status}`); + } +} + +export async function pullPanelResults(db: DrizzleDB, hackathonId: string) { + const target = await panelTarget(db, hackathonId); + if (!target) return 0; + const response = await fetch( + `${target.url.replace(/\/$/, "")}/v1/results/${target.eventId}`, + { headers: { authorization: `Bearer ${target.token}` } }, + ); + if (response.status === 403) throw new Error("Panel results are not published"); + if (!response.ok) throw new Error(`panel results returned ${response.status}`); + const body = (await response.json()) as { + rows: { + externalId: string | null; + placement: number | null; + score: number; + track: string; + voteCount: number; + }[]; + }; + const judging = await db + .select({ id: judgingProjects.id, sourceProjectId: judgingProjects.sourceProjectId }) + .from(judgingProjects) + .where(eq(judgingProjects.hackathonId, hackathonId)); + const bySource = new Map( + judging.flatMap((row) => + row.sourceProjectId ? [[row.sourceProjectId, row.id] as const] : [], + ), + ); + let written = 0; + for (const row of body.rows) { + if (!row.externalId || row.placement === null) continue; + const projectId = bySource.get(row.externalId); + if (!projectId) continue; + await db + .insert(hackathonResults) + .values({ + hackathonId, + projectId, + sourceProjectId: row.externalId, + track: row.track || "overall", + placement: row.placement, + weightedScore: row.score.toFixed(2), + voteCount: row.voteCount, + }) + .onConflictDoUpdate({ + target: [ + hackathonResults.hackathonId, + hackathonResults.projectId, + hackathonResults.track, + ], + set: { + placement: sql`excluded.placement`, + weightedScore: sql`excluded.weighted_score`, + voteCount: sql`excluded.vote_count`, + sourceProjectId: row.externalId, + }, + }); + written += 1; + } + return written; +} + +export async function panelDeskUrl(db: DrizzleDB, userId: string) { + const web = process.env.PANEL_WEB_URL; + const secret = process.env.PANEL_JWT_SECRET; + const org = process.env.PANEL_ORG_SLUG; + const eventSlug = process.env.PANEL_EVENT_SLUG; + if (!web || !secret || !org || !eventSlug) return { url: null as string | null }; + const [row] = await db + .select({ email: judges.email }) + .from(judges) + .innerJoin(hackathons, eq(hackathons.id, judges.hackathonId)) + .where( + and( + eq(judges.userId, userId), + eq(judges.isActive, true), + eq(hackathons.judgingBackend, "panel"), + ), + ); + if (!row?.email) return { url: null }; + const now = Math.floor(Date.now() / 1000); + const ticket = signHs256( + { + sub: userId, + email: row.email, + name: row.email, + org, + role: null, + judge_external_id: null, + iat: now, + exp: now + 60 * 30, + }, + secret, + ); + const url = `${web.replace(/\/$/, "")}/j/${org}/${eventSlug}?ticket=${encodeURIComponent(ticket)}`; + return { url }; +} + +export async function panelConsoleUrl( + db: DrizzleDB, + input: { hackathonId: string; userId: string; email: string; name: string }, +) { + const web = process.env.PANEL_WEB_URL; + const secret = process.env.PANEL_JWT_SECRET; + const org = process.env.PANEL_ORG_SLUG; + const eventSlug = process.env.PANEL_EVENT_SLUG; + if (!web || !secret || !org || !eventSlug) return { url: null as string | null }; + const [row] = await db + .select({ backend: hackathons.judgingBackend }) + .from(hackathons) + .where(eq(hackathons.id, input.hackathonId)); + if (row?.backend !== "panel") return { url: null }; + const now = Math.floor(Date.now() / 1000); + const ticket = signHs256( + { + sub: input.userId, + email: input.email, + name: input.name, + org, + role: "organizer", + judge_external_id: null, + iat: now, + exp: now + 60 * 30, + }, + secret, + ); + const url = `${web.replace(/\/$/, "")}/o/${org}/${eventSlug}?ticket=${encodeURIComponent(ticket)}`; + return { url }; +} + +function signHs256(payload: Record, secret: string) { + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const signature = createHmac("sha256", secret).update(`${header}.${body}`).digest("base64url"); + return `${header}.${body}.${signature}`; +} diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index 0077a323..1bcd806e 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -5,5 +5,5 @@ "rootDir": "src" }, "include": ["src", "src/.internal-tests/**/*.ts"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/routers/judge/replay.test.ts"] } diff --git a/packages/db/src/schemas/hackathons.ts b/packages/db/src/schemas/hackathons.ts index 0ada7f93..1df9860c 100644 --- a/packages/db/src/schemas/hackathons.ts +++ b/packages/db/src/schemas/hackathons.ts @@ -56,6 +56,11 @@ export const hackathons = pgTable( websiteUrl: text("website_url"), isPublic: boolean("is_public").notNull().default(true), judgingActive: boolean("judging_active").notNull().default(false), + // legacy keeps the current judge portal. panel hands the same edition to + // the standalone judging service. Additive: existing rows stay on legacy. + judgingBackend: text("judging_backend", { enum: ["legacy", "panel"] }) + .notNull() + .default("legacy"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 056a6a71..17d1b918 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,7 +26,7 @@ importers: dependencies: next: specifier: 16.3.7 - version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) typescript: specifier: ^7.0.2 version: 7.0.2 @@ -41,6 +41,34 @@ importers: specifier: ^5.0.1 version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + judging/cli: + dependencies: + '@panel/db': + specifier: workspace:* + version: link:../db + '@panel/server': + specifier: workspace:* + version: link:../server + drizzle-orm: + specifier: 0.45.3 + version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) + tsx: + specifier: ^4.23.15 + version: 4.23.15 + devDependencies: + '@query/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@query/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + typescript: + specifier: 7.0.2 + version: 7.0.2 + judging/core: devDependencies: '@query/eslint-config': @@ -59,6 +87,129 @@ importers: specifier: ^5.0.1 version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + judging/db: + dependencies: + '@panel/core': + specifier: workspace:* + version: link:../core + drizzle-orm: + specifier: 0.45.3 + version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) + pg: + specifier: 8.23.0 + version: 8.23.0 + devDependencies: + '@query/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@query/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + '@types/pg': + specifier: ^8.23.1 + version: 8.23.1 + typescript: + specifier: 7.0.2 + version: 7.0.2 + + judging/server: + dependencies: + '@hono/node-server': + specifier: ^1.19.0 + version: 1.19.17(hono@4.13.13) + '@panel/core': + specifier: workspace:* + version: link:../core + '@panel/db': + specifier: workspace:* + version: link:../db + '@trpc/server': + specifier: 11.19.0 + version: 11.19.0(typescript@7.0.2) + drizzle-orm: + specifier: 0.45.3 + version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) + hono: + specifier: ^4.9.0 + version: 4.13.13 + ioredis: + specifier: ^5.8.2 + version: 5.11.1 + jose: + specifier: ^6.1.0 + version: 6.2.3 + nodemailer: + specifier: ^10.0.10 + version: 10.0.10 + prom-client: + specifier: 15.1.3 + version: 15.1.3 + tsx: + specifier: ^4.23.15 + version: 4.23.15 + ws: + specifier: ^8.20.1 + version: 8.21.3 + zod: + specifier: 4.6.5 + version: 4.6.5 + devDependencies: + '@query/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@query/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.2 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: ^5.0.1 + version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + + judging/web: + dependencies: + next: + specifier: 16.3.7 + version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + devDependencies: + '@playwright/test': + specifier: ^1.58.2 + version: 1.64.0 + '@query/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@query/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + typescript: + specifier: 7.0.2 + version: 7.0.2 + packages/api: dependencies: '@query/auth': @@ -75,7 +226,7 @@ importers: version: 11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2) '@trpc/next': specifier: 11.19.0 - version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) '@trpc/react-query': specifier: 11.19.0 version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2) @@ -133,7 +284,7 @@ importers: version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) next-auth: specifier: 5.0.0-beta.32 - version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0) + version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0) nodemailer: specifier: ^10.0.10 version: 10.0.10 @@ -173,7 +324,7 @@ importers: version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) next-auth: specifier: 5.0.0-beta.32 - version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0) + version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0) pg: specifier: 8.23.0 version: 8.23.0 @@ -250,7 +401,7 @@ importers: version: 1.0.0 next: specifier: 16.3.7 - version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: specifier: 19.3.0 version: 19.3.0 @@ -329,7 +480,7 @@ importers: version: 11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2) '@trpc/next': specifier: 11.19.0 - version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) '@trpc/react-query': specifier: 11.19.0 version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2) @@ -350,7 +501,7 @@ importers: version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) geist: specifier: ^1.7.2 - version: 1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) + version: 1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) lucide-react: specifier: ^1.48.0 version: 1.48.0(react@19.3.0) @@ -359,10 +510,10 @@ importers: version: 10.2.6 next: specifier: 16.3.7 - version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) next-auth: specifier: 5.0.0-beta.32 - version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0) + version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -551,7 +702,7 @@ importers: version: 4.3.3 next: specifier: 16.3.7 - version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) postcss: specifier: ^8.5.23 version: 8.5.28 @@ -980,6 +1131,12 @@ packages: resolution: {integrity: sha512-7ckgSjeEprV92VrlLjpYFA3PFerflvFtF7Ea86oQXGNCprGZq5rtpAVK0zgWnCy+44mLjms42M7MiWa433gz2A==} engines: {node: '>=22'} + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1180,6 +1337,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1401,6 +1561,11 @@ packages: '@pdf-lib/upng@1.0.1': resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + '@playwright/test@1.64.0': + resolution: {integrity: sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==} + engines: {node: '>=20'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -2211,6 +2376,9 @@ packages: '@types/webxr@0.5.24': resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + '@typescript-eslint/eslint-plugin@8.70.1': resolution: {integrity: sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2669,6 +2837,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2776,6 +2948,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + detect-libc@1.0.3: resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} engines: {node: '>=0.10'} @@ -3375,6 +3551,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hono@4.13.13: + resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} + engines: {node: '>=16.9.0'} + hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -3429,6 +3609,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -4010,6 +4194,16 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + playwright-core@1.64.0: + resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.0: + resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==} + engines: {node: '>=20'} + hasBin: true + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -4222,6 +4416,14 @@ packages: resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==} engines: {node: '>=18'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4380,6 +4582,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -5210,6 +5415,10 @@ snapshots: - encoding - supports-color + '@hono/node-server@1.19.17(hono@4.13.13)': + dependencies: + hono: 4.13.13 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -5344,6 +5553,8 @@ snapshots: '@img/sharp-win32-x64@0.35.4': optional: true + '@ioredis/commands@1.10.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -5525,6 +5736,10 @@ snapshots: dependencies: pako: 1.0.11 + '@playwright/test@1.64.0': + dependencies: + playwright: 1.64.0 + '@polka/url@1.0.0-next.29': {} '@radix-ui/number@1.1.3': {} @@ -6120,11 +6335,11 @@ snapshots: '@trpc/server': 11.19.0(typescript@7.0.2) typescript: 7.0.2 - '@trpc/next@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)': + '@trpc/next@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)': dependencies: '@trpc/client': 11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2) '@trpc/server': 11.19.0(typescript@7.0.2) - next: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 react-dom: 19.3.0(react@19.3.0) typescript: 7.0.2 @@ -6233,6 +6448,10 @@ snapshots: '@types/webxr@0.5.24': {} + '@types/ws@8.18.2': + dependencies: + '@types/node': 22.20.4 + '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -6766,6 +6985,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -6860,6 +7081,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + denque@2.1.0: {} + detect-libc@1.0.3: {} detect-libc@2.1.2: {} @@ -7425,9 +7648,9 @@ snapshots: - encoding - supports-color - geist@1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)): + geist@1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)): dependencies: - next: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) generator-function@2.0.1: {} @@ -7544,6 +7767,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + hono@4.13.13: {} + hookified@1.15.1: {} hookified@2.2.0: {} @@ -7598,6 +7823,18 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.1 + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -7914,10 +8151,10 @@ snapshots: natural-compare@1.4.0: {} - next-auth@5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0): + next-auth@5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0): dependencies: '@auth/core': 0.41.3(nodemailer@10.0.10) - next: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 optionalDependencies: nodemailer: 10.0.10 @@ -7927,7 +8164,7 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@next/env': 16.3.7 '@swc/helpers': 0.5.23 @@ -7947,6 +8184,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.3.7 '@next/swc-win32-x64-msvc': 16.3.7 '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.64.0 babel-plugin-react-compiler: 1.0.0 sharp: 0.35.4(@types/node@22.20.4) transitivePeerDependencies: @@ -8126,6 +8364,12 @@ snapshots: picomatch@4.0.7: {} + playwright-core@1.64.0: {} + + playwright@1.64.0: + dependencies: + playwright-core: 1.64.0 + pngjs@5.0.0: {} possible-typed-array-names@1.1.0: {} @@ -8275,6 +8519,12 @@ snapshots: dependencies: minimatch: 10.2.6 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -8513,6 +8763,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + std-env@4.2.0: {} stop-iteration-iterator@1.1.0: diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index 805ed3c0..ed914c64 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -57,6 +57,11 @@ export default function AdminResultsPage() { ); // Get rankings for selected hackathon + const { data: panelConsole } = trpc.judge.panelConsole.useQuery( + { hackathonId: selectedHackathon as string }, + { enabled: !!selectedHackathon }, + ); + const { data: rankings } = trpc.judge.getRankings.useQuery( { hackathonId: selectedHackathon as string }, { enabled: !!selectedHackathon }, @@ -251,6 +256,11 @@ export default function AdminResultsPage() { ? "Pick a hackathon to see its results." : "Open and close judging, watch the floor, and compare scores."}

+ {panelConsole?.url ? ( + + Open the panel console + + ) : null} {/* Judging Control Panel */} diff --git a/sites/mainweb/app/(portal)/judge/page.tsx b/sites/mainweb/app/(portal)/judge/page.tsx index 8a61fae2..383c6fdf 100644 --- a/sites/mainweb/app/(portal)/judge/page.tsx +++ b/sites/mainweb/app/(portal)/judge/page.tsx @@ -55,6 +55,9 @@ export default function JudgePage() { // Gated on any approved application, not isJudge: isJudge answers for the // current edition only, while assignments span every edition, so a judge // approved for another one saw "Awaiting approval" forever. + const { data: panelDesk } = trpc.judge.panelDesk.useQuery(undefined, { + enabled: !!session, + }); const { data: assignments } = trpc.judge.getMyAssignments.useQuery( undefined, { @@ -112,6 +115,11 @@ export default function JudgePage() { Apply to judge an event. An organiser approves you before you can score.

+ {panelDesk?.url ? ( + + Open the judging desk + + ) : null} {!hackathons?.length ? ( diff --git a/turbo.json b/turbo.json index 9a01a7cf..0ac93324 100644 --- a/turbo.json +++ b/turbo.json @@ -54,6 +54,8 @@ }, "globalEnv": [ "PORT", + "PORT_A", + "PORT_B", "DATABASE_URL", "NEXTAUTH_SECRET", "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY", @@ -94,6 +96,19 @@ "NODE_ENV", "CI", "npm_lifecycle_event", - "JUDGING_TEST_DATABASE_URL" + "JUDGING_TEST_DATABASE_URL", + "PANEL_DATABASE_URL", + "PANEL_JWT_SECRET", + "PANEL_JWKS_URL", + "PANEL_DEV_AUTH", + "PANEL_SMTP_URL", + "PANEL_SMTP_FROM", + "PANEL_URL", + "PANEL_SERVICE_TOKEN", + "PANEL_EVENT_ID", + "PANEL_WEB_URL", + "PANEL_ORG_SLUG", + "PANEL_EVENT_SLUG", + "REDIS_URL" ] } From e2d1bf585a88ba136ff6780d552bb4894ec00288 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 8 Oct 2026 13:15:48 -0400 Subject: [PATCH 3/8] fix(panel): keep the SQL migrations in the repo The root ignore rule was hiding judging/db/migrations, and a Playwright run result had been committed with the desk. Co-authored-by: Cursor --- .gitignore | 2 + judging/db/migrations/0001_init.sql | 309 ++++++++++++++++++ judging/db/migrations/0002_feedback_token.sql | 1 + .../db/migrations/0003_shared_database.sql | 37 +++ judging/web/.gitignore | 2 + judging/web/test-results/.last-run.json | 4 - 6 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 judging/db/migrations/0001_init.sql create mode 100644 judging/db/migrations/0002_feedback_token.sql create mode 100644 judging/db/migrations/0003_shared_database.sql create mode 100644 judging/web/.gitignore delete mode 100644 judging/web/test-results/.last-run.json diff --git a/.gitignore b/.gitignore index a278d653..e1b9457a 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,8 @@ next-env.d.ts *.db *.db-journal migrations/ +!judging/db/migrations/ +!judging/db/migrations/** # Editor .vscode/* diff --git a/judging/db/migrations/0001_init.sql b/judging/db/migrations/0001_init.sql new file mode 100644 index 00000000..2037f72f --- /dev/null +++ b/judging/db/migrations/0001_init.sql @@ -0,0 +1,309 @@ +create extension if not exists pgcrypto; + +create type event_phase as enum ( + 'setup', + 'submissions_open', + 'submissions_closed', + 'judging_live', + 'judging_closed', + 'published', + 'archived' +); + +create type track_kind as enum ('main', 'sponsor', 'special'); + +create type dispatch_strategy as enum ('coverage', 'uncertainty'); + +create type judge_status as enum ('invited', 'applied', 'approved', 'suspended'); + +create type comparison_outcome as enum ('a', 'b', 'tie'); + +create type membership_role as enum ('owner', 'admin', 'organizer', 'volunteer'); + +create table organization ( + id uuid primary key default gen_random_uuid(), + slug text not null unique, + name text not null, + branding jsonb not null default '{}'::jsonb +); + +create table panel_user ( + id uuid primary key default gen_random_uuid(), + email text not null unique, + name text not null +); + +create table panel_event ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references organization (id) on delete cascade, + slug text not null, + name text not null, + starts_at timestamptz, + ends_at timestamptz, + phase event_phase not null default 'setup', + unique (org_id, slug) +); + +create table event_config ( + event_id uuid primary key references panel_event (id) on delete cascade, + target_seconds integer not null, + hard_limit_seconds integer not null, + walk_limit_seconds integer not null, + submit_grace_seconds integer not null, + min_looks_per_project integer not null, + dispatch_strategy dispatch_strategy not null, + pairwise_enabled boolean not null default false, + pairwise_weight numeric not null default 0, + bayesian_c numeric not null default 2, + calibration_looks integer not null default 0, + calibration_weight numeric not null default 1, + feedback_cards_enabled boolean not null default false, + leaderboard_public boolean not null default false, + board_poll_seconds integer not null default 5 +); + +create table rubric ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + name text not null, + is_default boolean not null default false +); + +create table criterion ( + id uuid primary key default gen_random_uuid(), + rubric_id uuid not null references rubric (id) on delete cascade, + position integer not null, + key text not null, + label text not null, + description text, + min numeric not null, + max numeric not null, + weight numeric not null, + anchors jsonb not null default '[]'::jsonb, + unique (rubric_id, key) +); + +create table track ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + slug text not null, + name text not null, + kind track_kind not null, + judge_group text not null, + rubric_id uuid references rubric (id), + position integer not null default 0, + is_active boolean not null default true, + unique (event_id, slug) +); + +create table prize ( + id uuid primary key default gen_random_uuid(), + track_id uuid not null references track (id) on delete cascade, + place integer not null, + title text not null, + amount numeric, + description text, + unique (track_id, place) +); + +create table zone ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + name text not null, + position integer not null default 0 +); + +-- `table` is reserved in SQL, so the floor plan lives in floor_table. +create table floor_table ( + event_id uuid not null references panel_event (id) on delete cascade, + number integer not null, + zone_id uuid references zone (id), + x numeric, + y numeric, + primary key (event_id, number) +); + +create table project ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + external_id text, + name text not null, + description text, + team_name text, + members jsonb not null default '[]'::jsonb, + links jsonb not null default '{}'::jsonb, + table_number integer, + zone_id uuid references zone (id), + qr_token uuid not null unique default gen_random_uuid(), + arrived_first_at timestamptz, + withdrawn_at timestamptz +); + +create unique index project_event_external_idx + on project (event_id, external_id) + where external_id is not null; + +create table project_track ( + project_id uuid not null references project (id) on delete cascade, + track_id uuid not null references track (id) on delete cascade, + primary key (project_id, track_id) +); + +create table panel_judge ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + external_id text, + name text not null, + email text not null, + org text, + title text, + status judge_status not null default 'invited', + track_id uuid references track (id), + zone_id uuid references zone (id), + is_lead boolean not null default false, + unique (event_id, email) +); + +create unique index judge_event_external_idx + on panel_judge (event_id, external_id) + where external_id is not null; + +create table visit ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + judge_id uuid not null references panel_judge (id) on delete cascade, + project_id uuid not null references project (id) on delete cascade, + judge_group text not null, + handed_out_at timestamptz not null, + arrived_at timestamptz, + completed_at timestamptz, + voided_at timestamptz, + void_reason text +); + +-- A completed look still counts: the same judge is not handed that project again. +create unique index visit_judge_project_open_idx + on visit (judge_id, project_id) + where voided_at is null; + +-- One table in hand at a time. The dispatch lock is the first guard; this is the second. +create unique index visit_one_open_idx + on visit (judge_id) + where voided_at is null and completed_at is null; + +create table vote ( + id uuid primary key default gen_random_uuid(), + visit_id uuid not null unique references visit (id) on delete cascade, + judge_id uuid not null references panel_judge (id) on delete cascade, + project_id uuid not null references project (id) on delete cascade, + event_id uuid not null references panel_event (id) on delete cascade, + total numeric not null, + comment text, + duration_seconds integer, + is_calibration boolean not null default false +); + +create table vote_score ( + vote_id uuid not null references vote (id) on delete cascade, + criterion_id uuid not null references criterion (id), + value numeric not null, + primary key (vote_id, criterion_id) +); + +create table comparison ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + judge_id uuid not null references panel_judge (id) on delete cascade, + judge_group text not null, + a_project_id uuid not null references project (id), + b_project_id uuid not null references project (id), + outcome comparison_outcome not null, + created_at timestamptz not null default now() +); + +create table result_run ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + computed_at timestamptz not null default now(), + computed_by uuid references panel_user (id), + config_snapshot jsonb not null, + published_at timestamptz, + notes text +); + +create table result ( + run_id uuid not null references result_run (id) on delete cascade, + project_id uuid not null references project (id) on delete cascade, + track_id uuid not null references track (id) on delete cascade, + placement integer, + score numeric not null, + rubric_component numeric not null, + pairwise_component numeric not null, + vote_count integer not null, + comparison_count integer not null, + flags text[] not null default '{}', + primary key (run_id, project_id, track_id) +); + +create table event_log ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + kind text not null, + actor jsonb not null default '{}'::jsonb, + subject jsonb not null default '{}'::jsonb, + payload jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create table outbox ( + id uuid primary key default gen_random_uuid(), + event_id uuid not null references panel_event (id) on delete cascade, + topic text not null, + payload jsonb not null, + created_at timestamptz not null default now(), + delivered_at timestamptz, + attempts integer not null default 0 +); + +create table webhook ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references organization (id) on delete cascade, + url text not null, + secret text not null, + topics text[] not null, + is_active boolean not null default true +); + +create table api_key ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references organization (id) on delete cascade, + hashed_key text not null, + scopes text[] not null, + created_at timestamptz not null default now(), + revoked_at timestamptz +); + +create table membership ( + user_id uuid not null references panel_user (id) on delete cascade, + org_id uuid not null references organization (id) on delete cascade, + role membership_role not null, + primary key (user_id, org_id) +); + +create table judge_identity ( + judge_id uuid primary key references panel_judge (id) on delete cascade, + user_id uuid not null references panel_user (id) on delete cascade +); + +create table login_code ( + id uuid primary key default gen_random_uuid(), + email text not null, + code_hash text not null, + expires_at timestamptz not null, + consumed_at timestamptz +); + +create index event_log_event_idx on event_log (event_id, created_at); +create index outbox_pending_idx on outbox (created_at) where delivered_at is null; +create index visit_event_idx on visit (event_id); +create index project_event_idx on project (event_id); diff --git a/judging/db/migrations/0002_feedback_token.sql b/judging/db/migrations/0002_feedback_token.sql new file mode 100644 index 00000000..1fc17747 --- /dev/null +++ b/judging/db/migrations/0002_feedback_token.sql @@ -0,0 +1 @@ +alter table project add column feedback_token uuid unique; diff --git a/judging/db/migrations/0003_shared_database.sql b/judging/db/migrations/0003_shared_database.sql new file mode 100644 index 00000000..6a29b730 --- /dev/null +++ b/judging/db/migrations/0003_shared_database.sql @@ -0,0 +1,37 @@ +-- Panel shares the club Postgres database. user, event, and judge already +-- belong to that app, so panel's copies use a prefix. Rename only when the +-- table is the panel one (uuid user id, event.org_id, judge.event_id). + +do $$ +begin + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'user' + and column_name = 'id' + and data_type = 'uuid' + ) and to_regclass('public.panel_user') is null then + alter table "user" rename to panel_user; + end if; + + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'event' + and column_name = 'org_id' + ) and to_regclass('public.panel_event') is null then + alter table event rename to panel_event; + end if; + + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'judge' + and column_name = 'event_id' + ) and to_regclass('public.panel_judge') is null then + alter table judge rename to panel_judge; + end if; +end $$; diff --git a/judging/web/.gitignore b/judging/web/.gitignore new file mode 100644 index 00000000..aaa9103e --- /dev/null +++ b/judging/web/.gitignore @@ -0,0 +1,2 @@ +test-results/ +playwright-report/ diff --git a/judging/web/test-results/.last-run.json b/judging/web/test-results/.last-run.json deleted file mode 100644 index cbcc1fba..00000000 --- a/judging/web/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "passed", - "failedTests": [] -} \ No newline at end of file From bfd6d0297128d9eb8f491ab1b573cda4b30bdf8e Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 8 Oct 2026 14:07:06 -0400 Subject: [PATCH 4/8] refactor(panel): fold judging into the monorepo Judging was a standalone product under judging/ with its own scope, licence, CI workflow, compose file and an import boundary against @query/*. It now lives as ordinary workspaces: packages/judging-{core,db,server,cli} and sites/judging, scoped @query/judging-*. Lint, typecheck, test and build run in ci.yml; image publishing on panel-v tags moved there too. The judging server joins the root docker-compose under the "judging" profile on the shared Postgres. packages/api now depends on @query/judging-core, so the ranking replay test imports it by name and is back in the api tsconfig. Both pre-existing lint failures (replay.test.ts and the web e2e spec outside tsconfig) are fixed. --- .github/workflows/ci.yml | 30 ++ .github/workflows/panel.yml | 75 ---- .gitignore | 4 +- PLAN.md | 98 +++-- docker-compose.yml | 23 ++ docs/judging/README.md | 55 +++ .../judging}/adr/001-standalone.md | 4 +- .../judging}/adr/002-pure-core.md | 2 +- .../judging}/adr/003-one-database.md | 2 +- {judging/docs => docs/judging}/api.md | 0 docs/judging/contributing.md | 28 ++ {judging/docs => docs/judging}/deploy.md | 12 +- {judging/docs => docs/judging}/rubric.md | 0 {judging/docs => docs/judging}/scoring.md | 0 judging/.github/ISSUE_TEMPLATE/bug.md | 11 - judging/.github/ISSUE_TEMPLATE/feature.md | 9 - judging/CHANGELOG.md | 8 - judging/CONTRIBUTING.md | 28 -- judging/LICENSE | 21 -- judging/README.md | 56 --- judging/cli/eslint.config.mjs | 27 -- judging/core/src/standalone.test.ts | 34 -- judging/db/eslint.config.mjs | 24 -- judging/infra/docker/docker-compose.yml | 30 -- judging/server/eslint.config.mjs | 28 -- judging/web/eslint.config.mjs | 23 -- package.json | 2 +- packages/api/package.json | 1 + packages/api/src/routers/judge/replay.test.ts | 2 +- packages/api/tsconfig.json | 2 +- packages/judging-cli/eslint.config.mjs | 10 + .../cli => packages/judging-cli}/package.json | 6 +- .../judging-cli}/src/csv.test.ts | 0 .../cli => packages/judging-cli}/src/csv.ts | 0 .../cli => packages/judging-cli}/src/main.ts | 4 +- .../judging-cli}/src/transfer.ts | 2 +- .../judging-cli}/tsconfig.json | 0 .../judging-core}/eslint.config.mjs | 9 +- .../judging-core}/package.json | 2 +- .../judging-core}/scripts/emit-extensions.mjs | 0 .../judging-core}/src/aggregate/bayes.test.ts | 0 .../judging-core}/src/aggregate/bayes.ts | 0 .../judging-core}/src/aggregate/blend.test.ts | 0 .../judging-core}/src/aggregate/blend.ts | 0 .../src/aggregate/bradleyTerry.test.ts | 0 .../src/aggregate/bradleyTerry.ts | 0 .../judging-core}/src/aggregate/rank.test.ts | 0 .../judging-core}/src/aggregate/rank.ts | 0 .../src/aggregate/zscore.test.ts | 0 .../judging-core}/src/aggregate/zscore.ts | 0 .../judging-core}/src/comparison.ts | 0 .../judging-core}/src/dispatch.sim.test.ts | 0 .../judging-core}/src/dispatch.test.ts | 0 .../judging-core}/src/dispatch.ts | 0 .../judging-core}/src/distance.test.ts | 0 .../judging-core}/src/distance.ts | 0 .../judging-core}/src/events.test.ts | 0 .../judging-core}/src/events.ts | 0 .../judging-core}/src/index.ts | 0 .../judging-core}/src/match.test.ts | 0 .../judging-core}/src/match.ts | 0 .../judging-core}/src/package.test.ts | 2 +- .../judging-core}/src/phase.test.ts | 0 .../judging-core}/src/phase.ts | 0 .../judging-core}/src/rubric.test.ts | 0 .../judging-core}/src/rubric.ts | 0 .../judging-core}/src/tables.test.ts | 0 .../judging-core}/src/tables.ts | 0 .../judging-core}/src/timers.test.ts | 0 .../judging-core}/src/timers.ts | 0 .../judging-core}/src/uncertainty.test.ts | 0 .../judging-core}/src/uncertainty.ts | 0 .../judging-core}/tsconfig.build.json | 0 .../judging-core}/tsconfig.json | 0 .../judging-core}/vitest.config.ts | 0 packages/judging-db/eslint.config.mjs | 4 + .../judging-db}/migrations/0001_init.sql | 0 .../migrations/0002_feedback_token.sql | 0 .../migrations/0003_shared_database.sql | 0 .../db => packages/judging-db}/package.json | 4 +- .../db => packages/judging-db}/src/client.ts | 0 .../db => packages/judging-db}/src/env.ts | 0 .../db => packages/judging-db}/src/index.ts | 0 .../db => packages/judging-db}/src/migrate.ts | 0 .../db => packages/judging-db}/src/schema.ts | 0 .../db => packages/judging-db}/src/seed.ts | 2 +- .../db => packages/judging-db}/tsconfig.json | 0 .../judging-server}/Dockerfile | 5 +- packages/judging-server/eslint.config.mjs | 10 + .../judging-server}/package.json | 6 +- .../judging-server}/src/app.test.ts | 0 .../judging-server}/src/app.ts | 4 +- .../judging-server}/src/auth.test.ts | 0 .../judging-server}/src/auth.ts | 0 .../judging-server}/src/bus.test.ts | 0 .../judging-server}/src/bus.ts | 0 .../judging-server}/src/cross-server.check.ts | 2 +- .../judging-server}/src/env.ts | 0 .../judging-server}/src/hub.ts | 0 .../judging-server}/src/index.ts | 4 +- .../judging-server}/src/log.ts | 0 .../judging-server}/src/mail.ts | 0 .../judging-server}/src/metrics.ts | 0 .../judging-server}/src/router.ts | 6 +- .../judging-server}/src/services/arrive.ts | 6 +- .../judging-server}/src/services/catalog.ts | 4 +- .../judging-server}/src/services/compare.ts | 6 +- .../src/services/dispatch.concurrent.test.ts | 4 +- .../judging-server}/src/services/dispatch.ts | 8 +- .../src/services/feedback.test.ts | 0 .../judging-server}/src/services/feedback.ts | 0 .../src/services/floor.test.ts | 2 +- .../judging-server}/src/services/floor.ts | 8 +- .../src/services/flow.integration.test.ts | 4 +- .../judging-server}/src/services/live.ts | 4 +- .../judging-server}/src/services/logs.test.ts | 0 .../judging-server}/src/services/logs.ts | 4 +- .../judging-server}/src/services/outbox.ts | 4 +- .../src/services/overtime.test.ts | 2 +- .../judging-server}/src/services/overtime.ts | 8 +- .../judging-server}/src/services/records.ts | 6 +- .../src/services/redis.integration.test.ts | 0 .../src/services/results.test.ts | 0 .../judging-server}/src/services/results.ts | 6 +- .../judging-server}/src/services/vote.test.ts | 2 +- .../judging-server}/src/services/vote.ts | 8 +- .../judging-server/terraform}/main.tf | 0 .../judging-server}/tsconfig.json | 0 .../judging-server}/vitest.config.ts | 0 pnpm-lock.yaml | 341 +++++++++--------- pnpm-workspace.yaml | 1 - {judging/web => sites/judging}/.gitignore | 0 .../judging/Dockerfile | 13 +- .../app/e/[orgSlug]/[eventSlug]/board.tsx | 0 .../[eventSlug]/feedback/[token]/page.tsx | 0 .../app/e/[orgSlug]/[eventSlug]/page.tsx | 0 .../app/j/[orgSlug]/[eventSlug]/desk.tsx | 0 .../app/j/[orgSlug]/[eventSlug]/page.tsx | 0 {judging/web => sites/judging}/app/layout.tsx | 0 .../app/o/[orgSlug]/[eventSlug]/console.tsx | 0 .../app/o/[orgSlug]/[eventSlug]/editors.tsx | 0 .../app/o/[orgSlug]/[eventSlug]/page.tsx | 0 {judging/web => sites/judging}/app/page.tsx | 0 .../web => sites/judging}/e2e/visit.spec.ts | 0 sites/judging/eslint.config.mjs | 8 + {judging/web => sites/judging}/lib/api.ts | 0 {judging/web => sites/judging}/lib/held.ts | 0 {judging/web => sites/judging}/next.config.ts | 0 {judging/web => sites/judging}/package.json | 2 +- .../judging}/playwright.config.ts | 0 .../judging}/public/manifest.webmanifest | 0 {judging/web => sites/judging}/tsconfig.json | 0 152 files changed, 477 insertions(+), 695 deletions(-) delete mode 100644 .github/workflows/panel.yml create mode 100644 docs/judging/README.md rename {judging/docs => docs/judging}/adr/001-standalone.md (79%) rename {judging/docs => docs/judging}/adr/002-pure-core.md (90%) rename {judging/docs => docs/judging}/adr/003-one-database.md (91%) rename {judging/docs => docs/judging}/api.md (100%) create mode 100644 docs/judging/contributing.md rename {judging/docs => docs/judging}/deploy.md (58%) rename {judging/docs => docs/judging}/rubric.md (100%) rename {judging/docs => docs/judging}/scoring.md (100%) delete mode 100644 judging/.github/ISSUE_TEMPLATE/bug.md delete mode 100644 judging/.github/ISSUE_TEMPLATE/feature.md delete mode 100644 judging/CHANGELOG.md delete mode 100644 judging/CONTRIBUTING.md delete mode 100644 judging/LICENSE delete mode 100644 judging/README.md delete mode 100644 judging/cli/eslint.config.mjs delete mode 100644 judging/core/src/standalone.test.ts delete mode 100644 judging/db/eslint.config.mjs delete mode 100644 judging/infra/docker/docker-compose.yml delete mode 100644 judging/server/eslint.config.mjs delete mode 100644 judging/web/eslint.config.mjs create mode 100644 packages/judging-cli/eslint.config.mjs rename {judging/cli => packages/judging-cli}/package.json (81%) rename {judging/cli => packages/judging-cli}/src/csv.test.ts (100%) rename {judging/cli => packages/judging-cli}/src/csv.ts (100%) rename {judging/cli => packages/judging-cli}/src/main.ts (98%) rename {judging/cli => packages/judging-cli}/src/transfer.ts (98%) rename {judging/cli => packages/judging-cli}/tsconfig.json (100%) rename {judging/core => packages/judging-core}/eslint.config.mjs (68%) rename {judging/core => packages/judging-core}/package.json (96%) rename {judging/core => packages/judging-core}/scripts/emit-extensions.mjs (100%) rename {judging/core => packages/judging-core}/src/aggregate/bayes.test.ts (100%) rename {judging/core => packages/judging-core}/src/aggregate/bayes.ts (100%) rename {judging/core => packages/judging-core}/src/aggregate/blend.test.ts (100%) rename {judging/core => packages/judging-core}/src/aggregate/blend.ts (100%) rename {judging/core => packages/judging-core}/src/aggregate/bradleyTerry.test.ts (100%) rename {judging/core => packages/judging-core}/src/aggregate/bradleyTerry.ts (100%) rename {judging/core => packages/judging-core}/src/aggregate/rank.test.ts (100%) rename {judging/core => packages/judging-core}/src/aggregate/rank.ts (100%) rename {judging/core => packages/judging-core}/src/aggregate/zscore.test.ts (100%) rename {judging/core => packages/judging-core}/src/aggregate/zscore.ts (100%) rename {judging/core => packages/judging-core}/src/comparison.ts (100%) rename {judging/core => packages/judging-core}/src/dispatch.sim.test.ts (100%) rename {judging/core => packages/judging-core}/src/dispatch.test.ts (100%) rename {judging/core => packages/judging-core}/src/dispatch.ts (100%) rename {judging/core => packages/judging-core}/src/distance.test.ts (100%) rename {judging/core => packages/judging-core}/src/distance.ts (100%) rename {judging/core => packages/judging-core}/src/events.test.ts (100%) rename {judging/core => packages/judging-core}/src/events.ts (100%) rename {judging/core => packages/judging-core}/src/index.ts (100%) rename {judging/core => packages/judging-core}/src/match.test.ts (100%) rename {judging/core => packages/judging-core}/src/match.ts (100%) rename {judging/core => packages/judging-core}/src/package.test.ts (90%) rename {judging/core => packages/judging-core}/src/phase.test.ts (100%) rename {judging/core => packages/judging-core}/src/phase.ts (100%) rename {judging/core => packages/judging-core}/src/rubric.test.ts (100%) rename {judging/core => packages/judging-core}/src/rubric.ts (100%) rename {judging/core => packages/judging-core}/src/tables.test.ts (100%) rename {judging/core => packages/judging-core}/src/tables.ts (100%) rename {judging/core => packages/judging-core}/src/timers.test.ts (100%) rename {judging/core => packages/judging-core}/src/timers.ts (100%) rename {judging/core => packages/judging-core}/src/uncertainty.test.ts (100%) rename {judging/core => packages/judging-core}/src/uncertainty.ts (100%) rename {judging/core => packages/judging-core}/tsconfig.build.json (100%) rename {judging/core => packages/judging-core}/tsconfig.json (100%) rename {judging/core => packages/judging-core}/vitest.config.ts (100%) create mode 100644 packages/judging-db/eslint.config.mjs rename {judging/db => packages/judging-db}/migrations/0001_init.sql (100%) rename {judging/db => packages/judging-db}/migrations/0002_feedback_token.sql (100%) rename {judging/db => packages/judging-db}/migrations/0003_shared_database.sql (100%) rename {judging/db => packages/judging-db}/package.json (86%) rename {judging/db => packages/judging-db}/src/client.ts (100%) rename {judging/db => packages/judging-db}/src/env.ts (100%) rename {judging/db => packages/judging-db}/src/index.ts (100%) rename {judging/db => packages/judging-db}/src/migrate.ts (100%) rename {judging/db => packages/judging-db}/src/schema.ts (100%) rename {judging/db => packages/judging-db}/src/seed.ts (99%) rename {judging/db => packages/judging-db}/tsconfig.json (100%) rename {judging/infra/docker => packages/judging-server}/Dockerfile (60%) create mode 100644 packages/judging-server/eslint.config.mjs rename {judging/server => packages/judging-server}/package.json (88%) rename {judging/server => packages/judging-server}/src/app.test.ts (100%) rename {judging/server => packages/judging-server}/src/app.ts (99%) rename {judging/server => packages/judging-server}/src/auth.test.ts (100%) rename {judging/server => packages/judging-server}/src/auth.ts (100%) rename {judging/server => packages/judging-server}/src/bus.test.ts (100%) rename {judging/server => packages/judging-server}/src/bus.ts (100%) rename {judging/server => packages/judging-server}/src/cross-server.check.ts (97%) rename {judging/server => packages/judging-server}/src/env.ts (100%) rename {judging/server => packages/judging-server}/src/hub.ts (100%) rename {judging/server => packages/judging-server}/src/index.ts (95%) rename {judging/server => packages/judging-server}/src/log.ts (100%) rename {judging/server => packages/judging-server}/src/mail.ts (100%) rename {judging/server => packages/judging-server}/src/metrics.ts (100%) rename {judging/server => packages/judging-server}/src/router.ts (99%) rename {judging/server => packages/judging-server}/src/services/arrive.ts (92%) rename {judging/server => packages/judging-server}/src/services/catalog.ts (99%) rename {judging/server => packages/judging-server}/src/services/compare.ts (90%) rename {judging/server => packages/judging-server}/src/services/dispatch.concurrent.test.ts (88%) rename {judging/server => packages/judging-server}/src/services/dispatch.ts (98%) rename {judging/server => packages/judging-server}/src/services/feedback.test.ts (100%) rename {judging/server => packages/judging-server}/src/services/feedback.ts (100%) rename {judging/server => packages/judging-server}/src/services/floor.test.ts (94%) rename {judging/server => packages/judging-server}/src/services/floor.ts (91%) rename {judging/server => packages/judging-server}/src/services/flow.integration.test.ts (97%) rename {judging/server => packages/judging-server}/src/services/live.ts (96%) rename {judging/server => packages/judging-server}/src/services/logs.test.ts (100%) rename {judging/server => packages/judging-server}/src/services/logs.ts (85%) rename {judging/server => packages/judging-server}/src/services/outbox.ts (93%) rename {judging/server => packages/judging-server}/src/services/overtime.test.ts (93%) rename {judging/server => packages/judging-server}/src/services/overtime.ts (87%) rename {judging/server => packages/judging-server}/src/services/records.ts (98%) rename {judging/server => packages/judging-server}/src/services/redis.integration.test.ts (100%) rename {judging/server => packages/judging-server}/src/services/results.test.ts (100%) rename {judging/server => packages/judging-server}/src/services/results.ts (98%) rename {judging/server => packages/judging-server}/src/services/vote.test.ts (96%) rename {judging/server => packages/judging-server}/src/services/vote.ts (97%) rename {judging/infra/gcp => packages/judging-server/terraform}/main.tf (100%) rename {judging/server => packages/judging-server}/tsconfig.json (100%) rename {judging/server => packages/judging-server}/vitest.config.ts (100%) rename {judging/web => sites/judging}/.gitignore (100%) rename judging/infra/docker/Dockerfile.web => sites/judging/Dockerfile (54%) rename {judging/web => sites/judging}/app/e/[orgSlug]/[eventSlug]/board.tsx (100%) rename {judging/web => sites/judging}/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx (100%) rename {judging/web => sites/judging}/app/e/[orgSlug]/[eventSlug]/page.tsx (100%) rename {judging/web => sites/judging}/app/j/[orgSlug]/[eventSlug]/desk.tsx (100%) rename {judging/web => sites/judging}/app/j/[orgSlug]/[eventSlug]/page.tsx (100%) rename {judging/web => sites/judging}/app/layout.tsx (100%) rename {judging/web => sites/judging}/app/o/[orgSlug]/[eventSlug]/console.tsx (100%) rename {judging/web => sites/judging}/app/o/[orgSlug]/[eventSlug]/editors.tsx (100%) rename {judging/web => sites/judging}/app/o/[orgSlug]/[eventSlug]/page.tsx (100%) rename {judging/web => sites/judging}/app/page.tsx (100%) rename {judging/web => sites/judging}/e2e/visit.spec.ts (100%) create mode 100644 sites/judging/eslint.config.mjs rename {judging/web => sites/judging}/lib/api.ts (100%) rename {judging/web => sites/judging}/lib/held.ts (100%) rename {judging/web => sites/judging}/next.config.ts (100%) rename {judging/web => sites/judging}/package.json (94%) rename {judging/web => sites/judging}/playwright.config.ts (100%) rename {judging/web => sites/judging}/public/manifest.webmanifest (100%) rename {judging/web => sites/judging}/tsconfig.json (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0d58879..899084da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ name: CI/CD on: push: branches: ["main", "dev"] + tags: ["panel-v*"] pull_request: concurrency: @@ -107,3 +108,32 @@ jobs: projectId: dsgt-website channelId: live entryPoint: "." + + publish-judging: + name: Publish judging images + needs: verify + if: startsWith(github.ref, 'refs/tags/panel-v') + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: . + file: packages/judging-server/Dockerfile + push: true + tags: ghcr.io/${{ github.repository }}/panel-server:${{ github.ref_name }} + - uses: docker/build-push-action@v6 + with: + context: . + file: sites/judging/Dockerfile + push: true + build-args: NEXT_PUBLIC_PANEL_URL=${{ vars.NEXT_PUBLIC_PANEL_URL || 'http://localhost:8787' }} + tags: ghcr.io/${{ github.repository }}/panel-web:${{ github.ref_name }} diff --git a/.github/workflows/panel.yml b/.github/workflows/panel.yml deleted file mode 100644 index 69d4aed6..00000000 --- a/.github/workflows/panel.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: panel - -on: - push: - paths: - - "judging/**" - - ".github/workflows/panel.yml" - pull_request: - paths: - - "judging/**" - - ".github/workflows/panel.yml" - -jobs: - verify: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm --filter @panel/core --filter @panel/db --filter @panel/server --filter @panel/cli --filter @panel/web lint - - run: pnpm --filter @panel/core --filter @panel/db --filter @panel/server --filter @panel/cli --filter @panel/web typecheck - - run: pnpm --filter @panel/core build - - run: pnpm --filter @panel/web build - - run: pnpm --filter @panel/core --filter @panel/server --filter @panel/cli test - - publish: - if: startsWith(github.ref, 'refs/tags/panel-v') - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v6 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/build-push-action@v6 - with: - context: . - file: judging/infra/docker/Dockerfile - push: true - tags: ghcr.io/${{ github.repository }}/panel-server:${{ github.ref_name }} - - uses: docker/build-push-action@v6 - with: - context: . - file: judging/infra/docker/Dockerfile.web - push: true - build-args: NEXT_PUBLIC_PANEL_URL=${{ vars.NEXT_PUBLIC_PANEL_URL || 'http://localhost:8787' }} - tags: ghcr.io/${{ github.repository }}/panel-web:${{ github.ref_name }} - - release-core: - if: startsWith(github.ref, 'refs/tags/panel-v') - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - cache: pnpm - registry-url: https://registry.npmjs.org - - run: pnpm install --frozen-lockfile - - run: pnpm --filter @panel/core build - - working-directory: judging/core - run: npm pkg set private=false && npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index e1b9457a..cbdd6f3f 100644 --- a/.gitignore +++ b/.gitignore @@ -54,8 +54,8 @@ next-env.d.ts *.db *.db-journal migrations/ -!judging/db/migrations/ -!judging/db/migrations/** +!packages/judging-db/migrations/ +!packages/judging-db/migrations/** # Editor .vscode/* diff --git a/PLAN.md b/PLAN.md index 00cca049..ba92e79f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2,11 +2,9 @@ **Repo:** `query` · **Drafted:** 2026-10-07 · **Working name:** `panel` (rename freely) -A standalone hackathon judging platform, built inside this monorepo under -`judging/`, with no knowledge of DSGT in it. Hacklytics is its first user -through an adapter. When it is stable it leaves this repo with -`git subtree split --prefix=judging` and ships as an open-source project with -its own licence, docs, CI and Docker image. +A hackathon judging platform built as ordinary workspaces in this monorepo +(`packages/judging-*`, `sites/judging`, scope `@query/judging-*`). Hacklytics +is its first user. It is not being split into its own repository. The current judging code in `packages/api/src/routers/judge` and `sites/mainweb` keeps running until cutover, then is deleted. @@ -42,37 +40,30 @@ per-instance and polling for every live view. ## 1. Layout ``` -judging/ - core/ @panel/core pure TS, zero runtime deps. rubric validation, - dispatch, distance, uncertainty, aggregators, - phase state machine, timers - db/ @panel/db Drizzle schema, SQL migrations (generated, - committed), seed, demo data - server/ @panel/server Node service: tRPC over HTTP, OpenAPI mirror, - WebSocket hub, auth adapters, outbox + webhooks, - Prometheus metrics - web/ @panel/web Next.js: judge PWA, admin console, live board, - public leaderboard, team feedback - cli/ @panel/cli import/export/seed/migrate/doctor - infra/ - docker/ Dockerfiles, docker-compose.yml (full stack) - gcp/ optional Terraform module (Cloud Run, Memorystore, - Secret Manager, Monitoring) - docs/ architecture, ADRs, deploy, API, rubric guide - README.md LICENSE (MIT) CONTRIBUTING.md CHANGELOG.md +packages/judging-core/ @query/judging-core pure TS, zero runtime deps. rubric + validation, dispatch, distance, + uncertainty, aggregators, phase + state machine, timers +packages/judging-db/ @query/judging-db Drizzle schema, SQL migrations + (generated, committed), seed, demo +packages/judging-server/ @query/judging-server Node service: tRPC over HTTP, + OpenAPI mirror, WebSocket hub, auth + adapters, outbox + webhooks, + Prometheus metrics. Dockerfile and + optional Terraform (terraform/) +packages/judging-cli/ @query/judging-cli import/export/seed/migrate/doctor +sites/judging/ @query/judging-web Next.js: judge PWA, admin console, + live board, public leaderboard, + team feedback. Dockerfile +docs/judging/ ADRs, deploy, API, rubric guide ``` -Registered as workspaces in `pnpm-workspace.yaml`. Internal imports are -`@panel/*` only; nothing under `judging/` imports from `packages/*` or -`sites/*`. A lint rule (`no-restricted-imports`) enforces the boundary, which is -what makes the later extraction a one-command job. - -The reverse direction, `packages/api` importing `@panel/*`, is allowed and is -how the club site integrates (§7). +Lint, typecheck, test and build run with the rest of the repo in `ci.yml`. +`packages/api` imports `@query/judging-*` like any other workspace (§7). --- -## 2. Domain model (`@panel/db`) +## 2. Domain model (`@query/judging-db`) Multi-tenant from day one so one deployment can host many events; a single-event self-host simply has one org and one event. @@ -150,13 +141,13 @@ Design notes: admin can compute twice and compare before publishing one. - `external_id` on project and judge is how an integrator (the club site, a Devpost CSV, a Google Form) keeps its own identity in sync. -- Migrations are generated SQL files committed under `judging/db/migrations`, +- Migrations are generated SQL files committed under `packages/judging-db/migrations`, applied with `panel migrate`. No `drizzle-kit push` in this product; a self-hoster needs reproducible, reviewable migrations. --- -## 3. The core (`@panel/core`) +## 3. The core (`@query/judging-core`) No IO, no dates from the clock (every function takes `now`), no randomness without an injected source. Each module has a fixture-based test file. @@ -185,7 +176,7 @@ export. --- -## 4. Server (`@panel/server`) +## 4. Server (`@query/judging-server`) Node 22, Hono as the HTTP shell, tRPC router mounted on it, `trpc-openapi` exposing the same procedures as REST with a generated OpenAPI document for @@ -256,7 +247,7 @@ from `../PLAN.md` W8. --- -## 5. Web (`@panel/web`) +## 5. Web (`@query/judging-web`) Next.js app, standalone output, one Docker image. Branding from `organization.branding`; no hardcoded name or colours. @@ -283,7 +274,7 @@ Next.js app, standalone output, one Docker image. Branding from --- -## 6. CLI (`@panel/cli`) +## 6. CLI (`@query/judging-cli`) `panel migrate`, `panel seed demo` (an event with 40 projects, 6 judges, three tracks, a sponsor rubric), `panel import projects ` (column map in @@ -302,7 +293,7 @@ The club adapter does not use it; it imports through the API. Thin and replaceable. - `packages/api/src/routers/judging.ts` (new, small): on `promoteSubmissions` - calls `@panel/server` `project.upsert` with `external_id = hackathon_project.id`; + calls `@query/judging-server` `project.upsert` with `external_id = hackathon_project.id`; on judge approval calls `judge.upsert`; on publish, pulls results and feedback tokens back into `hackathon_result` so `/hackathons/[id]` keeps rendering from its own tables. @@ -311,10 +302,10 @@ Thin and replaceable. - Old `routers/judge/*` and the judging UI stay behind a feature flag per edition (`hackathon.judging_backend = legacy | panel`) until one event has run on the new system, then are deleted. -- Deploy: `@panel/server` and `@panel/web` as two Cloud Run services in the +- Deploy: `@query/judging-server` and `@query/judging-web` as two Cloud Run services in the same GCP project, Postgres as a second Neon database (not a schema in the club DB, so the extraction is clean), Memorystore Redis only if more than - one server instance is wanted. Terraform under `judging/infra/gcp`. + one server instance is wanted. Terraform under `packages/judging-server/terraform`. --- @@ -328,14 +319,14 @@ Each phase ships green on `pnpm test`, `lint --max-warnings 0`, `typecheck`, - Workspaces, tsconfig, eslint boundary rule, `judging/README.md` with the vision, MIT `LICENSE`, ADR-001 (why standalone), ADR-002 (pure core). - Port `pickNext`, `isLive`, `isPastCutoff`, `projectMatchesTrack`, - `zNormalize`, the Bayesian step into `@panel/core` as parametrised + `zNormalize`, the Bayesian step into `@query/judging-core` as parametrised functions. Bring `dispatch.test.ts` cases with them. - Add `distance`, `phase`, `rubric`, `bradleyTerry`, `blend`, `uncertainty` with tests. -**Verify:** `@panel/core` has no dependencies and 100% of exported functions +**Verify:** `@query/judging-core` has no dependencies and 100% of exported functions have a test. The legacy `rankings.ts` output on the seed equals -`@panel/core` `rank()` output byte for byte at `pairwise_weight = 0`. +`@query/judging-core` `rank()` output byte for byte at `pairwise_weight = 0`. ### Phase 1 — Data and server (2 weeks) @@ -398,19 +389,18 @@ before publish and for the wrong token. **Verify:** the legacy and new systems produce the same placings on a replay of an old event's votes. Post-event retro recorded as ADR-00x. -### Phase 6 — Open-source release (1 week) +### Phase 6 — Cleanup (1 week) -- `CONTRIBUTING.md`, issue templates, GitHub Actions (test, lint, typecheck, - build, Docker publish to GHCR, `@panel/core` to npm on tag). -- Docs: quick start (`docker compose up`, `panel seed demo`), deploy guide, - API reference from OpenAPI, rubric design guide, scoring explainer. -- `git subtree split --prefix=judging -b panel-main`, push to the new repo, - replace `judging/` here with a git submodule or a pinned npm dependency. +- Docs: quick start (`docker compose --profile judging up`, `panel seed demo`), + deploy guide, API reference from OpenAPI, rubric design guide, scoring + explainer. - Delete `routers/judge/*` and the legacy judging UI from the club site. -**Verify:** a fresh clone of the new repo runs the demo in under five -minutes with nothing but Docker. No string "DSGT", "Hacklytics" or -"datasciencegt" anywhere under `judging/`. +**Verify:** a fresh clone runs the demo in under five minutes with nothing +but Docker. + +Dropped: splitting judging into its own repository. It stays in this +monorepo. --- @@ -418,8 +408,8 @@ minutes with nothing but Docker. No string "DSGT", "Hacklytics" or **Taken** -- Standalone product in `judging/`, extracted later by subtree. Not a schema - inside the club database, so the extraction has no data entanglement. +- Judging lives in the monorepo as `@query/judging-*` workspaces on the club + Postgres (ADR-003). No later extraction. - WebSockets on our own server with a bus adapter; Redis optional. - Terraform for GCP as an optional module; Docker Compose is the primary documented deploy. diff --git a/docker-compose.yml b/docker-compose.yml index 8feef538..32676ea2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -116,6 +116,29 @@ services: timeout: 5s retries: 10 + # ── Judging ────────────────────────────────────────────────────────────── + # + # The judging server on the same Postgres as the club site: + # + # docker compose --profile judging up -d --build + + judging-server: + build: + context: . + dockerfile: packages/judging-server/Dockerfile + container_name: monorepo-judging-server + profiles: ["judging"] + environment: + DATABASE_URL: postgresql://postgres:postgres@db:5432/neondb + PANEL_JWT_SECRET: dev-only-change-me + PANEL_DEV_AUTH: "1" + PORT: "8787" + ports: + - "8787:8787" + depends_on: + db: + condition: service_healthy + volumes: pgdata: promdata: diff --git a/docs/judging/README.md b/docs/judging/README.md new file mode 100644 index 00000000..613eb7b7 --- /dev/null +++ b/docs/judging/README.md @@ -0,0 +1,55 @@ +# Panel + +A hackathon judging product: its own data, its own API, its own web app. +Rubrics, tracks, timers, rooms, and dispatch strategy are rows, not code. +Postgres and Node are enough to run it. Set `REDIS_URL` when more than one +server process should share live updates. Without it, each process fans out +in memory, and a client that misses a message reloads the same state from +the next snapshot. + +Panel is part of this monorepo. Its packages are ordinary workspaces next to +the club's, and `packages/api` calls `@query/judging-core` directly. + +## Layout + +| Path | Package | What it is | +| --- | --- | --- | +| `packages/judging-core/` | `@query/judging-core` | Pure functions. No I/O, no clock, no runtime dependencies. | +| `packages/judging-db/` | `@query/judging-db` | Schema, committed SQL migrations, demo seed. | +| `packages/judging-server/` | `@query/judging-server` | HTTP API: dispatch, votes, health, metrics. | +| `sites/judging/` | `@query/judging-web` | Judge desk, organizer console, public board. | +| `packages/judging-cli/` | `@query/judging-cli` | `panel migrate`, `panel seed demo`, `panel doctor`. | + +## Run the core + +From the repository root: + +``` +pnpm --filter @query/judging-core test +pnpm --filter @query/judging-core typecheck +pnpm --filter @query/judging-core lint +``` + +`pnpm test` includes `@query/judging-core` and `@query/judging-server`. + +## Database and server + +Postgres is the one database the rest of the monorepo uses (`DATABASE_URL`). Panel migrations add its tables there. `PANEL_DATABASE_URL` is only a fallback when `DATABASE_URL` is unset. + +``` +docker compose --profile judging up -d --build +``` + +On the host, pointed at that database: + +``` +$env:DATABASE_URL = "postgresql://postgres:postgres@localhost:5433/neondb" +$env:PANEL_JWT_SECRET = "dev-only-change-me" +pnpm --filter @query/judging-cli panel migrate +pnpm --filter @query/judging-cli panel seed demo +pnpm --filter @query/judging-cli panel doctor +``` + +`panel doctor` checks Postgres, pending migrations, and `PANEL_JWT_SECRET`. Redis is reported and not required. + +The HTTP server is Hono. tRPC is mounted at `/trpc`. `GET /healthz` is the process, `GET /readyz` is Postgres, `GET /metrics` is Prometheus. `GET /openapi.json` lists the REST routes. How to authenticate is in `docs/judging/api.md`. Magic-link codes are returned in the response only when `PANEL_DEV_AUTH=1`, which is how the compose stack signs in without SMTP. diff --git a/judging/docs/adr/001-standalone.md b/docs/judging/adr/001-standalone.md similarity index 79% rename from judging/docs/adr/001-standalone.md rename to docs/judging/adr/001-standalone.md index dd621080..f463c613 100644 --- a/judging/docs/adr/001-standalone.md +++ b/docs/judging/adr/001-standalone.md @@ -2,7 +2,9 @@ ## Status -Accepted +Superseded. Panel's packages are ordinary monorepo workspaces +(`packages/judging-*`, `sites/judging`) under the `@query/*` scope. The import +boundary and the plan to split it out were dropped. ## Decision diff --git a/judging/docs/adr/002-pure-core.md b/docs/judging/adr/002-pure-core.md similarity index 90% rename from judging/docs/adr/002-pure-core.md rename to docs/judging/adr/002-pure-core.md index 1421615c..f5bc4aa5 100644 --- a/judging/docs/adr/002-pure-core.md +++ b/docs/judging/adr/002-pure-core.md @@ -8,7 +8,7 @@ Accepted Every decision Panel makes — the next table, whether a visit is still live, whether a score is valid, how a set of votes becomes a ranking — is a function -in `@panel/core`. Those functions take plain data, including `now` and a +in `@query/judging-core`. Those functions take plain data, including `now` and a `random` source, and return plain data. They do not read the clock, generate randomness, or touch a database, the network, or the filesystem. diff --git a/judging/docs/adr/003-one-database.md b/docs/judging/adr/003-one-database.md similarity index 91% rename from judging/docs/adr/003-one-database.md rename to docs/judging/adr/003-one-database.md index 90a8dc72..aa2d80d5 100644 --- a/judging/docs/adr/003-one-database.md +++ b/docs/judging/adr/003-one-database.md @@ -22,5 +22,5 @@ judge UI, wait until an event has run on panel. Sign-in mail waits on The host is the existing app. A second database and a second API fleet were the extraction plan, and they are not how this checkout runs. The replay of -the published score fixture still matches `@panel/core` `rank()` at pairwise +the published score fixture still matches `@query/judging-core` `rank()` at pairwise weight 0, so the scoring check does not need that event. diff --git a/judging/docs/api.md b/docs/judging/api.md similarity index 100% rename from judging/docs/api.md rename to docs/judging/api.md diff --git a/docs/judging/contributing.md b/docs/judging/contributing.md new file mode 100644 index 00000000..3e8919c1 --- /dev/null +++ b/docs/judging/contributing.md @@ -0,0 +1,28 @@ +# Contributing + +Panel is `packages/judging-*` and `sites/judging`. `pnpm lint`, `pnpm typecheck`, and `pnpm test` at the root cover it with the rest of the repository. + +## Checks + +From the repository root: + +``` +pnpm --filter @query/judging-core test +pnpm --filter @query/judging-server test +pnpm --filter @query/judging-cli test +pnpm --filter @query/judging-core lint +pnpm --filter @query/judging-server lint +pnpm --filter @query/judging-web lint +pnpm --filter @query/judging-db typecheck +pnpm --filter @query/judging-server typecheck +pnpm --filter @query/judging-web typecheck +pnpm --filter @query/judging-cli typecheck +``` + +## Migrations + +Schema changes are SQL files in `packages/judging-db/migrations`, applied with `panel migrate`. Do not rename or drop a column in the same change that ships code reading the new shape. + +## Pull requests + +Describe the behaviour a judge or an organizer will see. Include the command you ran. diff --git a/judging/docs/deploy.md b/docs/judging/deploy.md similarity index 58% rename from judging/docs/deploy.md rename to docs/judging/deploy.md index 5b28feed..706406e2 100644 --- a/judging/docs/deploy.md +++ b/docs/judging/deploy.md @@ -3,22 +3,22 @@ Docker Compose is the deploy to start from. From the repository root: ``` -docker compose -f judging/infra/docker/docker-compose.yml up --build +docker compose --profile judging up -d --build ``` Then, against the published Postgres port: ``` -DATABASE_URL=postgres://panel:panel@localhost:54329/panel +DATABASE_URL=postgresql://postgres:postgres@localhost:5433/neondb PANEL_JWT_SECRET=dev-only-change-me -pnpm --filter @panel/cli panel migrate -pnpm --filter @panel/cli panel seed demo -pnpm --filter @panel/cli panel doctor +pnpm --filter @query/judging-cli panel migrate +pnpm --filter @query/judging-cli panel seed demo +pnpm --filter @query/judging-cli panel doctor ``` `panel doctor` must report postgres, migrations, and jwt as ok. Set `DATABASE_URL` to the same Postgres the club app uses. `PANEL_DATABASE_URL` is only read when `DATABASE_URL` is unset. The stack is one API server. Leave `REDIS_URL` unset so live updates stay in that process. Clients that miss a socket message recover from `GET /v1/live`. -The optional GCP module is `judging/infra/gcp`. It is not required to run the stack. The web image is `judging/infra/docker/Dockerfile.web`. Pass `NEXT_PUBLIC_PANEL_URL` at build time so the desk calls the API. +The optional GCP module is `packages/judging-server/terraform`. It is not required to run the stack. The server image is `packages/judging-server/Dockerfile` and the web image is `sites/judging/Dockerfile`. Pass `NEXT_PUBLIC_PANEL_URL` at build time so the desk calls the API. Set `PANEL_SMTP_URL` to an SMTP URL when sign-in codes should be emailed. Without it, `PANEL_DEV_AUTH=1` returns the code in the response. OIDC waits diff --git a/judging/docs/rubric.md b/docs/judging/rubric.md similarity index 100% rename from judging/docs/rubric.md rename to docs/judging/rubric.md diff --git a/judging/docs/scoring.md b/docs/judging/scoring.md similarity index 100% rename from judging/docs/scoring.md rename to docs/judging/scoring.md diff --git a/judging/.github/ISSUE_TEMPLATE/bug.md b/judging/.github/ISSUE_TEMPLATE/bug.md deleted file mode 100644 index 6da6dc23..00000000 --- a/judging/.github/ISSUE_TEMPLATE/bug.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -name: Bug -description: Something in panel behaved wrongly -labels: bug ---- - -## What happened - -## What you expected - -## How to reproduce diff --git a/judging/.github/ISSUE_TEMPLATE/feature.md b/judging/.github/ISSUE_TEMPLATE/feature.md deleted file mode 100644 index 41836efd..00000000 --- a/judging/.github/ISSUE_TEMPLATE/feature.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: Feature -description: A change to how panel runs an event -labels: enhancement ---- - -## What should change - -## Who it is for diff --git a/judging/CHANGELOG.md b/judging/CHANGELOG.md deleted file mode 100644 index 2f5090c7..00000000 --- a/judging/CHANGELOG.md +++ /dev/null @@ -1,8 +0,0 @@ -# Panel - -## 0.0.0 - -- Pure core for dispatch, rubrics, phases, and ranking. -- Postgres schema with committed SQL migrations, a demo seed, and `panel` commands for migrate, seed, import, export, and doctor. -- HTTP server for judge sessions, results, health, metrics, and a websocket bus. -- Judge desk, organizer console, public board, and a feedback link. diff --git a/judging/CONTRIBUTING.md b/judging/CONTRIBUTING.md deleted file mode 100644 index 6df5c2bf..00000000 --- a/judging/CONTRIBUTING.md +++ /dev/null @@ -1,28 +0,0 @@ -# Contributing - -Panel is the tree under `judging/`. It does not import the rest of this repository. - -## Checks - -From the repository root: - -``` -pnpm --filter @panel/core test -pnpm --filter @panel/server test -pnpm --filter @panel/cli test -pnpm --filter @panel/core lint -pnpm --filter @panel/server lint -pnpm --filter @panel/web lint -pnpm --filter @panel/db typecheck -pnpm --filter @panel/server typecheck -pnpm --filter @panel/web typecheck -pnpm --filter @panel/cli typecheck -``` - -## Migrations - -Schema changes are SQL files in `judging/db/migrations`, applied with `panel migrate`. Do not rename or drop a column in the same change that ships code reading the new shape. - -## Pull requests - -Describe the behaviour a judge or an organizer will see. Include the command you ran. diff --git a/judging/LICENSE b/judging/LICENSE deleted file mode 100644 index 354a5c40..00000000 --- a/judging/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Panel contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/judging/README.md b/judging/README.md deleted file mode 100644 index 1a23e22f..00000000 --- a/judging/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# Panel - -A hackathon judging product: its own data, its own API, its own web app. -Rubrics, tracks, timers, rooms, and dispatch strategy are rows, not code. -Postgres and Node are enough to run it. Set `REDIS_URL` when more than one -server process should share live updates. Without it, each process fans out -in memory, and a client that misses a message reloads the same state from -the next snapshot. - -`judging/` does not import the host application. The host may call `@panel/*`. -When the product is stable it leaves this repository with -`git subtree split --prefix=judging`. - -## Layout - -| Path | Package | What it is | -| --- | --- | --- | -| `core/` | `@panel/core` | Pure functions. No I/O, no clock, no runtime dependencies. | -| `db/` | `@panel/db` | Schema, committed SQL migrations, demo seed. | -| `server/` | `@panel/server` | HTTP API: dispatch, votes, health, metrics. | -| `web/` | `@panel/web` | Judge desk, organizer console, public board. | -| `cli/` | `@panel/cli` | `panel migrate`, `panel seed demo`, `panel doctor`. | - -## Run the core - -From the repository root: - -``` -pnpm --filter @panel/core test -pnpm --filter @panel/core typecheck -pnpm --filter @panel/core lint -``` - -`pnpm test` includes `@panel/core` and `@panel/server`. - -## Database and server - -Postgres is the one database the rest of the monorepo uses (`DATABASE_URL`). Panel migrations add its tables there. `PANEL_DATABASE_URL` is only a fallback when `DATABASE_URL` is unset. - -``` -docker compose -f judging/infra/docker/docker-compose.yml up --build -``` - -On the host, pointed at that database: - -``` -$env:PANEL_DATABASE_URL = "postgres://panel:panel@localhost:54329/panel" -$env:PANEL_JWT_SECRET = "dev-only-change-me" -pnpm --filter @panel/cli panel migrate -pnpm --filter @panel/cli panel seed demo -pnpm --filter @panel/cli panel doctor -``` - -`panel doctor` checks Postgres, pending migrations, and `PANEL_JWT_SECRET`. Redis is reported and not required. - -The HTTP server is Hono. tRPC is mounted at `/trpc`. `GET /healthz` is the process, `GET /readyz` is Postgres, `GET /metrics` is Prometheus. `GET /openapi.json` lists the REST routes. How to authenticate is in `docs/api.md`. Magic-link codes are returned in the response only when `PANEL_DEV_AUTH=1`, which is how the compose stack signs in without SMTP. diff --git a/judging/cli/eslint.config.mjs b/judging/cli/eslint.config.mjs deleted file mode 100644 index a96e7a28..00000000 --- a/judging/cli/eslint.config.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import { config } from "@query/eslint-config/base"; - -/** @type {import("eslint").Linter.Config[]} */ -export default [ - ...config, - { - files: ["src/**/*.ts"], - rules: { "no-console": "off" }, - }, - { - files: ["src/**/*.ts"], - rules: { - "no-restricted-imports": [ - "error", - { - patterns: [ - { - group: ["@query", "@query/*"], - message: - "judging/ is a standalone product. Import from @panel/* only.", - }, - ], - }, - ], - }, - }, -]; diff --git a/judging/core/src/standalone.test.ts b/judging/core/src/standalone.test.ts deleted file mode 100644 index 166726ab..00000000 --- a/judging/core/src/standalone.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { readdir, readFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -const root = join(dirname(fileURLToPath(import.meta.url)), "../.."); - -describe("standalone tree", () => { - it("does not mention the host club", async () => { - const hits: string[] = []; - await walk(root, hits); - expect(hits).toEqual([]); - }); -}); - -async function walk(dir: string, hits: string[]) { - const entries = await readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.name === "node_modules" || entry.name === ".next" || entry.name === "dist") { - continue; - } - const path = join(dir, entry.name); - if (entry.isDirectory()) { - await walk(path, hits); - continue; - } - if (!/\.(ts|tsx|md|sql|yml|json|tf)$/.test(entry.name)) continue; - const text = await readFile(path, "utf8"); - const needles = ["DS" + "GT", "Hack" + "lytics", "datascience" + "gt"]; - if (needles.some((needle) => text.toLowerCase().includes(needle.toLowerCase()))) { - hits.push(path); - } - } -} diff --git a/judging/db/eslint.config.mjs b/judging/db/eslint.config.mjs deleted file mode 100644 index 827bfcb7..00000000 --- a/judging/db/eslint.config.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import { config } from "@query/eslint-config/base"; - -/** @type {import("eslint").Linter.Config[]} */ -export default [ - ...config, - { - files: ["src/**/*.ts"], - ignores: ["src/env.ts"], - rules: { - "no-restricted-imports": [ - "error", - { - patterns: [ - { - group: ["@query", "@query/*"], - message: - "judging/ is a standalone product. Import from @panel/* only.", - }, - ], - }, - ], - }, - }, -]; diff --git a/judging/infra/docker/docker-compose.yml b/judging/infra/docker/docker-compose.yml deleted file mode 100644 index 4da40215..00000000 --- a/judging/infra/docker/docker-compose.yml +++ /dev/null @@ -1,30 +0,0 @@ -services: - postgres: - image: postgres:16 - environment: - POSTGRES_USER: panel - POSTGRES_PASSWORD: panel - POSTGRES_DB: panel - ports: - - "54329:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U panel"] - interval: 2s - timeout: 2s - retries: 20 - - server: - build: - context: ../../.. - dockerfile: judging/infra/docker/Dockerfile - environment: - PANEL_DATABASE_URL: postgres://panel:panel@postgres:5432/panel - DATABASE_URL: postgres://panel:panel@postgres:5432/panel - PANEL_JWT_SECRET: dev-only-change-me - PANEL_DEV_AUTH: "1" - PORT: "8787" - ports: - - "8787:8787" - depends_on: - postgres: - condition: service_healthy diff --git a/judging/server/eslint.config.mjs b/judging/server/eslint.config.mjs deleted file mode 100644 index d466eefb..00000000 --- a/judging/server/eslint.config.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import { config } from "@query/eslint-config/base"; - -/** @type {import("eslint").Linter.Config[]} */ -export default [ - ...config, - { - files: ["src/log.ts", "src/index.ts"], - rules: { "no-console": "off" }, - }, - { - files: ["src/**/*.ts"], - ignores: ["src/env.ts", "src/**/*.test.ts"], - rules: { - "no-restricted-imports": [ - "error", - { - patterns: [ - { - group: ["@query", "@query/*"], - message: - "judging/ is a standalone product. Import from @panel/* only.", - }, - ], - }, - ], - }, - }, -]; diff --git a/judging/web/eslint.config.mjs b/judging/web/eslint.config.mjs deleted file mode 100644 index 1236da8c..00000000 --- a/judging/web/eslint.config.mjs +++ /dev/null @@ -1,23 +0,0 @@ -import { config } from "@query/eslint-config/base"; - -/** @type {import("eslint").Linter.Config[]} */ -export default [ - ...config, - { - files: ["**/*.ts", "**/*.tsx"], - rules: { - "no-restricted-imports": [ - "error", - { - patterns: [ - { - group: ["@query", "@query/*"], - message: - "judging/ is a standalone product. Import from @panel/* only.", - }, - ], - }, - ], - }, - }, -]; diff --git a/package.json b/package.json index 6d7ed480..3e16ba56 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint": "turbo run lint", "format": "prettier --write .", "typecheck": "turbo run typecheck", - "test": "vitest run packages/api packages/auth packages/db sites/mainweb/lib judging/core judging/server judging/cli" + "test": "vitest run packages/api packages/auth packages/db sites/mainweb/lib packages/judging-core packages/judging-server packages/judging-cli" }, "dependencies": { "next": "16.3.7", diff --git a/packages/api/package.json b/packages/api/package.json index 4f8b792d..2290fd44 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -42,6 +42,7 @@ "zod": "4.6.5" }, "devDependencies": { + "@query/judging-core": "workspace:*", "@query/tsconfig": "workspace:*", "@types/sanitize-html": "^2.16.0", "typescript": "7.0.2", diff --git a/packages/api/src/routers/judge/replay.test.ts b/packages/api/src/routers/judge/replay.test.ts index 5a3fa563..7d844a15 100644 --- a/packages/api/src/routers/judge/replay.test.ts +++ b/packages/api/src/routers/judge/replay.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { rank } from "../../../../../judging/core/src/index"; +import { rank } from "@query/judging-core"; import { weightProjects } from "./weight"; const projects = [ diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index 1bcd806e..0077a323 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -5,5 +5,5 @@ "rootDir": "src" }, "include": ["src", "src/.internal-tests/**/*.ts"], - "exclude": ["node_modules", "dist", "src/routers/judge/replay.test.ts"] + "exclude": ["node_modules", "dist"] } diff --git a/packages/judging-cli/eslint.config.mjs b/packages/judging-cli/eslint.config.mjs new file mode 100644 index 00000000..b1dfd25a --- /dev/null +++ b/packages/judging-cli/eslint.config.mjs @@ -0,0 +1,10 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...config, + { + files: ["src/**/*.ts"], + rules: { "no-console": "off" }, + }, +]; diff --git a/judging/cli/package.json b/packages/judging-cli/package.json similarity index 81% rename from judging/cli/package.json rename to packages/judging-cli/package.json index 027dc16c..36268c76 100644 --- a/judging/cli/package.json +++ b/packages/judging-cli/package.json @@ -1,5 +1,5 @@ { - "name": "@panel/cli", + "name": "@query/judging-cli", "version": "0.0.0", "private": true, "type": "module", @@ -10,8 +10,8 @@ "test": "node ../../node_modules/vitest/vitest.mjs run src/csv.test.ts" }, "dependencies": { - "@panel/db": "workspace:*", - "@panel/server": "workspace:*", + "@query/judging-db": "workspace:*", + "@query/judging-server": "workspace:*", "drizzle-orm": "0.45.3", "tsx": "^4.23.15" }, diff --git a/judging/cli/src/csv.test.ts b/packages/judging-cli/src/csv.test.ts similarity index 100% rename from judging/cli/src/csv.test.ts rename to packages/judging-cli/src/csv.test.ts diff --git a/judging/cli/src/csv.ts b/packages/judging-cli/src/csv.ts similarity index 100% rename from judging/cli/src/csv.ts rename to packages/judging-cli/src/csv.ts diff --git a/judging/cli/src/main.ts b/packages/judging-cli/src/main.ts similarity index 98% rename from judging/cli/src/main.ts rename to packages/judging-cli/src/main.ts index 5e9b0df8..a7d00297 100644 --- a/judging/cli/src/main.ts +++ b/packages/judging-cli/src/main.ts @@ -6,8 +6,8 @@ import { databaseUrl, requireDatabaseUrl, seedDemo, -} from "@panel/db"; -import { drainOutbox } from "@panel/server/outbox"; +} from "@query/judging-db"; +import { drainOutbox } from "@query/judging-server/outbox"; import { exportResults, importProjects } from "./transfer"; const [command, arg] = process.argv.slice(2); diff --git a/judging/cli/src/transfer.ts b/packages/judging-cli/src/transfer.ts similarity index 98% rename from judging/cli/src/transfer.ts rename to packages/judging-cli/src/transfer.ts index 8daa0c5c..4469539f 100644 --- a/judging/cli/src/transfer.ts +++ b/packages/judging-cli/src/transfer.ts @@ -1,6 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { and, eq } from "drizzle-orm"; -import { createDb, project, requireDatabaseUrl, result, resultRun, track } from "@panel/db"; +import { createDb, project, requireDatabaseUrl, result, resultRun, track } from "@query/judging-db"; import { formatResultsCsv, formatResultsJson, parseColumnMap, parseProjectCsv } from "./csv"; export async function importProjects(file: string, eventId: string, mapFile?: string) { diff --git a/judging/cli/tsconfig.json b/packages/judging-cli/tsconfig.json similarity index 100% rename from judging/cli/tsconfig.json rename to packages/judging-cli/tsconfig.json diff --git a/judging/core/eslint.config.mjs b/packages/judging-core/eslint.config.mjs similarity index 68% rename from judging/core/eslint.config.mjs rename to packages/judging-core/eslint.config.mjs index a7d0fb28..09f98a9a 100644 --- a/judging/core/eslint.config.mjs +++ b/packages/judging-core/eslint.config.mjs @@ -15,15 +15,10 @@ export default [ name: "process", importNames: ["env"], message: - "@panel/core takes its inputs as arguments. It does not read the environment.", + "@query/judging-core takes its inputs as arguments. It does not read the environment.", }, ], patterns: [ - { - group: ["@query", "@query/*"], - message: - "judging/ is a standalone product. Import from @panel/* only.", - }, { group: [ "node:*", @@ -35,7 +30,7 @@ export default [ "node:crypto", ], message: - "@panel/core has no runtime dependencies and does not call Node APIs.", + "@query/judging-core has no runtime dependencies and does not call Node APIs.", }, ], }, diff --git a/judging/core/package.json b/packages/judging-core/package.json similarity index 96% rename from judging/core/package.json rename to packages/judging-core/package.json index 8f0f6bbf..1e961bdd 100644 --- a/judging/core/package.json +++ b/packages/judging-core/package.json @@ -1,5 +1,5 @@ { - "name": "@panel/core", + "name": "@query/judging-core", "version": "0.0.0", "private": true, "type": "module", diff --git a/judging/core/scripts/emit-extensions.mjs b/packages/judging-core/scripts/emit-extensions.mjs similarity index 100% rename from judging/core/scripts/emit-extensions.mjs rename to packages/judging-core/scripts/emit-extensions.mjs diff --git a/judging/core/src/aggregate/bayes.test.ts b/packages/judging-core/src/aggregate/bayes.test.ts similarity index 100% rename from judging/core/src/aggregate/bayes.test.ts rename to packages/judging-core/src/aggregate/bayes.test.ts diff --git a/judging/core/src/aggregate/bayes.ts b/packages/judging-core/src/aggregate/bayes.ts similarity index 100% rename from judging/core/src/aggregate/bayes.ts rename to packages/judging-core/src/aggregate/bayes.ts diff --git a/judging/core/src/aggregate/blend.test.ts b/packages/judging-core/src/aggregate/blend.test.ts similarity index 100% rename from judging/core/src/aggregate/blend.test.ts rename to packages/judging-core/src/aggregate/blend.test.ts diff --git a/judging/core/src/aggregate/blend.ts b/packages/judging-core/src/aggregate/blend.ts similarity index 100% rename from judging/core/src/aggregate/blend.ts rename to packages/judging-core/src/aggregate/blend.ts diff --git a/judging/core/src/aggregate/bradleyTerry.test.ts b/packages/judging-core/src/aggregate/bradleyTerry.test.ts similarity index 100% rename from judging/core/src/aggregate/bradleyTerry.test.ts rename to packages/judging-core/src/aggregate/bradleyTerry.test.ts diff --git a/judging/core/src/aggregate/bradleyTerry.ts b/packages/judging-core/src/aggregate/bradleyTerry.ts similarity index 100% rename from judging/core/src/aggregate/bradleyTerry.ts rename to packages/judging-core/src/aggregate/bradleyTerry.ts diff --git a/judging/core/src/aggregate/rank.test.ts b/packages/judging-core/src/aggregate/rank.test.ts similarity index 100% rename from judging/core/src/aggregate/rank.test.ts rename to packages/judging-core/src/aggregate/rank.test.ts diff --git a/judging/core/src/aggregate/rank.ts b/packages/judging-core/src/aggregate/rank.ts similarity index 100% rename from judging/core/src/aggregate/rank.ts rename to packages/judging-core/src/aggregate/rank.ts diff --git a/judging/core/src/aggregate/zscore.test.ts b/packages/judging-core/src/aggregate/zscore.test.ts similarity index 100% rename from judging/core/src/aggregate/zscore.test.ts rename to packages/judging-core/src/aggregate/zscore.test.ts diff --git a/judging/core/src/aggregate/zscore.ts b/packages/judging-core/src/aggregate/zscore.ts similarity index 100% rename from judging/core/src/aggregate/zscore.ts rename to packages/judging-core/src/aggregate/zscore.ts diff --git a/judging/core/src/comparison.ts b/packages/judging-core/src/comparison.ts similarity index 100% rename from judging/core/src/comparison.ts rename to packages/judging-core/src/comparison.ts diff --git a/judging/core/src/dispatch.sim.test.ts b/packages/judging-core/src/dispatch.sim.test.ts similarity index 100% rename from judging/core/src/dispatch.sim.test.ts rename to packages/judging-core/src/dispatch.sim.test.ts diff --git a/judging/core/src/dispatch.test.ts b/packages/judging-core/src/dispatch.test.ts similarity index 100% rename from judging/core/src/dispatch.test.ts rename to packages/judging-core/src/dispatch.test.ts diff --git a/judging/core/src/dispatch.ts b/packages/judging-core/src/dispatch.ts similarity index 100% rename from judging/core/src/dispatch.ts rename to packages/judging-core/src/dispatch.ts diff --git a/judging/core/src/distance.test.ts b/packages/judging-core/src/distance.test.ts similarity index 100% rename from judging/core/src/distance.test.ts rename to packages/judging-core/src/distance.test.ts diff --git a/judging/core/src/distance.ts b/packages/judging-core/src/distance.ts similarity index 100% rename from judging/core/src/distance.ts rename to packages/judging-core/src/distance.ts diff --git a/judging/core/src/events.test.ts b/packages/judging-core/src/events.test.ts similarity index 100% rename from judging/core/src/events.test.ts rename to packages/judging-core/src/events.test.ts diff --git a/judging/core/src/events.ts b/packages/judging-core/src/events.ts similarity index 100% rename from judging/core/src/events.ts rename to packages/judging-core/src/events.ts diff --git a/judging/core/src/index.ts b/packages/judging-core/src/index.ts similarity index 100% rename from judging/core/src/index.ts rename to packages/judging-core/src/index.ts diff --git a/judging/core/src/match.test.ts b/packages/judging-core/src/match.test.ts similarity index 100% rename from judging/core/src/match.test.ts rename to packages/judging-core/src/match.test.ts diff --git a/judging/core/src/match.ts b/packages/judging-core/src/match.ts similarity index 100% rename from judging/core/src/match.ts rename to packages/judging-core/src/match.ts diff --git a/judging/core/src/package.test.ts b/packages/judging-core/src/package.test.ts similarity index 90% rename from judging/core/src/package.test.ts rename to packages/judging-core/src/package.test.ts index 678fffae..85a15172 100644 --- a/judging/core/src/package.test.ts +++ b/packages/judging-core/src/package.test.ts @@ -3,7 +3,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -describe("@panel/core package", () => { +describe("@query/judging-core package", () => { it("has no runtime dependencies", () => { const packageJson = join(dirname(fileURLToPath(import.meta.url)), "../package.json"); const pkg = JSON.parse(readFileSync(packageJson, "utf8")) as { diff --git a/judging/core/src/phase.test.ts b/packages/judging-core/src/phase.test.ts similarity index 100% rename from judging/core/src/phase.test.ts rename to packages/judging-core/src/phase.test.ts diff --git a/judging/core/src/phase.ts b/packages/judging-core/src/phase.ts similarity index 100% rename from judging/core/src/phase.ts rename to packages/judging-core/src/phase.ts diff --git a/judging/core/src/rubric.test.ts b/packages/judging-core/src/rubric.test.ts similarity index 100% rename from judging/core/src/rubric.test.ts rename to packages/judging-core/src/rubric.test.ts diff --git a/judging/core/src/rubric.ts b/packages/judging-core/src/rubric.ts similarity index 100% rename from judging/core/src/rubric.ts rename to packages/judging-core/src/rubric.ts diff --git a/judging/core/src/tables.test.ts b/packages/judging-core/src/tables.test.ts similarity index 100% rename from judging/core/src/tables.test.ts rename to packages/judging-core/src/tables.test.ts diff --git a/judging/core/src/tables.ts b/packages/judging-core/src/tables.ts similarity index 100% rename from judging/core/src/tables.ts rename to packages/judging-core/src/tables.ts diff --git a/judging/core/src/timers.test.ts b/packages/judging-core/src/timers.test.ts similarity index 100% rename from judging/core/src/timers.test.ts rename to packages/judging-core/src/timers.test.ts diff --git a/judging/core/src/timers.ts b/packages/judging-core/src/timers.ts similarity index 100% rename from judging/core/src/timers.ts rename to packages/judging-core/src/timers.ts diff --git a/judging/core/src/uncertainty.test.ts b/packages/judging-core/src/uncertainty.test.ts similarity index 100% rename from judging/core/src/uncertainty.test.ts rename to packages/judging-core/src/uncertainty.test.ts diff --git a/judging/core/src/uncertainty.ts b/packages/judging-core/src/uncertainty.ts similarity index 100% rename from judging/core/src/uncertainty.ts rename to packages/judging-core/src/uncertainty.ts diff --git a/judging/core/tsconfig.build.json b/packages/judging-core/tsconfig.build.json similarity index 100% rename from judging/core/tsconfig.build.json rename to packages/judging-core/tsconfig.build.json diff --git a/judging/core/tsconfig.json b/packages/judging-core/tsconfig.json similarity index 100% rename from judging/core/tsconfig.json rename to packages/judging-core/tsconfig.json diff --git a/judging/core/vitest.config.ts b/packages/judging-core/vitest.config.ts similarity index 100% rename from judging/core/vitest.config.ts rename to packages/judging-core/vitest.config.ts diff --git a/packages/judging-db/eslint.config.mjs b/packages/judging-db/eslint.config.mjs new file mode 100644 index 00000000..780d26b6 --- /dev/null +++ b/packages/judging-db/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [...config]; diff --git a/judging/db/migrations/0001_init.sql b/packages/judging-db/migrations/0001_init.sql similarity index 100% rename from judging/db/migrations/0001_init.sql rename to packages/judging-db/migrations/0001_init.sql diff --git a/judging/db/migrations/0002_feedback_token.sql b/packages/judging-db/migrations/0002_feedback_token.sql similarity index 100% rename from judging/db/migrations/0002_feedback_token.sql rename to packages/judging-db/migrations/0002_feedback_token.sql diff --git a/judging/db/migrations/0003_shared_database.sql b/packages/judging-db/migrations/0003_shared_database.sql similarity index 100% rename from judging/db/migrations/0003_shared_database.sql rename to packages/judging-db/migrations/0003_shared_database.sql diff --git a/judging/db/package.json b/packages/judging-db/package.json similarity index 86% rename from judging/db/package.json rename to packages/judging-db/package.json index e6e07b1b..87a7eee0 100644 --- a/judging/db/package.json +++ b/packages/judging-db/package.json @@ -1,5 +1,5 @@ { - "name": "@panel/db", + "name": "@query/judging-db", "version": "0.0.0", "private": true, "type": "module", @@ -11,7 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@panel/core": "workspace:*", + "@query/judging-core": "workspace:*", "drizzle-orm": "0.45.3", "pg": "8.23.0" }, diff --git a/judging/db/src/client.ts b/packages/judging-db/src/client.ts similarity index 100% rename from judging/db/src/client.ts rename to packages/judging-db/src/client.ts diff --git a/judging/db/src/env.ts b/packages/judging-db/src/env.ts similarity index 100% rename from judging/db/src/env.ts rename to packages/judging-db/src/env.ts diff --git a/judging/db/src/index.ts b/packages/judging-db/src/index.ts similarity index 100% rename from judging/db/src/index.ts rename to packages/judging-db/src/index.ts diff --git a/judging/db/src/migrate.ts b/packages/judging-db/src/migrate.ts similarity index 100% rename from judging/db/src/migrate.ts rename to packages/judging-db/src/migrate.ts diff --git a/judging/db/src/schema.ts b/packages/judging-db/src/schema.ts similarity index 100% rename from judging/db/src/schema.ts rename to packages/judging-db/src/schema.ts diff --git a/judging/db/src/seed.ts b/packages/judging-db/src/seed.ts similarity index 99% rename from judging/db/src/seed.ts rename to packages/judging-db/src/seed.ts index a313315f..9a11405a 100644 --- a/judging/db/src/seed.ts +++ b/packages/judging-db/src/seed.ts @@ -1,5 +1,5 @@ import { eq, sql } from "drizzle-orm"; -import { DEFAULT_TIMERS } from "@panel/core"; +import { DEFAULT_TIMERS } from "@query/judging-core"; import type { PanelDb, PanelTx } from "./client"; import { criterion, diff --git a/judging/db/tsconfig.json b/packages/judging-db/tsconfig.json similarity index 100% rename from judging/db/tsconfig.json rename to packages/judging-db/tsconfig.json diff --git a/judging/infra/docker/Dockerfile b/packages/judging-server/Dockerfile similarity index 60% rename from judging/infra/docker/Dockerfile rename to packages/judging-server/Dockerfile index e40b129d..78dbfbd3 100644 --- a/judging/infra/docker/Dockerfile +++ b/packages/judging-server/Dockerfile @@ -5,11 +5,10 @@ RUN corepack enable COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ COPY tooling ./tooling -COPY judging ./judging COPY packages ./packages COPY sites ./sites -RUN pnpm install --frozen-lockfile --filter @panel/server... +RUN pnpm install --frozen-lockfile --filter @query/judging-server... EXPOSE 8787 -CMD ["pnpm", "--filter", "@panel/server", "start"] +CMD ["pnpm", "--filter", "@query/judging-server", "start"] diff --git a/packages/judging-server/eslint.config.mjs b/packages/judging-server/eslint.config.mjs new file mode 100644 index 00000000..2d326cc0 --- /dev/null +++ b/packages/judging-server/eslint.config.mjs @@ -0,0 +1,10 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...config, + { + files: ["src/log.ts", "src/index.ts"], + rules: { "no-console": "off" }, + }, +]; diff --git a/judging/server/package.json b/packages/judging-server/package.json similarity index 88% rename from judging/server/package.json rename to packages/judging-server/package.json index d260728b..141ef278 100644 --- a/judging/server/package.json +++ b/packages/judging-server/package.json @@ -1,5 +1,5 @@ { - "name": "@panel/server", + "name": "@query/judging-server", "version": "0.0.0", "private": true, "type": "module", @@ -14,8 +14,8 @@ }, "dependencies": { "@hono/node-server": "^1.19.0", - "@panel/core": "workspace:*", - "@panel/db": "workspace:*", + "@query/judging-core": "workspace:*", + "@query/judging-db": "workspace:*", "@trpc/server": "11.19.0", "drizzle-orm": "0.45.3", "hono": "^4.9.0", diff --git a/judging/server/src/app.test.ts b/packages/judging-server/src/app.test.ts similarity index 100% rename from judging/server/src/app.test.ts rename to packages/judging-server/src/app.test.ts diff --git a/judging/server/src/app.ts b/packages/judging-server/src/app.ts similarity index 99% rename from judging/server/src/app.ts rename to packages/judging-server/src/app.ts index cdd03e78..b9d19367 100644 --- a/judging/server/src/app.ts +++ b/packages/judging-server/src/app.ts @@ -4,8 +4,8 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { TRPCError } from "@trpc/server"; import { and, eq, isNull, sql } from "drizzle-orm"; import { z } from "zod"; -import type { PanelDb } from "@panel/db"; -import { event, loginCode, membership, organization, user, visit } from "@panel/db"; +import type { PanelDb } from "@query/judging-db"; +import { event, loginCode, membership, organization, user, visit } from "@query/judging-db"; import { hashCode, newLoginCode, roleAtLeast, signActor, verifyActor } from "./auth"; import { sendLoginCode } from "./mail"; import type { Role } from "./auth"; diff --git a/judging/server/src/auth.test.ts b/packages/judging-server/src/auth.test.ts similarity index 100% rename from judging/server/src/auth.test.ts rename to packages/judging-server/src/auth.test.ts diff --git a/judging/server/src/auth.ts b/packages/judging-server/src/auth.ts similarity index 100% rename from judging/server/src/auth.ts rename to packages/judging-server/src/auth.ts diff --git a/judging/server/src/bus.test.ts b/packages/judging-server/src/bus.test.ts similarity index 100% rename from judging/server/src/bus.test.ts rename to packages/judging-server/src/bus.test.ts diff --git a/judging/server/src/bus.ts b/packages/judging-server/src/bus.ts similarity index 100% rename from judging/server/src/bus.ts rename to packages/judging-server/src/bus.ts diff --git a/judging/server/src/cross-server.check.ts b/packages/judging-server/src/cross-server.check.ts similarity index 97% rename from judging/server/src/cross-server.check.ts rename to packages/judging-server/src/cross-server.check.ts index 85c4120c..dff233ef 100644 --- a/judging/server/src/cross-server.check.ts +++ b/packages/judging-server/src/cross-server.check.ts @@ -1,6 +1,6 @@ import { WebSocket } from "ws"; import { eq } from "drizzle-orm"; -import { createDb, event, judge, organization } from "@panel/db"; +import { createDb, event, judge, organization } from "@query/judging-db"; import { signActor } from "./auth"; import { log } from "./log"; diff --git a/judging/server/src/env.ts b/packages/judging-server/src/env.ts similarity index 100% rename from judging/server/src/env.ts rename to packages/judging-server/src/env.ts diff --git a/judging/server/src/hub.ts b/packages/judging-server/src/hub.ts similarity index 100% rename from judging/server/src/hub.ts rename to packages/judging-server/src/hub.ts diff --git a/judging/server/src/index.ts b/packages/judging-server/src/index.ts similarity index 95% rename from judging/server/src/index.ts rename to packages/judging-server/src/index.ts index b5ad4d8b..1ef346c6 100644 --- a/judging/server/src/index.ts +++ b/packages/judging-server/src/index.ts @@ -1,8 +1,8 @@ import { serve } from "@hono/node-server"; import type { Server } from "node:http"; import { sql } from "drizzle-orm"; -import { boardChannel, judgeChannel } from "@panel/core"; -import { createDb } from "@panel/db"; +import { boardChannel, judgeChannel } from "@query/judging-core"; +import { createDb } from "@query/judging-db"; import { createBus } from "./bus"; import { attachHub } from "./hub"; import { createApp } from "./app"; diff --git a/judging/server/src/log.ts b/packages/judging-server/src/log.ts similarity index 100% rename from judging/server/src/log.ts rename to packages/judging-server/src/log.ts diff --git a/judging/server/src/mail.ts b/packages/judging-server/src/mail.ts similarity index 100% rename from judging/server/src/mail.ts rename to packages/judging-server/src/mail.ts diff --git a/judging/server/src/metrics.ts b/packages/judging-server/src/metrics.ts similarity index 100% rename from judging/server/src/metrics.ts rename to packages/judging-server/src/metrics.ts diff --git a/judging/server/src/router.ts b/packages/judging-server/src/router.ts similarity index 99% rename from judging/server/src/router.ts rename to packages/judging-server/src/router.ts index 8e7806bc..6e3d7e9e 100644 --- a/judging/server/src/router.ts +++ b/packages/judging-server/src/router.ts @@ -1,9 +1,9 @@ import { initTRPC, TRPCError } from "@trpc/server"; import { and, eq, sql } from "drizzle-orm"; import { z } from "zod"; -import { assertTransition, boardChannel, judgeChannel, leaderboardChannel } from "@panel/core"; -import type { PanelDb } from "@panel/db"; -import { event, eventLog, judge, criterion, rubric, visit, vote } from "@panel/db"; +import { assertTransition, boardChannel, judgeChannel, leaderboardChannel } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; +import { event, eventLog, judge, criterion, rubric, visit, vote } from "@query/judging-db"; import type { Actor, Role } from "./auth"; import { roleAtLeast } from "./auth"; import type { Bus } from "./bus"; diff --git a/judging/server/src/services/arrive.ts b/packages/judging-server/src/services/arrive.ts similarity index 92% rename from judging/server/src/services/arrive.ts rename to packages/judging-server/src/services/arrive.ts index bd213f61..616e98f7 100644 --- a/judging/server/src/services/arrive.ts +++ b/packages/judging-server/src/services/arrive.ts @@ -1,7 +1,7 @@ import { and, eq, isNull } from "drizzle-orm"; -import type { PanelDb } from "@panel/db"; -import { event, eventLog, outbox, project, visit } from "@panel/db"; -import { phaseAllows } from "@panel/core"; +import type { PanelDb } from "@query/judging-db"; +import { event, eventLog, outbox, project, visit } from "@query/judging-db"; +import { phaseAllows } from "@query/judging-core"; export async function arrive( db: PanelDb, diff --git a/judging/server/src/services/catalog.ts b/packages/judging-server/src/services/catalog.ts similarity index 99% rename from judging/server/src/services/catalog.ts rename to packages/judging-server/src/services/catalog.ts index 9bef9d5d..8f1e3996 100644 --- a/judging/server/src/services/catalog.ts +++ b/packages/judging-server/src/services/catalog.ts @@ -1,5 +1,5 @@ import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; -import type { PanelDb } from "@panel/db"; +import type { PanelDb } from "@query/judging-db"; import { apiKey, criterion, @@ -20,7 +20,7 @@ import { voteScore, webhook, zone, -} from "@panel/db"; +} from "@query/judging-db"; import { randomBytes } from "node:crypto"; import type { Actor } from "../auth"; import { hashApiKey, roleForScopes } from "../auth"; diff --git a/judging/server/src/services/compare.ts b/packages/judging-server/src/services/compare.ts similarity index 90% rename from judging/server/src/services/compare.ts rename to packages/judging-server/src/services/compare.ts index 953314ce..f1220ce7 100644 --- a/judging/server/src/services/compare.ts +++ b/packages/judging-server/src/services/compare.ts @@ -1,7 +1,7 @@ import { and, desc, eq, isNull } from "drizzle-orm"; -import { phaseAllows } from "@panel/core"; -import type { PanelDb } from "@panel/db"; -import { comparison, event, eventLog, visit } from "@panel/db"; +import { phaseAllows } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; +import { comparison, event, eventLog, visit } from "@query/judging-db"; /** The judge's previous table against the one they are at. */ export async function castComparison( diff --git a/judging/server/src/services/dispatch.concurrent.test.ts b/packages/judging-server/src/services/dispatch.concurrent.test.ts similarity index 88% rename from judging/server/src/services/dispatch.concurrent.test.ts rename to packages/judging-server/src/services/dispatch.concurrent.test.ts index 71eb9e52..e39c0584 100644 --- a/judging/server/src/services/dispatch.concurrent.test.ts +++ b/packages/judging-server/src/services/dispatch.concurrent.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { dispatchNext } from "./dispatch"; -import { createDb, databaseUrl, migrate } from "@panel/db"; +import { createDb, databaseUrl, migrate } from "@query/judging-db"; const url = databaseUrl(); @@ -9,7 +9,7 @@ describe.skipIf(!url)("concurrent dispatch", () => { const { db, pool } = createDb(url as string); try { await migrate(pool); - const { seedDemo, judge, project } = await import("@panel/db"); + const { seedDemo, judge, project } = await import("@query/judging-db"); const { eq } = await import("drizzle-orm"); const { eventId } = await seedDemo(db); const judges = await db.select().from(judge).where(eq(judge.eventId, eventId)); diff --git a/judging/server/src/services/dispatch.ts b/packages/judging-server/src/services/dispatch.ts similarity index 98% rename from judging/server/src/services/dispatch.ts rename to packages/judging-server/src/services/dispatch.ts index 9210b081..0b6655c4 100644 --- a/judging/server/src/services/dispatch.ts +++ b/packages/judging-server/src/services/dispatch.ts @@ -1,8 +1,8 @@ import { randomInt } from "node:crypto"; import { and, eq, isNull, sql } from "drizzle-orm"; -import { isLive, pickNext } from "@panel/core"; -import type { TimerConfig } from "@panel/core"; -import type { PanelDb, PanelTx } from "@panel/db"; +import { isLive, pickNext } from "@query/judging-core"; +import type { TimerConfig } from "@query/judging-core"; +import type { PanelDb, PanelTx } from "@query/judging-db"; import { event, eventConfig, @@ -14,7 +14,7 @@ import { track, visit, vote, -} from "@panel/db"; +} from "@query/judging-db"; import { isDeliveredTopic, logsForHandout } from "./logs"; export type DispatchOutcome = diff --git a/judging/server/src/services/feedback.test.ts b/packages/judging-server/src/services/feedback.test.ts similarity index 100% rename from judging/server/src/services/feedback.test.ts rename to packages/judging-server/src/services/feedback.test.ts diff --git a/judging/server/src/services/feedback.ts b/packages/judging-server/src/services/feedback.ts similarity index 100% rename from judging/server/src/services/feedback.ts rename to packages/judging-server/src/services/feedback.ts diff --git a/judging/server/src/services/floor.test.ts b/packages/judging-server/src/services/floor.test.ts similarity index 94% rename from judging/server/src/services/floor.test.ts rename to packages/judging-server/src/services/floor.test.ts index 1da5647e..aabba96b 100644 --- a/judging/server/src/services/floor.test.ts +++ b/packages/judging-server/src/services/floor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_TIMERS } from "@panel/core"; +import { DEFAULT_TIMERS } from "@query/judging-core"; import { judgeFloorState } from "./floor"; const arrived = new Date("2027-02-28T13:00:00Z"); diff --git a/judging/server/src/services/floor.ts b/packages/judging-server/src/services/floor.ts similarity index 91% rename from judging/server/src/services/floor.ts rename to packages/judging-server/src/services/floor.ts index 0358e071..5dfe8c0a 100644 --- a/judging/server/src/services/floor.ts +++ b/packages/judging-server/src/services/floor.ts @@ -1,8 +1,8 @@ import { and, eq, isNull, sql } from "drizzle-orm"; -import { isOverTarget } from "@panel/core"; -import type { TimerConfig } from "@panel/core"; -import type { PanelDb } from "@panel/db"; -import { eventConfig, judge, project, visit, vote } from "@panel/db"; +import { isOverTarget } from "@query/judging-core"; +import type { TimerConfig } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; +import { eventConfig, judge, project, visit, vote } from "@query/judging-db"; export type FloorState = "idle" | "walking" | "at table"; diff --git a/judging/server/src/services/flow.integration.test.ts b/packages/judging-server/src/services/flow.integration.test.ts similarity index 97% rename from judging/server/src/services/flow.integration.test.ts rename to packages/judging-server/src/services/flow.integration.test.ts index 91f3a024..58156201 100644 --- a/judging/server/src/services/flow.integration.test.ts +++ b/packages/judging-server/src/services/flow.integration.test.ts @@ -10,7 +10,7 @@ import { project, rubric, seedDemo, -} from "@panel/db"; +} from "@query/judging-db"; import { arrive } from "./arrive"; import { dispatchNext } from "./dispatch"; import { feedbackCard } from "./catalog"; @@ -25,7 +25,7 @@ describe.skipIf(!url)("a full visit", () => { try { await migrate(pool); const { eventId } = await seedDemo(db); - const { judge } = await import("@panel/db"); + const { judge } = await import("@query/judging-db"); const [person] = await db.select().from(judge).where(eq(judge.eventId, eventId)); if (!person) throw new Error("demo judge missing"); const now = new Date("2027-02-28T13:00:00Z"); diff --git a/judging/server/src/services/live.ts b/packages/judging-server/src/services/live.ts similarity index 96% rename from judging/server/src/services/live.ts rename to packages/judging-server/src/services/live.ts index 750c7481..3e75fbe2 100644 --- a/judging/server/src/services/live.ts +++ b/packages/judging-server/src/services/live.ts @@ -1,6 +1,6 @@ import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"; -import type { PanelDb } from "@panel/db"; -import { event, eventConfig, judge, organization, prize, project, result, resultRun, track, visit, vote } from "@panel/db"; +import type { PanelDb } from "@query/judging-db"; +import { event, eventConfig, judge, organization, prize, project, result, resultRun, track, visit, vote } from "@query/judging-db"; export async function liveSnapshot(db: PanelDb, orgSlug: string, eventSlug: string) { const [row] = await db diff --git a/judging/server/src/services/logs.test.ts b/packages/judging-server/src/services/logs.test.ts similarity index 100% rename from judging/server/src/services/logs.test.ts rename to packages/judging-server/src/services/logs.test.ts diff --git a/judging/server/src/services/logs.ts b/packages/judging-server/src/services/logs.ts similarity index 85% rename from judging/server/src/services/logs.ts rename to packages/judging-server/src/services/logs.ts index 7c288047..b7d54c73 100644 --- a/judging/server/src/services/logs.ts +++ b/packages/judging-server/src/services/logs.ts @@ -1,5 +1,5 @@ -import { isOutboxTopic } from "@panel/core"; -import type { LogKind } from "@panel/core"; +import { isOutboxTopic } from "@query/judging-core"; +import type { LogKind } from "@query/judging-core"; export type LogDraft = { kind: LogKind; diff --git a/judging/server/src/services/outbox.ts b/packages/judging-server/src/services/outbox.ts similarity index 93% rename from judging/server/src/services/outbox.ts rename to packages/judging-server/src/services/outbox.ts index 0844039b..4f6ca441 100644 --- a/judging/server/src/services/outbox.ts +++ b/packages/judging-server/src/services/outbox.ts @@ -1,7 +1,7 @@ import { createHmac } from "node:crypto"; import { eq, isNull } from "drizzle-orm"; -import type { PanelDb } from "@panel/db"; -import { outbox, webhook } from "@panel/db"; +import type { PanelDb } from "@query/judging-db"; +import { outbox, webhook } from "@query/judging-db"; /** Delivers pending outbox rows. A topic with no active webhook is delivered. */ export async function drainOutbox(db: PanelDb, now = new Date()): Promise { diff --git a/judging/server/src/services/overtime.test.ts b/packages/judging-server/src/services/overtime.test.ts similarity index 93% rename from judging/server/src/services/overtime.test.ts rename to packages/judging-server/src/services/overtime.test.ts index f39cfaef..35fdb143 100644 --- a/judging/server/src/services/overtime.test.ts +++ b/packages/judging-server/src/services/overtime.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_TIMERS } from "@panel/core"; +import { DEFAULT_TIMERS } from "@query/judging-core"; import { visitsOverTarget } from "./overtime"; const arrived = new Date("2027-02-28T13:00:00Z"); diff --git a/judging/server/src/services/overtime.ts b/packages/judging-server/src/services/overtime.ts similarity index 87% rename from judging/server/src/services/overtime.ts rename to packages/judging-server/src/services/overtime.ts index dad634b7..db7fc2a0 100644 --- a/judging/server/src/services/overtime.ts +++ b/packages/judging-server/src/services/overtime.ts @@ -1,8 +1,8 @@ import { and, eq, isNotNull, isNull } from "drizzle-orm"; -import { isOverTarget } from "@panel/core"; -import type { TimerConfig } from "@panel/core"; -import type { PanelDb } from "@panel/db"; -import { eventConfig, visit } from "@panel/db"; +import { isOverTarget } from "@query/judging-core"; +import type { TimerConfig } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; +import { eventConfig, visit } from "@query/judging-db"; export type OpenVisit = { visitId: string; diff --git a/judging/server/src/services/records.ts b/packages/judging-server/src/services/records.ts similarity index 98% rename from judging/server/src/services/records.ts rename to packages/judging-server/src/services/records.ts index 39f865cd..7000de5b 100644 --- a/judging/server/src/services/records.ts +++ b/packages/judging-server/src/services/records.ts @@ -1,8 +1,8 @@ import { randomUUID } from "node:crypto"; import { and, eq, isNull } from "drizzle-orm"; -import { assignTables, phaseAllows } from "@panel/core"; -import type { PanelDb } from "@panel/db"; -import { event, eventLog, floorTable, judge, outbox, project, projectTrack, visit } from "@panel/db"; +import { assignTables, phaseAllows } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; +import { event, eventLog, floorTable, judge, outbox, project, projectTrack, visit } from "@query/judging-db"; async function phaseOf(db: PanelDb, eventId: string) { const [row] = await db diff --git a/judging/server/src/services/redis.integration.test.ts b/packages/judging-server/src/services/redis.integration.test.ts similarity index 100% rename from judging/server/src/services/redis.integration.test.ts rename to packages/judging-server/src/services/redis.integration.test.ts diff --git a/judging/server/src/services/results.test.ts b/packages/judging-server/src/services/results.test.ts similarity index 100% rename from judging/server/src/services/results.test.ts rename to packages/judging-server/src/services/results.test.ts diff --git a/judging/server/src/services/results.ts b/packages/judging-server/src/services/results.ts similarity index 98% rename from judging/server/src/services/results.ts rename to packages/judging-server/src/services/results.ts index 486c5d32..addcd6f1 100644 --- a/judging/server/src/services/results.ts +++ b/packages/judging-server/src/services/results.ts @@ -1,6 +1,6 @@ import { and, desc, eq, isNotNull, isNull, sql } from "drizzle-orm"; -import { phaseAllows, rank } from "@panel/core"; -import type { PanelDb } from "@panel/db"; +import { phaseAllows, rank } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; import { comparison, event, @@ -14,7 +14,7 @@ import { resultRun, track, vote, -} from "@panel/db"; +} from "@query/judging-db"; export type Placement = { projectId: string; diff --git a/judging/server/src/services/vote.test.ts b/packages/judging-server/src/services/vote.test.ts similarity index 96% rename from judging/server/src/services/vote.test.ts rename to packages/judging-server/src/services/vote.test.ts index 739f5334..43dd458b 100644 --- a/judging/server/src/services/vote.test.ts +++ b/packages/judging-server/src/services/vote.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_TIMERS } from "@panel/core"; +import { DEFAULT_TIMERS } from "@query/judging-core"; import { assessVote } from "./vote"; const criteria = [{ id: "creativity", min: 0, max: 10, weight: 1 }]; diff --git a/judging/server/src/services/vote.ts b/packages/judging-server/src/services/vote.ts similarity index 97% rename from judging/server/src/services/vote.ts rename to packages/judging-server/src/services/vote.ts index 3d0dfd89..bb1d8f0d 100644 --- a/judging/server/src/services/vote.ts +++ b/packages/judging-server/src/services/vote.ts @@ -1,7 +1,7 @@ import { and, eq } from "drizzle-orm"; -import { isPastCutoff, phaseAllows, validateScores, weightedTotal } from "@panel/core"; -import type { Criterion, Phase, ScoreInput, TimerConfig } from "@panel/core"; -import type { PanelDb } from "@panel/db"; +import { isPastCutoff, phaseAllows, validateScores, weightedTotal } from "@query/judging-core"; +import type { Criterion, Phase, ScoreInput, TimerConfig } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; import { criterion, event, @@ -13,7 +13,7 @@ import { visit, vote, voteScore, -} from "@panel/db"; +} from "@query/judging-db"; export type VoteDecision = | { ok: true; total: number } diff --git a/judging/infra/gcp/main.tf b/packages/judging-server/terraform/main.tf similarity index 100% rename from judging/infra/gcp/main.tf rename to packages/judging-server/terraform/main.tf diff --git a/judging/server/tsconfig.json b/packages/judging-server/tsconfig.json similarity index 100% rename from judging/server/tsconfig.json rename to packages/judging-server/tsconfig.json diff --git a/judging/server/vitest.config.ts b/packages/judging-server/vitest.config.ts similarity index 100% rename from judging/server/vitest.config.ts rename to packages/judging-server/vitest.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17d1b918..c09297d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,175 +41,6 @@ importers: specifier: ^5.0.1 version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) - judging/cli: - dependencies: - '@panel/db': - specifier: workspace:* - version: link:../db - '@panel/server': - specifier: workspace:* - version: link:../server - drizzle-orm: - specifier: 0.45.3 - version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) - tsx: - specifier: ^4.23.15 - version: 4.23.15 - devDependencies: - '@query/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@query/tsconfig': - specifier: workspace:* - version: link:../../tooling/typescript - '@types/node': - specifier: 22.20.4 - version: 22.20.4 - typescript: - specifier: 7.0.2 - version: 7.0.2 - - judging/core: - devDependencies: - '@query/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@query/tsconfig': - specifier: workspace:* - version: link:../../tooling/typescript - '@types/node': - specifier: 22.20.4 - version: 22.20.4 - typescript: - specifier: 7.0.2 - version: 7.0.2 - vitest: - specifier: ^5.0.1 - version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) - - judging/db: - dependencies: - '@panel/core': - specifier: workspace:* - version: link:../core - drizzle-orm: - specifier: 0.45.3 - version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) - pg: - specifier: 8.23.0 - version: 8.23.0 - devDependencies: - '@query/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@query/tsconfig': - specifier: workspace:* - version: link:../../tooling/typescript - '@types/node': - specifier: 22.20.4 - version: 22.20.4 - '@types/pg': - specifier: ^8.23.1 - version: 8.23.1 - typescript: - specifier: 7.0.2 - version: 7.0.2 - - judging/server: - dependencies: - '@hono/node-server': - specifier: ^1.19.0 - version: 1.19.17(hono@4.13.13) - '@panel/core': - specifier: workspace:* - version: link:../core - '@panel/db': - specifier: workspace:* - version: link:../db - '@trpc/server': - specifier: 11.19.0 - version: 11.19.0(typescript@7.0.2) - drizzle-orm: - specifier: 0.45.3 - version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) - hono: - specifier: ^4.9.0 - version: 4.13.13 - ioredis: - specifier: ^5.8.2 - version: 5.11.1 - jose: - specifier: ^6.1.0 - version: 6.2.3 - nodemailer: - specifier: ^10.0.10 - version: 10.0.10 - prom-client: - specifier: 15.1.3 - version: 15.1.3 - tsx: - specifier: ^4.23.15 - version: 4.23.15 - ws: - specifier: ^8.20.1 - version: 8.21.3 - zod: - specifier: 4.6.5 - version: 4.6.5 - devDependencies: - '@query/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@query/tsconfig': - specifier: workspace:* - version: link:../../tooling/typescript - '@types/node': - specifier: 22.20.4 - version: 22.20.4 - '@types/ws': - specifier: ^8.18.1 - version: 8.18.2 - typescript: - specifier: 7.0.2 - version: 7.0.2 - vitest: - specifier: ^5.0.1 - version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) - - judging/web: - dependencies: - next: - specifier: 16.3.7 - version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - react: - specifier: 19.3.0 - version: 19.3.0 - react-dom: - specifier: 19.3.0 - version: 19.3.0(react@19.3.0) - devDependencies: - '@playwright/test': - specifier: ^1.58.2 - version: 1.64.0 - '@query/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@query/tsconfig': - specifier: workspace:* - version: link:../../tooling/typescript - '@types/node': - specifier: 22.20.4 - version: 22.20.4 - '@types/react': - specifier: ^19.3.0 - version: 19.3.0 - '@types/react-dom': - specifier: ^19.3.0 - version: 19.3.0(@types/react@19.3.0) - typescript: - specifier: 7.0.2 - version: 7.0.2 - packages/api: dependencies: '@query/auth': @@ -255,6 +86,9 @@ importers: specifier: 4.6.5 version: 4.6.5 devDependencies: + '@query/judging-core': + specifier: workspace:* + version: link:../judging-core '@query/tsconfig': specifier: workspace:* version: link:../../tooling/typescript @@ -357,6 +191,141 @@ importers: specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + packages/judging-cli: + dependencies: + '@query/judging-db': + specifier: workspace:* + version: link:../judging-db + '@query/judging-server': + specifier: workspace:* + version: link:../judging-server + drizzle-orm: + specifier: 0.45.3 + version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) + tsx: + specifier: ^4.23.15 + version: 4.23.15 + devDependencies: + '@query/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@query/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + typescript: + specifier: 7.0.2 + version: 7.0.2 + + packages/judging-core: + devDependencies: + '@query/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@query/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: ^5.0.1 + version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + + packages/judging-db: + dependencies: + '@query/judging-core': + specifier: workspace:* + version: link:../judging-core + drizzle-orm: + specifier: 0.45.3 + version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) + pg: + specifier: 8.23.0 + version: 8.23.0 + devDependencies: + '@query/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@query/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + '@types/pg': + specifier: ^8.23.1 + version: 8.23.1 + typescript: + specifier: 7.0.2 + version: 7.0.2 + + packages/judging-server: + dependencies: + '@hono/node-server': + specifier: ^1.19.0 + version: 1.19.17(hono@4.13.13) + '@query/judging-core': + specifier: workspace:* + version: link:../judging-core + '@query/judging-db': + specifier: workspace:* + version: link:../judging-db + '@trpc/server': + specifier: 11.19.0 + version: 11.19.0(typescript@7.0.2) + drizzle-orm: + specifier: 0.45.3 + version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) + hono: + specifier: ^4.9.0 + version: 4.13.13 + ioredis: + specifier: ^5.8.2 + version: 5.11.1 + jose: + specifier: ^6.1.0 + version: 6.2.3 + nodemailer: + specifier: ^10.0.10 + version: 10.0.10 + prom-client: + specifier: 15.1.3 + version: 15.1.3 + tsx: + specifier: ^4.23.15 + version: 4.23.15 + ws: + specifier: ^8.20.1 + version: 8.21.3 + zod: + specifier: 4.6.5 + version: 4.6.5 + devDependencies: + '@query/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@query/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.2 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: ^5.0.1 + version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + packages/ui: dependencies: minimatch: @@ -446,6 +415,40 @@ importers: specifier: ^8.70.1 version: 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + sites/judging: + dependencies: + next: + specifier: 16.3.7 + version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + devDependencies: + '@playwright/test': + specifier: ^1.58.2 + version: 1.64.0 + '@query/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@query/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + typescript: + specifier: 7.0.2 + version: 7.0.2 + sites/mainweb: dependencies: '@google-cloud/storage': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c9f5f1e7..4da7f960 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,4 +7,3 @@ packages: - packages/auth - packages/db - packages/ui - - judging/* diff --git a/judging/web/.gitignore b/sites/judging/.gitignore similarity index 100% rename from judging/web/.gitignore rename to sites/judging/.gitignore diff --git a/judging/infra/docker/Dockerfile.web b/sites/judging/Dockerfile similarity index 54% rename from judging/infra/docker/Dockerfile.web rename to sites/judging/Dockerfile index 4dee1b6f..488feb4f 100644 --- a/judging/infra/docker/Dockerfile.web +++ b/sites/judging/Dockerfile @@ -5,15 +5,14 @@ RUN corepack enable COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ COPY tooling ./tooling -COPY judging ./judging COPY packages ./packages COPY sites ./sites ARG NEXT_PUBLIC_PANEL_URL=http://localhost:8787 ENV NEXT_PUBLIC_PANEL_URL=$NEXT_PUBLIC_PANEL_URL -RUN pnpm install --frozen-lockfile --filter @panel/web... -RUN pnpm --filter @panel/web build +RUN pnpm install --frozen-lockfile --filter @query/judging-web... +RUN pnpm --filter @query/judging-web build FROM node:22-bookworm-slim @@ -22,9 +21,9 @@ ENV NODE_ENV=production ENV HOSTNAME=0.0.0.0 ENV PORT=3000 -COPY --from=build /app/judging/web/.next/standalone ./ -COPY --from=build /app/judging/web/.next/static ./judging/web/.next/static -COPY --from=build /app/judging/web/public ./judging/web/public +COPY --from=build /app/sites/judging/.next/standalone ./ +COPY --from=build /app/sites/judging/.next/static ./sites/judging/.next/static +COPY --from=build /app/sites/judging/public ./sites/judging/public EXPOSE 3000 -CMD ["node", "judging/web/server.js"] +CMD ["node", "sites/judging/server.js"] diff --git a/judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx b/sites/judging/app/e/[orgSlug]/[eventSlug]/board.tsx similarity index 100% rename from judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx rename to sites/judging/app/e/[orgSlug]/[eventSlug]/board.tsx diff --git a/judging/web/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx b/sites/judging/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx similarity index 100% rename from judging/web/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx rename to sites/judging/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx diff --git a/judging/web/app/e/[orgSlug]/[eventSlug]/page.tsx b/sites/judging/app/e/[orgSlug]/[eventSlug]/page.tsx similarity index 100% rename from judging/web/app/e/[orgSlug]/[eventSlug]/page.tsx rename to sites/judging/app/e/[orgSlug]/[eventSlug]/page.tsx diff --git a/judging/web/app/j/[orgSlug]/[eventSlug]/desk.tsx b/sites/judging/app/j/[orgSlug]/[eventSlug]/desk.tsx similarity index 100% rename from judging/web/app/j/[orgSlug]/[eventSlug]/desk.tsx rename to sites/judging/app/j/[orgSlug]/[eventSlug]/desk.tsx diff --git a/judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx b/sites/judging/app/j/[orgSlug]/[eventSlug]/page.tsx similarity index 100% rename from judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx rename to sites/judging/app/j/[orgSlug]/[eventSlug]/page.tsx diff --git a/judging/web/app/layout.tsx b/sites/judging/app/layout.tsx similarity index 100% rename from judging/web/app/layout.tsx rename to sites/judging/app/layout.tsx diff --git a/judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx b/sites/judging/app/o/[orgSlug]/[eventSlug]/console.tsx similarity index 100% rename from judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx rename to sites/judging/app/o/[orgSlug]/[eventSlug]/console.tsx diff --git a/judging/web/app/o/[orgSlug]/[eventSlug]/editors.tsx b/sites/judging/app/o/[orgSlug]/[eventSlug]/editors.tsx similarity index 100% rename from judging/web/app/o/[orgSlug]/[eventSlug]/editors.tsx rename to sites/judging/app/o/[orgSlug]/[eventSlug]/editors.tsx diff --git a/judging/web/app/o/[orgSlug]/[eventSlug]/page.tsx b/sites/judging/app/o/[orgSlug]/[eventSlug]/page.tsx similarity index 100% rename from judging/web/app/o/[orgSlug]/[eventSlug]/page.tsx rename to sites/judging/app/o/[orgSlug]/[eventSlug]/page.tsx diff --git a/judging/web/app/page.tsx b/sites/judging/app/page.tsx similarity index 100% rename from judging/web/app/page.tsx rename to sites/judging/app/page.tsx diff --git a/judging/web/e2e/visit.spec.ts b/sites/judging/e2e/visit.spec.ts similarity index 100% rename from judging/web/e2e/visit.spec.ts rename to sites/judging/e2e/visit.spec.ts diff --git a/sites/judging/eslint.config.mjs b/sites/judging/eslint.config.mjs new file mode 100644 index 00000000..69fbcb17 --- /dev/null +++ b/sites/judging/eslint.config.mjs @@ -0,0 +1,8 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...config, + // Playwright specs sit outside tsconfig, so the typed parser cannot load them. + { ignores: ["e2e/**"] }, +]; diff --git a/judging/web/lib/api.ts b/sites/judging/lib/api.ts similarity index 100% rename from judging/web/lib/api.ts rename to sites/judging/lib/api.ts diff --git a/judging/web/lib/held.ts b/sites/judging/lib/held.ts similarity index 100% rename from judging/web/lib/held.ts rename to sites/judging/lib/held.ts diff --git a/judging/web/next.config.ts b/sites/judging/next.config.ts similarity index 100% rename from judging/web/next.config.ts rename to sites/judging/next.config.ts diff --git a/judging/web/package.json b/sites/judging/package.json similarity index 94% rename from judging/web/package.json rename to sites/judging/package.json index 42b7da58..35282b2e 100644 --- a/judging/web/package.json +++ b/sites/judging/package.json @@ -1,5 +1,5 @@ { - "name": "@panel/web", + "name": "@query/judging-web", "version": "0.0.0", "private": true, "scripts": { diff --git a/judging/web/playwright.config.ts b/sites/judging/playwright.config.ts similarity index 100% rename from judging/web/playwright.config.ts rename to sites/judging/playwright.config.ts diff --git a/judging/web/public/manifest.webmanifest b/sites/judging/public/manifest.webmanifest similarity index 100% rename from judging/web/public/manifest.webmanifest rename to sites/judging/public/manifest.webmanifest diff --git a/judging/web/tsconfig.json b/sites/judging/tsconfig.json similarity index 100% rename from judging/web/tsconfig.json rename to sites/judging/tsconfig.json From 152e261a342c200e6dba380fa15f125f3b02219d Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 8 Oct 2026 15:06:23 -0400 Subject: [PATCH 5/8] feat(panel): run judging inside the portal Judging no longer has its own server or web app. mainweb mounts the judging API at /api/panel and resolves the caller from the portal session: staff organize, volunteers volunteer, and a judge is matched by the email on their portal judge row for a panel edition. The desk, organizer console, public board and feedback card are portal routes (/judge/panel, /admin/judging/panel, /judging), so the ticket handoff, magic-link sign-in and organizer token are gone. Club-side sync calls the judging procedures in process instead of over HTTP, and published results are read straight from the shared database. Live views poll, since the portal process has no WebSocket hub. Removed the standalone pieces this leaves unused: sites/judging, the server entrypoint and WebSocket hub, the two-server check, Dockerfiles, Terraform, and the PANEL_URL / PANEL_SERVICE_TOKEN / PANEL_WEB_URL settings. --- .github/workflows/ci.yml | 30 -- PLAN.md | 24 +- docker-compose.yml | 23 - docs/judging/README.md | 65 +-- docs/judging/adr/001-standalone.md | 2 +- docs/judging/contributing.md | 6 +- docs/judging/deploy.md | 24 +- packages/api/package.json | 5 +- packages/api/src/routers/judge/portal.ts | 13 +- packages/api/src/services/panel-sync.ts | 108 +--- packages/api/src/services/panel.ts | 123 +++++ packages/judging-db/src/client.ts | 5 +- packages/judging-server/Dockerfile | 14 - packages/judging-server/package.json | 6 +- packages/judging-server/src/app.test.ts | 22 + packages/judging-server/src/app.ts | 32 +- .../judging-server/src/cross-server.check.ts | 111 ---- packages/judging-server/src/env.ts | 11 - packages/judging-server/src/hub.ts | 31 -- packages/judging-server/src/index.ts | 80 +-- packages/judging-server/terraform/main.tf | 129 ----- pnpm-lock.yaml | 129 +---- sites/judging/.gitignore | 2 - sites/judging/Dockerfile | 29 -- .../app/j/[orgSlug]/[eventSlug]/desk.tsx | 474 ------------------ .../app/j/[orgSlug]/[eventSlug]/page.tsx | 16 - sites/judging/app/layout.tsx | 30 -- .../app/o/[orgSlug]/[eventSlug]/page.tsx | 16 - sites/judging/app/page.tsx | 8 - sites/judging/e2e/visit.spec.ts | 61 --- sites/judging/eslint.config.mjs | 8 - sites/judging/lib/api.ts | 15 - sites/judging/next.config.ts | 7 - sites/judging/package.json | 26 - sites/judging/playwright.config.ts | 9 - sites/judging/public/manifest.webmanifest | 8 - sites/judging/tsconfig.json | 11 - .../app/(portal)/admin/judging/page.tsx | 5 +- .../panel}/[orgSlug]/[eventSlug]/console.tsx | 143 ++++-- .../panel}/[orgSlug]/[eventSlug]/editors.tsx | 166 ++++-- .../panel/[orgSlug]/[eventSlug]/page.tsx | 16 + .../app/(portal)/api/panel/[...path]/route.ts | 24 + sites/mainweb/app/(portal)/judge/page.tsx | 4 +- .../panel/[orgSlug]/[eventSlug]/desk.tsx | 440 ++++++++++++++++ .../panel/[orgSlug]/[eventSlug]/page.tsx | 24 + .../judging}/[orgSlug]/[eventSlug]/board.tsx | 47 +- .../[eventSlug]/feedback/[token]/page.tsx | 21 +- .../judging}/[orgSlug]/[eventSlug]/page.tsx | 13 +- .../lib/held.ts => mainweb/lib/panel-held.ts} | 0 sites/mainweb/lib/panel.server.ts | 16 + sites/mainweb/lib/panel.ts | 10 + sites/mainweb/next.config.mjs | 10 +- turbo.json | 7 - 53 files changed, 1085 insertions(+), 1574 deletions(-) create mode 100644 packages/api/src/services/panel.ts delete mode 100644 packages/judging-server/Dockerfile delete mode 100644 packages/judging-server/src/cross-server.check.ts delete mode 100644 packages/judging-server/src/env.ts delete mode 100644 packages/judging-server/src/hub.ts delete mode 100644 packages/judging-server/terraform/main.tf delete mode 100644 sites/judging/.gitignore delete mode 100644 sites/judging/Dockerfile delete mode 100644 sites/judging/app/j/[orgSlug]/[eventSlug]/desk.tsx delete mode 100644 sites/judging/app/j/[orgSlug]/[eventSlug]/page.tsx delete mode 100644 sites/judging/app/layout.tsx delete mode 100644 sites/judging/app/o/[orgSlug]/[eventSlug]/page.tsx delete mode 100644 sites/judging/app/page.tsx delete mode 100644 sites/judging/e2e/visit.spec.ts delete mode 100644 sites/judging/eslint.config.mjs delete mode 100644 sites/judging/lib/api.ts delete mode 100644 sites/judging/next.config.ts delete mode 100644 sites/judging/package.json delete mode 100644 sites/judging/playwright.config.ts delete mode 100644 sites/judging/public/manifest.webmanifest delete mode 100644 sites/judging/tsconfig.json rename sites/{judging/app/o => mainweb/app/(portal)/admin/judging/panel}/[orgSlug]/[eventSlug]/console.tsx (77%) rename sites/{judging/app/o => mainweb/app/(portal)/admin/judging/panel}/[orgSlug]/[eventSlug]/editors.tsx (66%) create mode 100644 sites/mainweb/app/(portal)/admin/judging/panel/[orgSlug]/[eventSlug]/page.tsx create mode 100644 sites/mainweb/app/(portal)/api/panel/[...path]/route.ts create mode 100644 sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx create mode 100644 sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/page.tsx rename sites/{judging/app/e => mainweb/app/judging}/[orgSlug]/[eventSlug]/board.tsx (64%) rename sites/{judging/app/e => mainweb/app/judging}/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx (71%) rename sites/{judging/app/e => mainweb/app/judging}/[orgSlug]/[eventSlug]/page.tsx (62%) rename sites/{judging/lib/held.ts => mainweb/lib/panel-held.ts} (100%) create mode 100644 sites/mainweb/lib/panel.server.ts create mode 100644 sites/mainweb/lib/panel.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 899084da..d0d58879 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,6 @@ name: CI/CD on: push: branches: ["main", "dev"] - tags: ["panel-v*"] pull_request: concurrency: @@ -108,32 +107,3 @@ jobs: projectId: dsgt-website channelId: live entryPoint: "." - - publish-judging: - name: Publish judging images - needs: verify - if: startsWith(github.ref, 'refs/tags/panel-v') - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v6 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/build-push-action@v6 - with: - context: . - file: packages/judging-server/Dockerfile - push: true - tags: ghcr.io/${{ github.repository }}/panel-server:${{ github.ref_name }} - - uses: docker/build-push-action@v6 - with: - context: . - file: sites/judging/Dockerfile - push: true - build-args: NEXT_PUBLIC_PANEL_URL=${{ vars.NEXT_PUBLIC_PANEL_URL || 'http://localhost:8787' }} - tags: ghcr.io/${{ github.repository }}/panel-web:${{ github.ref_name }} diff --git a/PLAN.md b/PLAN.md index ba92e79f..1680889d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -3,7 +3,8 @@ **Repo:** `query` · **Drafted:** 2026-10-07 · **Working name:** `panel` (rename freely) A hackathon judging platform built as ordinary workspaces in this monorepo -(`packages/judging-*`, `sites/judging`, scope `@query/judging-*`). Hacklytics +(`packages/judging-*`, scope `@query/judging-*`) and served by the portal +(`sites/mainweb`). Hacklytics is its first user. It is not being split into its own repository. The current judging code in `packages/api/src/routers/judge` and @@ -49,12 +50,13 @@ packages/judging-db/ @query/judging-db Drizzle schema, SQL migrations packages/judging-server/ @query/judging-server Node service: tRPC over HTTP, OpenAPI mirror, WebSocket hub, auth adapters, outbox + webhooks, - Prometheus metrics. Dockerfile and - optional Terraform (terraform/) + Prometheus metrics. A library: + mainweb mounts it at /api/panel packages/judging-cli/ @query/judging-cli import/export/seed/migrate/doctor -sites/judging/ @query/judging-web Next.js: judge PWA, admin console, - live board, public leaderboard, - team feedback. Dockerfile +sites/mainweb/ web portal routes: judge desk + (/judge/panel), organizer console + (/admin/judging/panel), public + board and team feedback (/judging) docs/judging/ ADRs, deploy, API, rubric guide ``` @@ -297,15 +299,13 @@ Thin and replaceable. on judge approval calls `judge.upsert`; on publish, pulls results and feedback tokens back into `hackathon_result` so `/hackathons/[id]` keeps rendering from its own tables. -- `sites/mainweb` `/judge` becomes a trusted-JWT handoff to the judge PWA. - Admin "Judging" tab deep-links into the console with the same handoff. +- The judge desk and organizer console are portal routes on the portal + session (`/judge/panel/...`, `/admin/judging/panel/...`). No handoff. - Old `routers/judge/*` and the judging UI stay behind a feature flag per edition (`hackathon.judging_backend = legacy | panel`) until one event has run on the new system, then are deleted. -- Deploy: `@query/judging-server` and `@query/judging-web` as two Cloud Run services in the - same GCP project, Postgres as a second Neon database (not a schema in the - club DB, so the extraction is clean), Memorystore Redis only if more than - one server instance is wanted. Terraform under `packages/judging-server/terraform`. +- Deploy: ships with mainweb. The API is `/api/panel` in the same process, + on the club Postgres (ADR-003). No separate service. --- diff --git a/docker-compose.yml b/docker-compose.yml index 32676ea2..8feef538 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -116,29 +116,6 @@ services: timeout: 5s retries: 10 - # ── Judging ────────────────────────────────────────────────────────────── - # - # The judging server on the same Postgres as the club site: - # - # docker compose --profile judging up -d --build - - judging-server: - build: - context: . - dockerfile: packages/judging-server/Dockerfile - container_name: monorepo-judging-server - profiles: ["judging"] - environment: - DATABASE_URL: postgresql://postgres:postgres@db:5432/neondb - PANEL_JWT_SECRET: dev-only-change-me - PANEL_DEV_AUTH: "1" - PORT: "8787" - ports: - - "8787:8787" - depends_on: - db: - condition: service_healthy - volumes: pgdata: promdata: diff --git a/docs/judging/README.md b/docs/judging/README.md index 613eb7b7..5043f260 100644 --- a/docs/judging/README.md +++ b/docs/judging/README.md @@ -1,14 +1,8 @@ # Panel -A hackathon judging product: its own data, its own API, its own web app. -Rubrics, tracks, timers, rooms, and dispatch strategy are rows, not code. -Postgres and Node are enough to run it. Set `REDIS_URL` when more than one -server process should share live updates. Without it, each process fans out -in memory, and a client that misses a message reloads the same state from -the next snapshot. - -Panel is part of this monorepo. Its packages are ordinary workspaces next to -the club's, and `packages/api` calls `@query/judging-core` directly. +Hackathon judging inside the portal. Rubrics, tracks, timers, rooms, and +dispatch strategy are rows, not code. It runs in the mainweb process on the +club Postgres, and the portal sign-in decides who is judging or organizing. ## Layout @@ -16,40 +10,51 @@ the club's, and `packages/api` calls `@query/judging-core` directly. | --- | --- | --- | | `packages/judging-core/` | `@query/judging-core` | Pure functions. No I/O, no clock, no runtime dependencies. | | `packages/judging-db/` | `@query/judging-db` | Schema, committed SQL migrations, demo seed. | -| `packages/judging-server/` | `@query/judging-server` | HTTP API: dispatch, votes, health, metrics. | -| `sites/judging/` | `@query/judging-web` | Judge desk, organizer console, public board. | -| `packages/judging-cli/` | `@query/judging-cli` | `panel migrate`, `panel seed demo`, `panel doctor`. | +| `packages/judging-server/` | `@query/judging-server` | The judging API (Hono + tRPC) as a library. | +| `packages/judging-cli/` | `@query/judging-cli` | `panel migrate`, `panel seed demo`, `panel doctor`, `panel outbox drain`. | +| `packages/api/src/services/panel.ts` | `@query/api/panel` | One API instance per process, and the portal user as a judging actor. | -## Run the core +## In the portal -From the repository root: +| Route | Who | What | +| --- | --- | --- | +| `/api/panel/*` | the pages below | The judging API. Same routes as `docs/judging/api.md`, without the prefix. | +| `/judge/panel/[org]/[event]` | approved judges | Judge desk: next table, QR or table number, scores, pairwise, offline hold. | +| `/admin/judging/panel/[org]/[event]` | staff | Organizer console: phases, floor, tables, rubric, tracks, judges, results. | +| `/judging/[org]/[event]` | anyone | Public board while judging, placements after publish. | +| `/judging/[org]/[event]/feedback/[token]` | a team | That team's feedback card after publish. | -``` -pnpm --filter @query/judging-core test -pnpm --filter @query/judging-core typecheck -pnpm --filter @query/judging-core lint -``` +Roles come from the `admins` row: `super_admin` is owner, other staff are +admin, a volunteer row is volunteer, a bug tester has none. A judge is matched +by the email on their portal judge row for an edition whose +`judging_backend` is `panel`. Projects and judges are copied in process when +an organizer promotes submissions or approves a judge, and published results +are pulled back into `hackathon_result`. -`pnpm test` includes `@query/judging-core` and `@query/judging-server`. +Set `PANEL_EVENT_ID`, `PANEL_ORG_SLUG`, and `PANEL_EVENT_SLUG` to the judging +event an edition uses. Without them the portal shows no panel links. -## Database and server +Live views poll. There is no WebSocket in the portal process; the board uses +the event's `board_poll_seconds` and the console polls the floor every five +seconds. -Postgres is the one database the rest of the monorepo uses (`DATABASE_URL`). Panel migrations add its tables there. `PANEL_DATABASE_URL` is only a fallback when `DATABASE_URL` is unset. +## Checks -``` -docker compose --profile judging up -d --build -``` +`pnpm lint`, `pnpm typecheck`, and `pnpm test` at the root cover these +packages with the rest of the repository. -On the host, pointed at that database: +## Database + +Postgres is the one database the rest of the monorepo uses (`DATABASE_URL`). +Panel migrations add its tables there. ``` +docker compose up -d $env:DATABASE_URL = "postgresql://postgres:postgres@localhost:5433/neondb" -$env:PANEL_JWT_SECRET = "dev-only-change-me" pnpm --filter @query/judging-cli panel migrate pnpm --filter @query/judging-cli panel seed demo pnpm --filter @query/judging-cli panel doctor ``` -`panel doctor` checks Postgres, pending migrations, and `PANEL_JWT_SECRET`. Redis is reported and not required. - -The HTTP server is Hono. tRPC is mounted at `/trpc`. `GET /healthz` is the process, `GET /readyz` is Postgres, `GET /metrics` is Prometheus. `GET /openapi.json` lists the REST routes. How to authenticate is in `docs/judging/api.md`. Magic-link codes are returned in the response only when `PANEL_DEV_AUTH=1`, which is how the compose stack signs in without SMTP. +Webhooks queue in `outbox`. Drain them with `panel outbox drain`, on a +schedule if an event uses webhooks. diff --git a/docs/judging/adr/001-standalone.md b/docs/judging/adr/001-standalone.md index f463c613..0c335096 100644 --- a/docs/judging/adr/001-standalone.md +++ b/docs/judging/adr/001-standalone.md @@ -3,7 +3,7 @@ ## Status Superseded. Panel's packages are ordinary monorepo workspaces -(`packages/judging-*`, `sites/judging`) under the `@query/*` scope. The import +(`packages/judging-*`) under the `@query/*` scope, served by the portal. The import boundary and the plan to split it out were dropped. ## Decision diff --git a/docs/judging/contributing.md b/docs/judging/contributing.md index 3e8919c1..8048ca15 100644 --- a/docs/judging/contributing.md +++ b/docs/judging/contributing.md @@ -1,6 +1,6 @@ # Contributing -Panel is `packages/judging-*` and `sites/judging`. `pnpm lint`, `pnpm typecheck`, and `pnpm test` at the root cover it with the rest of the repository. +Panel is `packages/judging-*` plus its portal routes in `sites/mainweb` (listed in `README.md`). `pnpm lint`, `pnpm typecheck`, and `pnpm test` at the root cover it with the rest of the repository. ## Checks @@ -12,10 +12,10 @@ pnpm --filter @query/judging-server test pnpm --filter @query/judging-cli test pnpm --filter @query/judging-core lint pnpm --filter @query/judging-server lint -pnpm --filter @query/judging-web lint +pnpm --filter web lint pnpm --filter @query/judging-db typecheck pnpm --filter @query/judging-server typecheck -pnpm --filter @query/judging-web typecheck +pnpm --filter web typecheck pnpm --filter @query/judging-cli typecheck ``` diff --git a/docs/judging/deploy.md b/docs/judging/deploy.md index 706406e2..bbd89485 100644 --- a/docs/judging/deploy.md +++ b/docs/judging/deploy.md @@ -1,25 +1,19 @@ # Deploy -Docker Compose is the deploy to start from. From the repository root: +Judging ships with mainweb. There is no separate service or image: the API is +`/api/panel` in the portal, and the pages are portal routes (see `README.md`). -``` -docker compose --profile judging up -d --build -``` - -Then, against the published Postgres port: +Before the first event on panel, against the production database: ``` -DATABASE_URL=postgresql://postgres:postgres@localhost:5433/neondb -PANEL_JWT_SECRET=dev-only-change-me +DATABASE_URL= pnpm --filter @query/judging-cli panel migrate -pnpm --filter @query/judging-cli panel seed demo pnpm --filter @query/judging-cli panel doctor ``` -`panel doctor` must report postgres, migrations, and jwt as ok. Set `DATABASE_URL` to the same Postgres the club app uses. `PANEL_DATABASE_URL` is only read when `DATABASE_URL` is unset. The stack is one API server. Leave `REDIS_URL` unset so live updates stay in that process. Clients that miss a socket message recover from `GET /v1/live`. - -The optional GCP module is `packages/judging-server/terraform`. It is not required to run the stack. The server image is `packages/judging-server/Dockerfile` and the web image is `sites/judging/Dockerfile`. Pass `NEXT_PUBLIC_PANEL_URL` at build time so the desk calls the API. +Then set `PANEL_EVENT_ID`, `PANEL_ORG_SLUG`, and `PANEL_EVENT_SLUG` in the +mainweb environment (`apphosting.yaml`) and switch the edition's +`judging_backend` to `panel`. -Set `PANEL_SMTP_URL` to an SMTP URL when sign-in codes should be emailed. -Without it, `PANEL_DEV_AUTH=1` returns the code in the response. OIDC waits -on a provider; trusted JWT from the club site is the handoff that works now. +`PANEL_JWT_SECRET` is only needed for callers outside the portal that send a +bearer token; API keys from `catalog.issueApiKey` work without it. diff --git a/packages/api/package.json b/packages/api/package.json index 2290fd44..795d7c4e 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -18,7 +18,8 @@ "./eastern-time": "./src/services/eastern-time.ts", "./metrics": "./src/services/metrics.ts", "./portal-context": "./src/types/portal-context.ts", - "./resume-list": "./src/services/resume-list.ts" + "./resume-list": "./src/services/resume-list.ts", + "./panel": "./src/services/panel.ts" }, "scripts": { "lint": "eslint . --max-warnings 0", @@ -28,6 +29,8 @@ "dependencies": { "@query/auth": "workspace:*", "@query/db": "workspace:*", + "@query/judging-db": "workspace:*", + "@query/judging-server": "workspace:*", "@tanstack/react-query": "5.103.2", "@trpc/client": "11.19.0", "@trpc/next": "11.19.0", diff --git a/packages/api/src/routers/judge/portal.ts b/packages/api/src/routers/judge/portal.ts index 89dc0d40..2b2b8aa2 100644 --- a/packages/api/src/routers/judge/portal.ts +++ b/packages/api/src/routers/judge/portal.ts @@ -69,23 +69,16 @@ export const judgePortalRouter = createTRPCRouter({ return result; }), - /** A short-lived ticket into the panel desk, only for an edition that opted in. */ + /** The panel judge desk in the portal, only for an edition that opted in. */ panelDesk: protectedProcedure.query(async ({ ctx }) => { return panelDeskUrl(ctx.db as DrizzleDB, ctx.userId as string); }), - /** Organizer handoff into the panel console when this edition uses panel. */ + /** The panel organizer console in the portal when this edition uses panel. */ panelConsole: isAdmin .input(z.object({ hackathonId: z.string().uuid() })) .query(async ({ ctx, input }) => { - const email = ctx.session?.user?.email; - if (!email) return { url: null as string | null }; - return panelConsoleUrl(ctx.db as DrizzleDB, { - hackathonId: input.hackathonId, - userId: ctx.userId as string, - email, - name: ctx.session?.user?.name ?? email, - }); + return panelConsoleUrl(ctx.db as DrizzleDB, input.hackathonId); }), getMyAssignments: protectedProcedure.query(async ({ ctx }) => { diff --git a/packages/api/src/services/panel-sync.ts b/packages/api/src/services/panel-sync.ts index b1f17fac..d88fdc1e 100644 --- a/packages/api/src/services/panel-sync.ts +++ b/packages/api/src/services/panel-sync.ts @@ -1,10 +1,11 @@ import { and, eq, inArray, sql } from "drizzle-orm"; import { hackathonProjects, hackathonResults, hackathons, judges, judgingProjects } from "@query/db"; -import { createHmac } from "node:crypto"; import type { DrizzleDB } from "@query/db"; +import { publishedPlacements } from "@query/judging-server"; +import { panel, panelAsPortal } from "./panel"; /** - * Copies an edition into the panel service when that edition has opted in. + * Copies an edition into judging when that edition has opted in. * A failure here does not undo the club write. The portal stays the source * of registration, and the same action can be run again. */ @@ -24,8 +25,9 @@ export async function syncProjectsToPanel(db: DrizzleDB, hackathonId: string) { inArray(hackathonProjects.status, ["submitted", "judging"]), ), ); + const caller = panelAsPortal(); for (const row of rows) { - await post(target, "project.upsert", { + await caller.project.upsert({ eventId: target.eventId, externalId: row.id, name: row.name, @@ -46,7 +48,7 @@ export async function syncJudgeToPanel( if (!judge.email) return; const target = await panelTarget(db, judge.hackathonId); if (!target) return; - await post(target, "judge.upsert", { + await panelAsPortal().judge.upsert({ eventId: target.eventId, email: judge.email, name: judge.email, @@ -58,54 +60,22 @@ export async function syncJudgeToPanel( } async function panelTarget(db: DrizzleDB, hackathonId: string) { - const url = process.env.PANEL_URL; - const token = process.env.PANEL_SERVICE_TOKEN; const eventId = process.env.PANEL_EVENT_ID; - if (!url || !token || !eventId) return null; + if (!eventId) return null; const [row] = await db .select({ backend: hackathons.judgingBackend }) .from(hackathons) .where(eq(hackathons.id, hackathonId)); if (row?.backend !== "panel") return null; - return { url, token, eventId }; -} - -async function post( - target: { url: string; token: string }, - procedure: string, - body: unknown, -) { - const response = await fetch(`${target.url.replace(/\/$/, "")}/trpc/${procedure}`, { - method: "POST", - headers: { - authorization: `Bearer ${target.token}`, - "content-type": "application/json", - }, - body: JSON.stringify(body), - }); - if (!response.ok) { - throw new Error(`panel ${procedure} returned ${response.status}`); - } + return { eventId }; } export async function pullPanelResults(db: DrizzleDB, hackathonId: string) { const target = await panelTarget(db, hackathonId); if (!target) return 0; - const response = await fetch( - `${target.url.replace(/\/$/, "")}/v1/results/${target.eventId}`, - { headers: { authorization: `Bearer ${target.token}` } }, - ); - if (response.status === 403) throw new Error("Panel results are not published"); - if (!response.ok) throw new Error(`panel results returned ${response.status}`); - const body = (await response.json()) as { - rows: { - externalId: string | null; - placement: number | null; - score: number; - track: string; - voteCount: number; - }[]; - }; + const body = await publishedPlacements(panel().db, target.eventId); + if (!body) throw new Error("Panel event not found"); + if (!body.published) throw new Error("Panel results are not published"); const judging = await db .select({ id: judgingProjects.id, sourceProjectId: judgingProjects.sourceProjectId }) .from(judgingProjects) @@ -150,11 +120,9 @@ export async function pullPanelResults(db: DrizzleDB, hackathonId: string) { } export async function panelDeskUrl(db: DrizzleDB, userId: string) { - const web = process.env.PANEL_WEB_URL; - const secret = process.env.PANEL_JWT_SECRET; const org = process.env.PANEL_ORG_SLUG; const eventSlug = process.env.PANEL_EVENT_SLUG; - if (!web || !secret || !org || !eventSlug) return { url: null as string | null }; + if (!org || !eventSlug) return { url: null as string | null }; const [row] = await db .select({ email: judges.email }) .from(judges) @@ -167,59 +135,17 @@ export async function panelDeskUrl(db: DrizzleDB, userId: string) { ), ); if (!row?.email) return { url: null }; - const now = Math.floor(Date.now() / 1000); - const ticket = signHs256( - { - sub: userId, - email: row.email, - name: row.email, - org, - role: null, - judge_external_id: null, - iat: now, - exp: now + 60 * 30, - }, - secret, - ); - const url = `${web.replace(/\/$/, "")}/j/${org}/${eventSlug}?ticket=${encodeURIComponent(ticket)}`; - return { url }; + return { url: `/judge/panel/${org}/${eventSlug}` }; } -export async function panelConsoleUrl( - db: DrizzleDB, - input: { hackathonId: string; userId: string; email: string; name: string }, -) { - const web = process.env.PANEL_WEB_URL; - const secret = process.env.PANEL_JWT_SECRET; +export async function panelConsoleUrl(db: DrizzleDB, hackathonId: string) { const org = process.env.PANEL_ORG_SLUG; const eventSlug = process.env.PANEL_EVENT_SLUG; - if (!web || !secret || !org || !eventSlug) return { url: null as string | null }; + if (!org || !eventSlug) return { url: null as string | null }; const [row] = await db .select({ backend: hackathons.judgingBackend }) .from(hackathons) - .where(eq(hackathons.id, input.hackathonId)); + .where(eq(hackathons.id, hackathonId)); if (row?.backend !== "panel") return { url: null }; - const now = Math.floor(Date.now() / 1000); - const ticket = signHs256( - { - sub: input.userId, - email: input.email, - name: input.name, - org, - role: "organizer", - judge_external_id: null, - iat: now, - exp: now + 60 * 30, - }, - secret, - ); - const url = `${web.replace(/\/$/, "")}/o/${org}/${eventSlug}?ticket=${encodeURIComponent(ticket)}`; - return { url }; -} - -function signHs256(payload: Record, secret: string) { - const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); - const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); - const signature = createHmac("sha256", secret).update(`${header}.${body}`).digest("base64url"); - return `${header}.${body}.${signature}`; + return { url: `/admin/judging/panel/${org}/${eventSlug}` }; } diff --git a/packages/api/src/services/panel.ts b/packages/api/src/services/panel.ts new file mode 100644 index 00000000..cfc23202 --- /dev/null +++ b/packages/api/src/services/panel.ts @@ -0,0 +1,123 @@ +import { and, eq } from "drizzle-orm"; +import { admins, hackathons, judges } from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { createDb } from "@query/judging-db"; +import type { PanelDb } from "@query/judging-db"; +import { + InMemoryBus, + appRouter, + createApp, + createMetrics, +} from "@query/judging-server"; +import type { Actor, Role } from "@query/judging-server"; +import { createContext } from "../context"; +import { + isBugTesterRole, + isExpiredAdmin, + isStaffRole, +} from "../types/portal-context"; + +type Embedded = { + db: PanelDb; + app: ReturnType; + bus: InMemoryBus; + metrics: ReturnType; +}; + +const globalForPanel = globalThis as unknown as { panel?: Embedded }; + +/** + * Judging runs inside the portal: same process, same Postgres, and the portal + * sign-in decides who the caller is. One instance per process, so the metrics + * registry and the pool are not rebuilt on every request. + */ +export function panel(): Embedded { + if (globalForPanel.panel) return globalForPanel.panel; + const url = process.env.DATABASE_URL; + if (!url) throw new Error("DATABASE_URL is not set"); + const { db } = createDb(url, { + ssl: + process.env.NODE_ENV === "production" + ? { rejectUnauthorized: true } + : undefined, + }); + const metrics = createMetrics(); + const bus = new InMemoryBus(); + const app = createApp({ + db, + metrics, + bus, + jwtSecret: process.env.PANEL_JWT_SECRET ?? "", + devAuth: false, + busMode: "memory", + resolveActor: () => portalActor(), + }); + globalForPanel.panel = { db, app, bus, metrics }; + return globalForPanel.panel; +} + +/** The judging procedures, called by the portal itself to sync an edition. */ +export function panelAsPortal() { + const { db, bus, metrics } = panel(); + return appRouter.createCaller({ + db, + bus, + metrics, + now: new Date(), + actor: { + sub: "portal", + email: "portal", + name: "Portal", + org: null, + role: "admin", + judgeExternalId: null, + }, + }); +} + +/** + * The signed-in portal user as a judging actor. Staff organize, a volunteer + * row is a volunteer, and a judge on a panel edition is matched by the email + * on their judge row, which is the one synced into judging. + */ +export async function portalActor(): Promise { + const ctx = await createContext(); + const email = ctx.session?.user?.email; + if (!ctx.db || !ctx.userId || !email) return null; + const db = ctx.db as DrizzleDB; + const [admin, judgeRows] = await Promise.all([ + db.query.admins.findFirst({ + where: and(eq(admins.userId, ctx.userId), eq(admins.isActive, true)), + }), + db + .select({ email: judges.email }) + .from(judges) + .innerJoin(hackathons, eq(hackathons.id, judges.hackathonId)) + .where( + and( + eq(judges.userId, ctx.userId), + eq(judges.isActive, true), + eq(hackathons.judgingBackend, "panel"), + ), + ), + ]); + return { + sub: ctx.userId, + email: judgeRows[0]?.email ?? email, + name: ctx.session?.user?.name ?? email, + org: process.env.PANEL_ORG_SLUG ?? null, + role: roleFor(admin ?? null), + judgeExternalId: null, + }; +} + +function roleFor( + admin: { role: string; expiresAt: Date | null } | null, +): Role | null { + if (!admin || isExpiredAdmin(admin) || isBugTesterRole(admin.role)) { + return null; + } + if (admin.role === "super_admin") return "owner"; + if (isStaffRole(admin.role)) return "admin"; + return "volunteer"; +} diff --git a/packages/judging-db/src/client.ts b/packages/judging-db/src/client.ts index 6824b41c..a658b8c9 100644 --- a/packages/judging-db/src/client.ts +++ b/packages/judging-db/src/client.ts @@ -1,9 +1,10 @@ import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; +import type { PoolConfig } from "pg"; import * as schema from "./schema"; -export function createDb(url: string) { - const pool = new Pool({ connectionString: url, max: 10 }); +export function createDb(url: string, options: PoolConfig = {}) { + const pool = new Pool({ connectionString: url, max: 10, ...options }); pool.on("error", () => { // The pool drops a dead client on its own. An unhandled "error" event // would take the process down. diff --git a/packages/judging-server/Dockerfile b/packages/judging-server/Dockerfile deleted file mode 100644 index 78dbfbd3..00000000 --- a/packages/judging-server/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM node:22-bookworm-slim - -WORKDIR /app -RUN corepack enable - -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ -COPY tooling ./tooling -COPY packages ./packages -COPY sites ./sites - -RUN pnpm install --frozen-lockfile --filter @query/judging-server... - -EXPOSE 8787 -CMD ["pnpm", "--filter", "@query/judging-server", "start"] diff --git a/packages/judging-server/package.json b/packages/judging-server/package.json index 141ef278..da7cabd3 100644 --- a/packages/judging-server/package.json +++ b/packages/judging-server/package.json @@ -4,16 +4,15 @@ "private": true, "type": "module", "exports": { + ".": "./src/index.ts", "./outbox": "./src/services/outbox.ts" }, "scripts": { - "start": "tsx src/index.ts", "lint": "eslint . --max-warnings 0", "typecheck": "tsc --noEmit", "test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts" }, "dependencies": { - "@hono/node-server": "^1.19.0", "@query/judging-core": "workspace:*", "@query/judging-db": "workspace:*", "@trpc/server": "11.19.0", @@ -23,15 +22,12 @@ "jose": "^6.1.0", "nodemailer": "^10.0.10", "prom-client": "15.1.3", - "tsx": "^4.23.15", - "ws": "8.20.1", "zod": "4.6.5" }, "devDependencies": { "@query/eslint-config": "workspace:*", "@query/tsconfig": "workspace:*", "@types/node": "22.20.4", - "@types/ws": "^8.18.1", "typescript": "7.0.2", "vitest": "^5.0.1" } diff --git a/packages/judging-server/src/app.test.ts b/packages/judging-server/src/app.test.ts index 9d445519..d31f2ae7 100644 --- a/packages/judging-server/src/app.test.ts +++ b/packages/judging-server/src/app.test.ts @@ -16,3 +16,25 @@ describe("health", () => { expect(await response.json()).toEqual({ ok: true }); }); }); + +describe("session actor", () => { + it("asks resolveActor when there is no bearer token", async () => { + const seen: string[] = []; + const app = createApp({ + db: {} as never, + metrics: createMetrics(), + jwtSecret: "test", + devAuth: false, + busMode: "memory", + resolveActor: async (request) => { + seen.push(new URL(request.url).pathname); + return null; + }, + }); + const response = await app.request( + "/v1/session/progress?eventId=00000000-0000-4000-8000-000000000000", + ); + expect(response.status).toBe(401); + expect(seen).toEqual(["/v1/session/progress"]); + }); +}); diff --git a/packages/judging-server/src/app.ts b/packages/judging-server/src/app.ts index b9d19367..e6718151 100644 --- a/packages/judging-server/src/app.ts +++ b/packages/judging-server/src/app.ts @@ -8,7 +8,7 @@ import type { PanelDb } from "@query/judging-db"; import { event, loginCode, membership, organization, user, visit } from "@query/judging-db"; import { hashCode, newLoginCode, roleAtLeast, signActor, verifyActor } from "./auth"; import { sendLoginCode } from "./mail"; -import type { Role } from "./auth"; +import type { Actor, Role } from "./auth"; import type { Bus } from "./bus"; import { InMemoryBus } from "./bus"; import { feedbackCard, feedbackTokenForExternal, actorForApiKey } from "./services/catalog"; @@ -64,6 +64,8 @@ export function createApp(options: { smtpUrl?: string; busMode: "memory" | "redis"; bus?: Bus; + /** Who is signed in when the request carries no bearer token, e.g. a portal session cookie. */ + resolveActor?: (request: Request) => Promise; }) { const bus = options.bus ?? new InMemoryBus(); const app = new Hono(); @@ -215,7 +217,9 @@ export function createApp(options: { return c.json(row); }); - const contextFor = async (c: { req: { header: (name: string) => string | undefined } }): Promise => { + const contextFor = async (c: { + req: { header: (name: string) => string | undefined; raw: Request }; + }): Promise => { const header = c.req.header("authorization"); let actor: Context["actor"] = null; if (header?.startsWith("Bearer ")) { @@ -228,6 +232,8 @@ export function createApp(options: { } catch { actor = await actorForApiKey(options.db, token); } + } else if (options.resolveActor) { + actor = await options.resolveActor(c.req.raw); } return { db: options.db, actor, now: new Date(), bus, metrics: options.metrics }; }; @@ -329,31 +335,13 @@ export function createApp(options: { }); app.get("/v1/results/run/:runId", async (c) => { - const header = c.req.header("authorization"); - if (!header?.startsWith("Bearer ")) return c.json({ message: "Unauthorized" }, 401); - try { - await verifyActor(header.slice("Bearer ".length), { - secret: options.jwtSecret, - jwksUrl: options.jwksUrl, - }); - } catch { - return c.json({ message: "Unauthorized" }, 401); - } + if (!(await contextFor(c)).actor) return c.json({ message: "Unauthorized" }, 401); const runId = z.string().uuid().parse(c.req.param("runId")); return c.json(await inspectRun(options.db, runId)); }); app.get("/v1/results/:eventId", async (c) => { - const header = c.req.header("authorization"); - if (!header?.startsWith("Bearer ")) return c.json({ message: "Unauthorized" }, 401); - try { - await verifyActor(header.slice("Bearer ".length), { - secret: options.jwtSecret, - jwksUrl: options.jwksUrl, - }); - } catch { - return c.json({ message: "Unauthorized" }, 401); - } + if (!(await contextFor(c)).actor) return c.json({ message: "Unauthorized" }, 401); const eventId = z.string().uuid().parse(c.req.param("eventId")); const placements = await publishedPlacements(options.db, eventId); if (!placements) return c.json({ message: "Not found" }, 404); diff --git a/packages/judging-server/src/cross-server.check.ts b/packages/judging-server/src/cross-server.check.ts deleted file mode 100644 index dff233ef..00000000 --- a/packages/judging-server/src/cross-server.check.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { WebSocket } from "ws"; -import { eq } from "drizzle-orm"; -import { createDb, event, judge, organization } from "@query/judging-db"; -import { signActor } from "./auth"; -import { log } from "./log"; - -const databaseUrl = process.env.DATABASE_URL ?? process.env.PANEL_DATABASE_URL; -const secret = process.env.PANEL_JWT_SECRET; -const portA = process.env.PORT_A ?? "8787"; -const portB = process.env.PORT_B ?? portA; -if (!databaseUrl || !secret) { - throw new Error("DATABASE_URL and PANEL_JWT_SECRET are required"); -} - -const { db, pool } = createDb(databaseUrl); -const [demo] = await db - .select({ eventId: event.id, org: organization.slug }) - .from(event) - .innerJoin(organization, eq(organization.id, event.orgId)) - .where(eq(organization.slug, "demo")); -if (!demo) throw new Error("demo event missing"); - -const judges = await db.select().from(judge).where(eq(judge.eventId, demo.eventId)); -await pool.end(); - -const baseA = `http://127.0.0.1:${portA}`; -const socket = new WebSocket( - `ws://127.0.0.1:${portB}/ws?channel=event:${demo.eventId}:board`, -); -await new Promise((resolve, reject) => { - socket.once("open", () => resolve()); - socket.once("error", reject); -}); - -let accept = false; -const heard = new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("no vote on the other server within 1s")), 15000); - socket.on("message", (data) => { - const text = String(data); - if (!accept || !text.includes("vote.cast")) return; - clearTimeout(timer); - resolve(text); - }); -}); - -let voted = false; -for (const row of judges) { - const token = await signActor( - { - sub: row.id, - email: row.email, - name: row.name, - org: demo.org, - role: null, - judgeExternalId: null, - }, - secret, - 600, - ); - const headers = { - authorization: `Bearer ${token}`, - "content-type": "application/json", - }; - const next = await fetch(`${baseA}/v1/session/next`, { - method: "POST", - headers, - body: JSON.stringify({ eventId: demo.eventId }), - }); - const visit = (await next.json()) as { - done?: boolean; - visitId?: string; - tableNumber?: number | null; - message?: string; - }; - if (!next.ok || visit.done || !visit.visitId) continue; - await fetch(`${baseA}/v1/session/arrive`, { - method: "POST", - headers, - body: JSON.stringify({ eventId: demo.eventId, tableNumber: visit.tableNumber }), - }); - const rubricResponse = await fetch( - `${baseA}/v1/session/rubric?eventId=${demo.eventId}`, - { headers }, - ); - const rubric = (await rubricResponse.json()) as { id: string; min: number }[]; - if (!Array.isArray(rubric) || rubric.length === 0) continue; - accept = true; - const vote = await fetch(`${baseA}/v1/session/vote`, { - method: "POST", - headers, - body: JSON.stringify({ - eventId: demo.eventId, - visitId: visit.visitId, - comment: null, - scores: rubric.map((item) => ({ criterionId: item.id, value: item.min })), - }), - }); - if (!vote.ok) continue; - voted = true; - break; -} - -if (!voted) throw new Error("no judge could cast a vote"); -const message = await Promise.race([ - heard, - new Promise((_, reject) => { - setTimeout(() => reject(new Error("no vote on the other server within 1s")), 1000); - }), -]); -log({ message }); -socket.close(); diff --git a/packages/judging-server/src/env.ts b/packages/judging-server/src/env.ts deleted file mode 100644 index 4240ad98..00000000 --- a/packages/judging-server/src/env.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function serverEnv() { - return { - databaseUrl: process.env.DATABASE_URL ?? process.env.PANEL_DATABASE_URL ?? "", - jwtSecret: process.env.PANEL_JWT_SECRET ?? "", - jwksUrl: process.env.PANEL_JWKS_URL, - devAuth: process.env.PANEL_DEV_AUTH === "1", - smtpUrl: process.env.PANEL_SMTP_URL, - port: Number(process.env.PORT ?? 8787), - redisUrl: process.env.REDIS_URL, - }; -} diff --git a/packages/judging-server/src/hub.ts b/packages/judging-server/src/hub.ts deleted file mode 100644 index 3c97c38a..00000000 --- a/packages/judging-server/src/hub.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Server } from "node:http"; -import { WebSocketServer } from "ws"; -import type { Bus } from "./bus"; -import type { Metrics } from "./metrics"; - -/** Clients subscribe with `?channel=event:{id}:board`. The bus is the only fan-out. */ -export function attachHub(server: Server, bus: Bus, metrics: Metrics) { - const sockets = new WebSocketServer({ server, path: "/ws" }); - sockets.on("connection", (socket, request) => { - const channel = new URL(request.url ?? "/", "http://localhost").searchParams.get( - "channel", - ); - metrics.wsConnections.inc(); - if (!channel) { - socket.close(); - metrics.wsConnections.dec(); - return; - } - const unsubscribe = bus.subscribe(channel, (message) => { - if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message)); - }); - socket.on("close", () => { - unsubscribe(); - metrics.wsConnections.dec(); - }); - }); - bus.onDown(() => { - for (const client of sockets.clients) client.close(); - }); - return sockets; -} diff --git a/packages/judging-server/src/index.ts b/packages/judging-server/src/index.ts index 1ef346c6..3f3beebb 100644 --- a/packages/judging-server/src/index.ts +++ b/packages/judging-server/src/index.ts @@ -1,73 +1,7 @@ -import { serve } from "@hono/node-server"; -import type { Server } from "node:http"; -import { sql } from "drizzle-orm"; -import { boardChannel, judgeChannel } from "@query/judging-core"; -import { createDb } from "@query/judging-db"; -import { createBus } from "./bus"; -import { attachHub } from "./hub"; -import { createApp } from "./app"; -import { serverEnv } from "./env"; -import { log } from "./log"; -import { createMetrics } from "./metrics"; -import { drainOutbox } from "./services/outbox"; -import { loadOpenVisits, visitsOverTarget } from "./services/overtime"; - -const env = serverEnv(); -if (!env.databaseUrl) { - log({ level: "error", message: "DATABASE_URL is not set" }); - process.exit(1); -} - -const { db } = createDb(env.databaseUrl); -const metrics = createMetrics(); -const bus = createBus(env.redisUrl); -const app = createApp({ - db, - metrics, - jwtSecret: env.jwtSecret, - jwksUrl: env.jwksUrl, - devAuth: env.devAuth, - smtpUrl: env.smtpUrl, - busMode: env.redisUrl ? "redis" : "memory", - bus, -}); - -const notedOvertime = new Set(); -const timer = setInterval(() => { - drainOutbox(db) - .then(async () => { - const pending = await db.execute<{ count: string }>( - sql`select count(*)::text as count from outbox where delivered_at is null`, - ); - const count = Number(pending.rows[0]?.count ?? 0); - metrics.outboxPending.set(Number.isFinite(count) ? count : 0); - }) - .catch((error: unknown) => { - log({ - level: "error", - message: error instanceof Error ? error.message : "outbox", - }); - }); - loadOpenVisits(db) - .then((rows) => { - for (const row of visitsOverTarget(rows, new Date())) { - if (notedOvertime.has(row.visitId)) continue; - notedOvertime.add(row.visitId); - const message = { kind: "judge.overtime", eventId: row.eventId, visitId: row.visitId }; - bus.publish(judgeChannel(row.judgeId), message); - bus.publish(boardChannel(row.eventId), message); - } - }) - .catch((error: unknown) => { - log({ - level: "error", - message: error instanceof Error ? error.message : "overtime", - }); - }); -}, 2000); -timer.unref(); - -const server = serve({ fetch: app.fetch, port: env.port }, () => { - log({ level: "info", message: "listening", port: env.port }); -}); -attachHub(server as Server, bus, metrics); +export { createApp } from "./app"; +export { appRouter } from "./router"; +export type { Context } from "./router"; +export type { Actor, Role } from "./auth"; +export { InMemoryBus } from "./bus"; +export { createMetrics } from "./metrics"; +export { publishedPlacements } from "./services/results"; diff --git a/packages/judging-server/terraform/main.tf b/packages/judging-server/terraform/main.tf deleted file mode 100644 index ad1f1989..00000000 --- a/packages/judging-server/terraform/main.tf +++ /dev/null @@ -1,129 +0,0 @@ -variable "project" { - type = string -} - -variable "region" { - type = string - default = "us-east1" -} - -variable "server_image" { - type = string -} - -variable "web_image" { - type = string -} - -variable "database_url_secret" { - type = string - description = "Secret Manager secret id holding DATABASE_URL, the shared Postgres database." -} - -resource "google_cloud_run_v2_service" "server" { - name = "panel-server" - location = var.region - project = var.project - - template { - containers { - image = var.server_image - ports { - container_port = 8787 - } - env { - name = "DATABASE_URL" - value_source { - secret_key_ref { - secret = var.database_url_secret - version = "latest" - } - } - } - } - } -} - -resource "google_cloud_run_v2_service" "web" { - name = "panel-web" - location = var.region - project = var.project - - template { - containers { - image = var.web_image - env { - name = "NEXT_PUBLIC_PANEL_URL" - value = google_cloud_run_v2_service.server.uri - } - } - } -} - -variable "alert_email" { - type = string - default = "" -} - -locals { - server_host = trimprefix(google_cloud_run_v2_service.server.uri, "https://") -} - -resource "google_monitoring_uptime_check_config" "readyz" { - display_name = "panel readyz" - timeout = "10s" - period = "60s" - project = var.project - - http_check { - path = "/readyz" - port = "443" - use_ssl = true - validate_ssl = true - } - - monitored_resource { - type = "uptime_url" - labels = { - project_id = var.project - host = local.server_host - } - } -} - -resource "google_monitoring_notification_channel" "email" { - count = var.alert_email == "" ? 0 : 1 - display_name = "panel alerts" - type = "email" - project = var.project - labels = { - email_address = var.alert_email - } -} - -resource "google_monitoring_alert_policy" "readyz" { - count = var.alert_email == "" ? 0 : 1 - display_name = "panel /readyz" - combiner = "OR" - project = var.project - - conditions { - display_name = "readyz check failed" - condition_threshold { - filter = "metric.type=\"monitoring.googleapis.com/uptime_check/check_passed\" AND resource.type=\"uptime_url\" AND metric.label.check_id=\"${google_monitoring_uptime_check_config.readyz.uptime_check_id}\"" - duration = "120s" - comparison = "COMPARISON_LT" - threshold_value = 1 - } - } - - notification_channels = [google_monitoring_notification_channel.email[0].id] -} - -output "server_uri" { - value = google_cloud_run_v2_service.server.uri -} - -output "web_uri" { - value = google_cloud_run_v2_service.web.uri -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c09297d0..ecbd1840 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,7 +26,7 @@ importers: dependencies: next: specifier: 16.3.7 - version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) typescript: specifier: ^7.0.2 version: 7.0.2 @@ -49,6 +49,12 @@ importers: '@query/db': specifier: workspace:* version: link:../db + '@query/judging-db': + specifier: workspace:* + version: link:../judging-db + '@query/judging-server': + specifier: workspace:* + version: link:../judging-server '@tanstack/react-query': specifier: 5.103.2 version: 5.103.2(react@19.3.0) @@ -57,7 +63,7 @@ importers: version: 11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2) '@trpc/next': specifier: 11.19.0 - version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) '@trpc/react-query': specifier: 11.19.0 version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2) @@ -118,7 +124,7 @@ importers: version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) next-auth: specifier: 5.0.0-beta.32 - version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0) + version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0) nodemailer: specifier: ^10.0.10 version: 10.0.10 @@ -158,7 +164,7 @@ importers: version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) next-auth: specifier: 5.0.0-beta.32 - version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0) + version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0) pg: specifier: 8.23.0 version: 8.23.0 @@ -267,9 +273,6 @@ importers: packages/judging-server: dependencies: - '@hono/node-server': - specifier: ^1.19.0 - version: 1.19.17(hono@4.13.13) '@query/judging-core': specifier: workspace:* version: link:../judging-core @@ -297,12 +300,6 @@ importers: prom-client: specifier: 15.1.3 version: 15.1.3 - tsx: - specifier: ^4.23.15 - version: 4.23.15 - ws: - specifier: ^8.20.1 - version: 8.21.3 zod: specifier: 4.6.5 version: 4.6.5 @@ -316,9 +313,6 @@ importers: '@types/node': specifier: 22.20.4 version: 22.20.4 - '@types/ws': - specifier: ^8.18.1 - version: 8.18.2 typescript: specifier: 7.0.2 version: 7.0.2 @@ -370,7 +364,7 @@ importers: version: 1.0.0 next: specifier: 16.3.7 - version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: specifier: 19.3.0 version: 19.3.0 @@ -415,40 +409,6 @@ importers: specifier: ^8.70.1 version: 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) - sites/judging: - dependencies: - next: - specifier: 16.3.7 - version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - react: - specifier: 19.3.0 - version: 19.3.0 - react-dom: - specifier: 19.3.0 - version: 19.3.0(react@19.3.0) - devDependencies: - '@playwright/test': - specifier: ^1.58.2 - version: 1.64.0 - '@query/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@query/tsconfig': - specifier: workspace:* - version: link:../../tooling/typescript - '@types/node': - specifier: 22.20.4 - version: 22.20.4 - '@types/react': - specifier: ^19.3.0 - version: 19.3.0 - '@types/react-dom': - specifier: ^19.3.0 - version: 19.3.0(@types/react@19.3.0) - typescript: - specifier: 7.0.2 - version: 7.0.2 - sites/mainweb: dependencies: '@google-cloud/storage': @@ -483,7 +443,7 @@ importers: version: 11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2) '@trpc/next': specifier: 11.19.0 - version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) '@trpc/react-query': specifier: 11.19.0 version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2) @@ -504,7 +464,7 @@ importers: version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) geist: specifier: ^1.7.2 - version: 1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) + version: 1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) lucide-react: specifier: ^1.48.0 version: 1.48.0(react@19.3.0) @@ -513,10 +473,10 @@ importers: version: 10.2.6 next: specifier: 16.3.7 - version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) next-auth: specifier: 5.0.0-beta.32 - version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0) + version: 5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -705,7 +665,7 @@ importers: version: 4.3.3 next: specifier: 16.3.7 - version: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) postcss: specifier: ^8.5.23 version: 8.5.28 @@ -1134,12 +1094,6 @@ packages: resolution: {integrity: sha512-7ckgSjeEprV92VrlLjpYFA3PFerflvFtF7Ea86oQXGNCprGZq5rtpAVK0zgWnCy+44mLjms42M7MiWa433gz2A==} engines: {node: '>=22'} - '@hono/node-server@1.19.17': - resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1564,11 +1518,6 @@ packages: '@pdf-lib/upng@1.0.1': resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} - '@playwright/test@1.64.0': - resolution: {integrity: sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==} - engines: {node: '>=20'} - hasBin: true - '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -2379,9 +2328,6 @@ packages: '@types/webxr@0.5.24': resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} - '@types/ws@8.18.2': - resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} - '@typescript-eslint/eslint-plugin@8.70.1': resolution: {integrity: sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4197,16 +4143,6 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - playwright-core@1.64.0: - resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} - engines: {node: '>=20'} - hasBin: true - - playwright@1.64.0: - resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==} - engines: {node: '>=20'} - hasBin: true - pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -5418,10 +5354,6 @@ snapshots: - encoding - supports-color - '@hono/node-server@1.19.17(hono@4.13.13)': - dependencies: - hono: 4.13.13 - '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -5739,10 +5671,6 @@ snapshots: dependencies: pako: 1.0.11 - '@playwright/test@1.64.0': - dependencies: - playwright: 1.64.0 - '@polka/url@1.0.0-next.29': {} '@radix-ui/number@1.1.3': {} @@ -6338,11 +6266,11 @@ snapshots: '@trpc/server': 11.19.0(typescript@7.0.2) typescript: 7.0.2 - '@trpc/next@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)': + '@trpc/next@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)': dependencies: '@trpc/client': 11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2) '@trpc/server': 11.19.0(typescript@7.0.2) - next: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 react-dom: 19.3.0(react@19.3.0) typescript: 7.0.2 @@ -6451,10 +6379,6 @@ snapshots: '@types/webxr@0.5.24': {} - '@types/ws@8.18.2': - dependencies: - '@types/node': 22.20.4 - '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -7651,9 +7575,9 @@ snapshots: - encoding - supports-color - geist@1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)): + geist@1.7.2(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)): dependencies: - next: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) generator-function@2.0.1: {} @@ -8154,10 +8078,10 @@ snapshots: natural-compare@1.4.0: {} - next-auth@5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0): + next-auth@5.0.0-beta.32(next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nodemailer@10.0.10)(react@19.3.0): dependencies: '@auth/core': 0.41.3(nodemailer@10.0.10) - next: 16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 optionalDependencies: nodemailer: 10.0.10 @@ -8167,7 +8091,7 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - next@16.3.7(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.3.7(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@next/env': 16.3.7 '@swc/helpers': 0.5.23 @@ -8187,7 +8111,6 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.3.7 '@next/swc-win32-x64-msvc': 16.3.7 '@opentelemetry/api': 1.9.1 - '@playwright/test': 1.64.0 babel-plugin-react-compiler: 1.0.0 sharp: 0.35.4(@types/node@22.20.4) transitivePeerDependencies: @@ -8367,12 +8290,6 @@ snapshots: picomatch@4.0.7: {} - playwright-core@1.64.0: {} - - playwright@1.64.0: - dependencies: - playwright-core: 1.64.0 - pngjs@5.0.0: {} possible-typed-array-names@1.1.0: {} diff --git a/sites/judging/.gitignore b/sites/judging/.gitignore deleted file mode 100644 index aaa9103e..00000000 --- a/sites/judging/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -test-results/ -playwright-report/ diff --git a/sites/judging/Dockerfile b/sites/judging/Dockerfile deleted file mode 100644 index 488feb4f..00000000 --- a/sites/judging/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -FROM node:22-bookworm-slim AS build - -WORKDIR /app -RUN corepack enable - -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ -COPY tooling ./tooling -COPY packages ./packages -COPY sites ./sites - -ARG NEXT_PUBLIC_PANEL_URL=http://localhost:8787 -ENV NEXT_PUBLIC_PANEL_URL=$NEXT_PUBLIC_PANEL_URL - -RUN pnpm install --frozen-lockfile --filter @query/judging-web... -RUN pnpm --filter @query/judging-web build - -FROM node:22-bookworm-slim - -WORKDIR /app -ENV NODE_ENV=production -ENV HOSTNAME=0.0.0.0 -ENV PORT=3000 - -COPY --from=build /app/sites/judging/.next/standalone ./ -COPY --from=build /app/sites/judging/.next/static ./sites/judging/.next/static -COPY --from=build /app/sites/judging/public ./sites/judging/public - -EXPOSE 3000 -CMD ["node", "sites/judging/server.js"] diff --git a/sites/judging/app/j/[orgSlug]/[eventSlug]/desk.tsx b/sites/judging/app/j/[orgSlug]/[eventSlug]/desk.tsx deleted file mode 100644 index 95cc9083..00000000 --- a/sites/judging/app/j/[orgSlug]/[eventSlug]/desk.tsx +++ /dev/null @@ -1,474 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import type { CSSProperties } from "react"; -import { apiBase } from "../../../../lib/api"; -import type { PublicEvent } from "../../../../lib/api"; -import { flushHeld, holdVote } from "../../../../lib/held"; - -type Visit = { - done: boolean; - reused?: boolean; - visitId?: string; - projectName?: string; - tableNumber?: number | null; - arrivedAt?: string | null; - handedOutAt?: string; -}; - -type Anchor = { label?: string; value?: number }; -type CriterionRow = { id: string; label: string; min: number; max: number; anchors?: Anchor[] }; - -export function JudgeDesk({ - orgSlug, - event, - ticket, -}: { - orgSlug: string; - event: PublicEvent; - ticket?: string; -}) { - const [email, setEmail] = useState(""); - const [code, setCode] = useState(""); - const [token, setToken] = useState(""); - const [visit, setVisit] = useState(null); - const [criteria, setCriteria] = useState([]); - const [scores, setScores] = useState>({}); - const [tableNumber, setTableNumber] = useState(""); - const [message, setMessage] = useState(""); - const [progress, setProgress] = useState(""); - const [judgeId, setJudgeId] = useState(""); - const [elapsed, setElapsed] = useState(""); - const [scanning, setScanning] = useState(false); - const videoRef = useRef(null); - const accent = event.branding.colors?.accent ?? "#1c1917"; - - useEffect(() => { - if (!ticket) return; - setToken(ticket); - void fetch(`${apiBase}/v1/session/rubric?eventId=${event.eventId}`, { - headers: { authorization: `Bearer ${ticket}` }, - }) - .then((response) => response.json()) - .then(async (rows: CriterionRow[]) => { - if (!Array.isArray(rows)) { - setMessage("The handoff ticket was not accepted."); - return; - } - setCriteria(rows); - const progressResponse = await fetch( - `${apiBase}/v1/session/progress?eventId=${event.eventId}`, - { headers: { authorization: `Bearer ${ticket}` } }, - ); - if (!progressResponse.ok) return; - const row = (await progressResponse.json()) as { - completed: number; - total: number; - judgeId?: string; - }; - setProgress(`${row.completed} of ${row.total} scored`); - if (row.judgeId) setJudgeId(row.judgeId); - }) - .catch(() => setMessage("The handoff ticket was not accepted.")); - }, [ticket, event.eventId]); - - async function call(path: string, body?: unknown) { - const response = await fetch(`${apiBase}${path}`, { - method: body ? "POST" : "GET", - headers: { - "content-type": "application/json", - ...(token ? { authorization: `Bearer ${token}` } : {}), - }, - body: body ? JSON.stringify(body) : undefined, - }); - const payload = (await response.json()) as { message?: string }; - if (!response.ok) throw new Error(payload.message ?? "Request failed"); - return payload; - } - - async function compare(outcome: "a" | "b" | "tie") { - try { - await call("/v1/session/compare", { eventId: event.eventId, outcome }); - setMessage( - outcome === "b" - ? "This table is better." - : outcome === "a" - ? "The previous table was better." - : "Recorded as about the same.", - ); - } catch (error) { - setMessage(error instanceof Error ? error.message : "Could not compare"); - } - } - - useEffect(() => { - if (!token) return; - const send = () => { - void flushHeld((path, payload) => - fetch(`${apiBase}${path}`, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${token}`, - }, - body: JSON.stringify(payload), - }).then((response) => { - if (!response.ok) throw new Error("still offline"); - }), - ).then((sent) => { - if (sent > 0) { - setMessage(`Sent ${sent} score${sent === 1 ? "" : "s"} held on this phone.`); - } - }); - }; - window.addEventListener("online", send); - return () => window.removeEventListener("online", send); - }, [token]); - - useEffect(() => { - if (!visit?.handedOutAt || visit.arrivedAt || visit.done) { - setElapsed(""); - return; - } - const started = new Date(visit.handedOutAt).getTime(); - const tick = () => { - const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000)); - setElapsed(`${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} walking`); - }; - tick(); - const timer = setInterval(tick, 1000); - return () => clearInterval(timer); - }, [visit?.handedOutAt, visit?.arrivedAt, visit?.done]); - - useEffect(() => { - if (!token || !judgeId) return; - const socket = new WebSocket( - `${apiBase.replace(/^http/, "ws")}/ws?channel=judge:${judgeId}`, - ); - socket.onmessage = (incoming) => { - let payload: { kind?: string }; - try { - payload = JSON.parse(String(incoming.data)) as { kind?: string }; - } catch { - return; - } - if (payload.kind === "judge.recalled") { - setVisit(null); - setMessage("Come back to the desk."); - } - if (payload.kind === "visit.voided") { - setVisit(null); - setMessage("This visit was voided."); - } - if (payload.kind === "judge.overtime") { - setMessage("Past the target time at this table."); - } - }; - return () => socket.close(); - }, [token, judgeId]); - - async function loadProgress(bearer: string) { - const response = await fetch( - `${apiBase}/v1/session/progress?eventId=${event.eventId}`, - { headers: { authorization: `Bearer ${bearer}` } }, - ); - if (!response.ok) return; - const row = (await response.json()) as { completed: number; total: number; judgeId?: string }; - setProgress(`${row.completed} of ${row.total} scored`); - if (row.judgeId) setJudgeId(row.judgeId); - } - - return ( -
-

{event.orgName}

-

{event.name}

-

{event.branding.tagline}

-

Phase: {event.phase}

- - {!token ? ( -
{ - submitEvent.preventDefault(); - try { - if (!code) { - const issued = (await call("/v1/auth/magic-link", { email })) as { code?: string }; - setCode(issued.code ?? ""); - setMessage(issued.code ? "Code filled in for this demo." : "Check your email."); - return; - } - const verified = (await call("/v1/auth/verify", { email, code, org: orgSlug })) as { - token: string; - }; - setToken(verified.token); - const rows = (await fetch( - `${apiBase}/v1/session/rubric?eventId=${event.eventId}`, - { headers: { authorization: `Bearer ${verified.token}` } }, - ).then((response) => response.json())) as CriterionRow[]; - setCriteria(rows); - await loadProgress(verified.token); - const sent = await flushHeld((path, payload) => - fetch(`${apiBase}${path}`, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${verified.token}`, - }, - body: JSON.stringify(payload), - }).then((response) => { - if (!response.ok) throw new Error("still offline"); - }), - ); - setMessage(sent > 0 ? `Sent ${sent} score${sent === 1 ? "" : "s"} held on this phone.` : ""); - } catch (error) { - setMessage(error instanceof Error ? error.message : "Sign-in failed"); - } - }} - > - - - -
- ) : ( -
- - - {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/judging/app/j/[orgSlug]/[eventSlug]/page.tsx b/sites/judging/app/j/[orgSlug]/[eventSlug]/page.tsx deleted file mode 100644 index f1db4caa..00000000 --- a/sites/judging/app/j/[orgSlug]/[eventSlug]/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { JudgeDesk } from "./desk"; -import { loadEvent } from "../../../../lib/api"; - -export default async function JudgePage({ - params, - searchParams, -}: { - params: Promise<{ orgSlug: string; eventSlug: string }>; - searchParams: Promise<{ ticket?: string }>; -}) { - const { orgSlug, eventSlug } = await params; - const { ticket } = await searchParams; - const event = await loadEvent(orgSlug, eventSlug); - if (!event) return
This event is not available.
; - return ; -} diff --git a/sites/judging/app/layout.tsx b/sites/judging/app/layout.tsx deleted file mode 100644 index 0737340c..00000000 --- a/sites/judging/app/layout.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Metadata, Viewport } from "next"; - -export const metadata: Metadata = { - title: "Panel", - description: "Hackathon judging", - manifest: "/manifest.webmanifest", -}; - -export const viewport: Viewport = { - width: "device-width", - initialScale: 1, - themeColor: "#111111", -}; - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - - ); -} diff --git a/sites/judging/app/o/[orgSlug]/[eventSlug]/page.tsx b/sites/judging/app/o/[orgSlug]/[eventSlug]/page.tsx deleted file mode 100644 index df8e1ec7..00000000 --- a/sites/judging/app/o/[orgSlug]/[eventSlug]/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Console } from "./console"; -import { loadEvent } from "../../../../lib/api"; - -export default async function OrganizerPage({ - params, - searchParams, -}: { - params: Promise<{ orgSlug: string; eventSlug: string }>; - searchParams: Promise<{ ticket?: string }>; -}) { - const { orgSlug, eventSlug } = await params; - const { ticket } = await searchParams; - const event = await loadEvent(orgSlug, eventSlug); - if (!event) return
This event is not available.
; - return ; -} diff --git a/sites/judging/app/page.tsx b/sites/judging/app/page.tsx deleted file mode 100644 index 66db153a..00000000 --- a/sites/judging/app/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export default function HomePage() { - return ( -
-

Panel

-

Judging for one event. Open a judge desk, the organizer console, or the public board.

-
- ); -} diff --git a/sites/judging/e2e/visit.spec.ts b/sites/judging/e2e/visit.spec.ts deleted file mode 100644 index f15aaee8..00000000 --- a/sites/judging/e2e/visit.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.use({ viewport: { width: 390, height: 844 } }); - -test("a phone visit holds one offline score, then the board shows the published order", async ({ - page, - request, -}) => { - await page.goto("/j/demo/demo"); - await page.getByLabel("Email").fill("judge3@demo.panel"); - await page.getByRole("button", { name: "Email me a code" }).click(); - await page.getByRole("button", { name: "Sign in" }).click(); - await page.getByRole("button", { name: "Next table" }).click(); - const heading = page.getByRole("heading", { level: 2 }); - await expect(heading).toBeVisible(); - const table = (await heading.innerText()).match(/table (\d+)/)?.[1]; - expect(table).toBeTruthy(); - await page.getByLabel("Table number").fill(table ?? ""); - await page.getByRole("button", { name: "I'm here" }).click(); - await expect(page.getByText("Arrived.")).toBeVisible(); - for (const name of ["Creativity", "Impact", "Scope", "Clarity", "Soundness"]) { - await page.getByRole("spinbutton", { name }).fill("6"); - } - - await page.context().setOffline(true); - await page.getByRole("button", { name: "Submit score" }).click(); - await expect(page.getByText("Held on this phone")).toBeVisible(); - await page.context().setOffline(false); - await page.evaluate(() => window.dispatchEvent(new Event("online"))); - await expect(page.getByText("Sent 1 score")).toBeVisible(); - - const issued = await request.post("http://localhost:8787/v1/auth/magic-link", { - data: { email: "organizer@demo.panel" }, - }); - const code = ((await issued.json()) as { code?: string }).code; - expect(code).toBeTruthy(); - const verified = await request.post("http://localhost:8787/v1/auth/verify", { - data: { email: "organizer@demo.panel", code, org: "demo" }, - }); - const token = ((await verified.json()) as { token?: string }).token; - expect(token).toBeTruthy(); - - await page.setViewportSize({ width: 1280, height: 800 }); - await page.goto("/o/demo/demo"); - await page.getByLabel("Organizer token").fill(token ?? ""); - await page.getByRole("button", { name: "judging_closed" }).click(); - await expect(page.getByText("judging_closed")).toBeVisible(); - await page.getByRole("button", { name: "Compute results" }).click(); - await expect(page.getByText("Results computed")).toBeVisible(); - await page.getByRole("button", { name: "Publish", exact: true }).click(); - await expect(page.getByText("Published.")).toBeVisible(); - - await page.goto("/e/demo/demo"); - await expect(page.getByRole("listitem").first()).toBeVisible(); - - await page.goto("/o/demo/demo"); - await page.getByLabel("Organizer token").fill(token ?? ""); - await page.getByRole("button", { name: "judging_closed" }).click(); - await page.getByRole("button", { name: "judging_live" }).click(); - await expect(page.getByText("judging_live")).toBeVisible(); -}); diff --git a/sites/judging/eslint.config.mjs b/sites/judging/eslint.config.mjs deleted file mode 100644 index 69fbcb17..00000000 --- a/sites/judging/eslint.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import { config } from "@query/eslint-config/base"; - -/** @type {import("eslint").Linter.Config[]} */ -export default [ - ...config, - // Playwright specs sit outside tsconfig, so the typed parser cannot load them. - { ignores: ["e2e/**"] }, -]; diff --git a/sites/judging/lib/api.ts b/sites/judging/lib/api.ts deleted file mode 100644 index 4fbb377d..00000000 --- a/sites/judging/lib/api.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const apiBase = process.env.NEXT_PUBLIC_PANEL_URL ?? "http://localhost:8787"; - -export type PublicEvent = { - eventId: string; - name: string; - phase: string; - branding: { tagline?: string; colors?: { accent?: string } }; - orgName: string; -}; - -export async function loadEvent(orgSlug: string, eventSlug: string): Promise { - const response = await fetch(`${apiBase}/v1/public/${orgSlug}/${eventSlug}`); - if (!response.ok) return null; - return response.json() as Promise; -} diff --git a/sites/judging/next.config.ts b/sites/judging/next.config.ts deleted file mode 100644 index 68a6c64d..00000000 --- a/sites/judging/next.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - output: "standalone", -}; - -export default nextConfig; diff --git a/sites/judging/package.json b/sites/judging/package.json deleted file mode 100644 index 35282b2e..00000000 --- a/sites/judging/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@query/judging-web", - "version": "0.0.0", - "private": true, - "scripts": { - "dev": "next dev --port 3005", - "build": "next build", - "lint": "eslint . --max-warnings 0", - "typecheck": "tsc --noEmit", - "e2e": "playwright test" - }, - "dependencies": { - "next": "16.3.7", - "react": "19.3.0", - "react-dom": "19.3.0" - }, - "devDependencies": { - "@query/eslint-config": "workspace:*", - "@query/tsconfig": "workspace:*", - "@types/node": "22.20.4", - "@types/react": "^19.3.0", - "@types/react-dom": "^19.3.0", - "typescript": "7.0.2", - "@playwright/test": "^1.58.2" - } -} diff --git a/sites/judging/playwright.config.ts b/sites/judging/playwright.config.ts deleted file mode 100644 index 7d21aa52..00000000 --- a/sites/judging/playwright.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "@playwright/test"; - -export default defineConfig({ - testDir: "e2e", - timeout: 60_000, - use: { - baseURL: "http://localhost:3005", - }, -}); diff --git a/sites/judging/public/manifest.webmanifest b/sites/judging/public/manifest.webmanifest deleted file mode 100644 index cc5c32f8..00000000 --- a/sites/judging/public/manifest.webmanifest +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "Panel", - "short_name": "Panel", - "start_url": "/", - "display": "standalone", - "background_color": "#f6f5f2", - "theme_color": "#111111" -} diff --git a/sites/judging/tsconfig.json b/sites/judging/tsconfig.json deleted file mode 100644 index ea5d5185..00000000 --- a/sites/judging/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tooling/typescript/nextjs.json", - "compilerOptions": { - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "plugins": [{ "name": "next" }], - "jsx": "preserve", - "noEmit": true - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "e2e", "playwright.config.ts"] -} diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index ed914c64..fa36d7c7 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useEffect, useMemo, useRef } from "react"; +import Link from "next/link"; import { loginHref } from "@/lib/safe-callback"; import { useSession } from "next-auth/react"; import { trpc } from "@/lib/trpc"; @@ -257,9 +258,9 @@ export default function AdminResultsPage() { : "Open and close judging, watch the floor, and compare scores."}

{panelConsole?.url ? ( - + Open the panel console - + ) : null} diff --git a/sites/judging/app/o/[orgSlug]/[eventSlug]/console.tsx b/sites/mainweb/app/(portal)/admin/judging/panel/[orgSlug]/[eventSlug]/console.tsx similarity index 77% rename from sites/judging/app/o/[orgSlug]/[eventSlug]/console.tsx rename to sites/mainweb/app/(portal)/admin/judging/panel/[orgSlug]/[eventSlug]/console.tsx index cda7ce18..fa0813b0 100644 --- a/sites/judging/app/o/[orgSlug]/[eventSlug]/console.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/panel/[orgSlug]/[eventSlug]/console.tsx @@ -1,8 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import { apiBase } from "../../../../lib/api"; -import type { PublicEvent } from "../../../../lib/api"; +import { apiBase } from "@/lib/panel"; +import type { PublicEvent } from "@/lib/panel"; import { Editors } from "./editors"; const phases = [ @@ -15,15 +15,24 @@ const phases = [ "archived", ] as const; -export function Console({ event, ticket }: { event: PublicEvent; ticket?: string }) { - const [token, setToken] = useState(ticket ?? ""); +export function Console({ event }: { event: PublicEvent }) { const [message, setMessage] = useState(event.phase); const [floor, setFloor] = useState<{ tables: { tableNumber: number | null; looks: number }[]; - judges: { name: string; state: string; overtime: boolean; tableNumber: number | null }[]; + judges: { + name: string; + state: string; + overtime: boolean; + tableNumber: number | null; + }[]; } | null>(null); const [cards, setCards] = useState< - { id: string; name: string; tableNumber: number | null; zoneName: string | null }[] + { + id: string; + name: string; + tableNumber: number | null; + zoneName: string | null; + }[] >([]); const [logs, setLogs] = useState<{ id: string; kind: string }[]>([]); const [runId, setRunId] = useState(""); @@ -44,10 +53,7 @@ export function Console({ event, ticket }: { event: PublicEvent; ticket?: string async function post(path: string, body: unknown) { const response = await fetch(`${apiBase}/trpc/${path}`, { method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${token}`, - }, + headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); const payload = (await response.json()) as { @@ -61,17 +67,19 @@ export function Console({ event, ticket }: { event: PublicEvent; ticket?: string } useEffect(() => { - if (!token) return; let stop = false; const pull = async () => { - const response = await fetch(`${apiBase}/v1/floor/${event.eventId}`, { - headers: { authorization: `Bearer ${token}` }, - }); + const response = await fetch(`${apiBase}/v1/floor/${event.eventId}`); if (!response.ok || stop) return; setFloor( (await response.json()) as { tables: { tableNumber: number | null; looks: number }[]; - judges: { name: string; state: string; overtime: boolean; tableNumber: number | null }[]; + judges: { + name: string; + state: string; + overtime: boolean; + tableNumber: number | null; + }[]; }, ); }; @@ -81,7 +89,7 @@ export function Console({ event, ticket }: { event: PublicEvent; ticket?: string stop = true; clearInterval(timer); }; - }, [token, event.eventId]); + }, [event.eventId]); return (
@@ -90,7 +98,15 @@ export function Console({ event, ticket }: { event: PublicEvent; ticket?: string {floor ? (

Floor

-
    +
      {floor.tables.map((table) => (
    • (
    • {person.name}: {person.state} - {person.tableNumber !== null ? ` · table ${person.tableNumber}` : ""} + {person.tableNumber !== null + ? ` · table ${person.tableNumber}` + : ""} {person.overtime ? " · overtime" : ""}
    • ))}
) : null} - {phases.map((phase) => ( + + {progress ?

{progress}

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

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

+ {elapsed ?

{elapsed}

: 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/[orgSlug]/[eventSlug]/page.tsx b/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/page.tsx new file mode 100644 index 00000000..7d9dc292 --- /dev/null +++ b/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/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<{ orgSlug: string; eventSlug: string }>; +}) { + const { orgSlug, eventSlug } = await params; + const actor = await portalActor(); + if (!actor) { + redirect( + `/login?callbackUrl=${encodeURIComponent(`/judge/panel/${orgSlug}/${eventSlug}`)}`, + ); + } + const event = await loadEvent(orgSlug, eventSlug); + if (!event) + return ( +
This event is not available.
+ ); + return ; +} diff --git a/sites/judging/app/e/[orgSlug]/[eventSlug]/board.tsx b/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx similarity index 64% rename from sites/judging/app/e/[orgSlug]/[eventSlug]/board.tsx rename to sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx index 1e88cc3d..d6f7256f 100644 --- a/sites/judging/app/e/[orgSlug]/[eventSlug]/board.tsx +++ b/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx @@ -1,44 +1,46 @@ "use client"; import { useEffect, useState } from "react"; -import { apiBase } from "../../../../lib/api"; +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 }[]; + 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 }) { +export function LiveBoard({ + orgSlug, + eventSlug, +}: { + orgSlug: string; + eventSlug: string; +}) { const [snapshot, setSnapshot] = useState(null); - const [live, setLive] = useState(false); useEffect(() => { let stop = false; let timer: ReturnType | undefined; - let socket: WebSocket | undefined; const pull = async () => { - const response = await fetch(`${apiBase}/v1/live/${orgSlug}/${eventSlug}`); + const response = await fetch( + `${apiBase}/v1/live/${orgSlug}/${eventSlug}`, + ); if (!response.ok || stop) return; const next = (await response.json()) as Snapshot; setSnapshot(next); - if (!socket) { - socket = new WebSocket( - `${apiBase.replace(/^http/, "ws")}/ws?channel=event:${next.eventId}:board`, - ); - socket.onopen = () => setLive(true); - socket.onclose = () => { - setLive(false); - socket = undefined; - }; - socket.onmessage = () => { - void pull(); - }; - } clearTimeout(timer); timer = setTimeout(() => void pull(), (next.pollSeconds || 5) * 1000); }; @@ -47,16 +49,13 @@ export function LiveBoard({ orgSlug, eventSlug }: { orgSlug: string; eventSlug: return () => { stop = true; clearTimeout(timer); - socket?.close(); }; }, [orgSlug, eventSlug]); if (!snapshot) return

Loading the floor.

; return (
-

- {live ? "Live" : "Polling"} · {snapshot.phase} -

+

{snapshot.phase}

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

Prizes

diff --git a/sites/judging/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx b/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx similarity index 71% rename from sites/judging/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx rename to sites/mainweb/app/judging/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx index 5e0a3b23..59aae1bf 100644 --- a/sites/judging/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx +++ b/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx @@ -1,4 +1,4 @@ -import { apiBase } from "../../../../../../lib/api"; +import { panelRequest } from "@/lib/panel.server"; export default async function FeedbackPage({ params, @@ -6,16 +6,23 @@ export default async function FeedbackPage({ params: Promise<{ token: string }>; }) { const { token } = await params; - const response = await fetch(`${apiBase}/v1/feedback/${token}`); + 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. + Feedback is available after results are published, and only with this + project's link.
); } if (!response.ok) { - return
This feedback link is not available.
; + return ( +
+ This feedback link is not available. +
+ ); } const card = (await response.json()) as { name: string; @@ -29,14 +36,16 @@ export default async function FeedbackPage({
    {card.places.map((item) => (
  • - {item.track}: {item.placement === null ? "not placed" : item.placement} + {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)} + {item.label}: {item.mean.toFixed(1)} versus a median of{" "} + {item.median.toFixed(1)}
  • ))}
diff --git a/sites/judging/app/e/[orgSlug]/[eventSlug]/page.tsx b/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/page.tsx similarity index 62% rename from sites/judging/app/e/[orgSlug]/[eventSlug]/page.tsx rename to sites/mainweb/app/judging/[orgSlug]/[eventSlug]/page.tsx index 3761eb51..9aa9f248 100644 --- a/sites/judging/app/e/[orgSlug]/[eventSlug]/page.tsx +++ b/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/page.tsx @@ -1,5 +1,5 @@ import { LiveBoard } from "./board"; -import { loadEvent } from "../../../../lib/api"; +import { loadEvent } from "@/lib/panel.server"; export default async function PublicBoard({ params, @@ -8,12 +8,19 @@ export default async function PublicBoard({ }) { const { orgSlug, eventSlug } = await params; const event = await loadEvent(orgSlug, eventSlug); - if (!event) return
This event is not available.
; + if (!event) + return ( +
This event is not available.
+ ); return (

{event.orgName}

{event.name}

-

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

+

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

); diff --git a/sites/judging/lib/held.ts b/sites/mainweb/lib/panel-held.ts similarity index 100% rename from sites/judging/lib/held.ts rename to sites/mainweb/lib/panel-held.ts diff --git a/sites/mainweb/lib/panel.server.ts b/sites/mainweb/lib/panel.server.ts new file mode 100644 index 00000000..00fe571f --- /dev/null +++ b/sites/mainweb/lib/panel.server.ts @@ -0,0 +1,16 @@ +import { 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); +} + +export async function loadEvent( + orgSlug: string, + eventSlug: string, +): Promise { + const response = await panelRequest(`/v1/public/${orgSlug}/${eventSlug}`); + if (!response.ok) return null; + return response.json() as Promise; +} 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 0ac93324..2e5c92e6 100644 --- a/turbo.json +++ b/turbo.json @@ -54,8 +54,6 @@ }, "globalEnv": [ "PORT", - "PORT_A", - "PORT_B", "DATABASE_URL", "NEXTAUTH_SECRET", "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY", @@ -99,14 +97,9 @@ "JUDGING_TEST_DATABASE_URL", "PANEL_DATABASE_URL", "PANEL_JWT_SECRET", - "PANEL_JWKS_URL", - "PANEL_DEV_AUTH", "PANEL_SMTP_URL", "PANEL_SMTP_FROM", - "PANEL_URL", - "PANEL_SERVICE_TOKEN", "PANEL_EVENT_ID", - "PANEL_WEB_URL", "PANEL_ORG_SLUG", "PANEL_EVENT_SLUG", "REDIS_URL" From 95446da8806b6d8f5b08dcc1585065615ca7f650 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 8 Oct 2026 15:58:54 -0400 Subject: [PATCH 6/8] perf(panel): keep judging inside the Neon free plan Neon suspends after five idle minutes and the plan has 100 compute hours a month, so anything that polls keeps the database billing. The public board and the organizer floor now poll only during judging_live and only while the tab is visible; the board gets a refresh button otherwise. /api/panel/readyz (a Postgres round trip) and /metrics are no longer public. Judging now borrows the club's connection pool, which is tuned to let Neon scale to zero, instead of opening a second pool to the same database. On the 0.5 GB side, a drained outbox row is deleted rather than kept, since event_log already holds the history. The judge desk no longer holds a score the server refused (past the hard limit, a voided visit). It used to keep it on the phone and resend the same refusal every time the page opened; only outages are held now. --- docs/judging/README.md | 16 +++++--- packages/api/src/services/panel.ts | 20 ++++------ packages/judging-db/src/client.ts | 10 +++-- packages/judging-db/src/index.ts | 2 +- .../judging-server/src/services/outbox.ts | 13 +++--- .../panel/[orgSlug]/[eventSlug]/console.tsx | 19 +++++++-- .../app/(portal)/api/panel/[...path]/route.ts | 7 +++- .../panel/[orgSlug]/[eventSlug]/desk.tsx | 39 +++++++++++++----- .../judging/[orgSlug]/[eventSlug]/board.tsx | 40 +++++++++++++++++-- 9 files changed, 120 insertions(+), 46 deletions(-) diff --git a/docs/judging/README.md b/docs/judging/README.md index 5043f260..66ce228e 100644 --- a/docs/judging/README.md +++ b/docs/judging/README.md @@ -34,9 +34,13 @@ are pulled back into `hackathon_result`. Set `PANEL_EVENT_ID`, `PANEL_ORG_SLUG`, and `PANEL_EVENT_SLUG` to the judging event an edition uses. Without them the portal shows no panel links. -Live views poll. There is no WebSocket in the portal process; the board uses -the event's `board_poll_seconds` and the console polls the floor every five -seconds. +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. +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 @@ -56,5 +60,7 @@ pnpm --filter @query/judging-cli panel seed demo pnpm --filter @query/judging-cli panel doctor ``` -Webhooks queue in `outbox`. Drain them with `panel outbox drain`, on a -schedule if an event uses webhooks. +Webhooks queue in `outbox`. Drain them with `panel outbox drain`; delivered +rows are deleted, since `event_log` keeps the history. Run it after each event +even without webhooks, so the queue does not sit in the 0.5 GB database. A +Hacklytics-size event is on the order of 20 MB across the judging tables. diff --git a/packages/api/src/services/panel.ts b/packages/api/src/services/panel.ts index cfc23202..3735f6ff 100644 --- a/packages/api/src/services/panel.ts +++ b/packages/api/src/services/panel.ts @@ -1,7 +1,7 @@ import { and, eq } from "drizzle-orm"; -import { admins, hackathons, judges } from "@query/db"; +import { admins, db as clubDb, hackathons, judges } from "@query/db"; import type { DrizzleDB } from "@query/db"; -import { createDb } from "@query/judging-db"; +import { createDbFromPool } from "@query/judging-db"; import type { PanelDb } from "@query/judging-db"; import { InMemoryBus, @@ -28,19 +28,15 @@ const globalForPanel = globalThis as unknown as { panel?: Embedded }; /** * Judging runs inside the portal: same process, same Postgres, and the portal - * sign-in decides who the caller is. One instance per process, so the metrics - * registry and the pool are not rebuilt on every request. + * 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; - const url = process.env.DATABASE_URL; - if (!url) throw new Error("DATABASE_URL is not set"); - const { db } = createDb(url, { - ssl: - process.env.NODE_ENV === "production" - ? { rejectUnauthorized: true } - : undefined, - }); + 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({ diff --git a/packages/judging-db/src/client.ts b/packages/judging-db/src/client.ts index a658b8c9..abb74e26 100644 --- a/packages/judging-db/src/client.ts +++ b/packages/judging-db/src/client.ts @@ -1,10 +1,14 @@ import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; -import type { PoolConfig } from "pg"; import * as schema from "./schema"; -export function createDb(url: string, options: PoolConfig = {}) { - const pool = new Pool({ connectionString: url, max: 10, ...options }); +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. diff --git a/packages/judging-db/src/index.ts b/packages/judging-db/src/index.ts index 4514dd03..93619fb3 100644 --- a/packages/judging-db/src/index.ts +++ b/packages/judging-db/src/index.ts @@ -1,4 +1,4 @@ -export { createDb } from "./client"; +export { createDb, createDbFromPool } from "./client"; export type { PanelDb, PanelTx } from "./client"; export { databaseUrl, requireDatabaseUrl } from "./env"; export { appliedMigrations, migrate, migrationFiles } from "./migrate"; diff --git a/packages/judging-server/src/services/outbox.ts b/packages/judging-server/src/services/outbox.ts index 4f6ca441..1c43acc6 100644 --- a/packages/judging-server/src/services/outbox.ts +++ b/packages/judging-server/src/services/outbox.ts @@ -3,8 +3,12 @@ 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. */ -export async function drainOutbox(db: PanelDb, now = new Date()): Promise { +/** + * 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) @@ -42,10 +46,7 @@ export async function drainOutbox(db: PanelDb, now = new Date()): Promise { + // 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; @@ -83,13 +88,20 @@ export function Console({ event }: { event: PublicEvent }) { }, ); }; - void pull(); - const timer = setInterval(() => void pull(), 5000); + 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); }; - }, [event.eventId]); + }, [phase, event.eventId]); return (
@@ -140,6 +152,7 @@ export function Console({ event }: { event: PublicEvent }) { onClick={async () => { try { await post("event.setPhase", { eventId: event.eventId, phase }); + setPhase(phase); setMessage(phase); } catch (error) { setMessage( diff --git a/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts b/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts index 1c26a148..982485c6 100644 --- a/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts +++ b/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts @@ -10,8 +10,11 @@ const PREFIX = "/api/panel"; async function handler(req: Request) { const url = new URL(req.url); url.pathname = url.pathname.slice(PREFIX.length) || "/"; - // Process metrics are not public; the portal's own /api/metrics is the scrape target. - if (url.pathname === "/metrics") return new Response(null, { status: 404 }); + // 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 diff --git a/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx b/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx index 9cc4d1ce..838bf5e2 100644 --- a/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx +++ b/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx @@ -74,8 +74,14 @@ export function JudgeDesk({ headers: { "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined, }); - const payload = (await response.json()) as { message?: string }; - if (!response.ok) throw new Error(payload.message ?? "Request failed"); + 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; } @@ -102,15 +108,19 @@ export function JudgeDesk({ headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }).then((response) => { - if (!response.ok) throw new Error("still offline"); + // 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.`, - ); - } - }); + ) + .then((sent) => { + if (sent > 0) { + setMessage( + `Sent ${sent} score${sent === 1 ? "" : "s"} held on this phone.`, + ); + } + }) + .catch(() => undefined); }; send(); window.addEventListener("online", send); @@ -372,6 +382,15 @@ export function JudgeDesk({ setMessage("Score stored."); setVisit(null); } catch (error) { + const status = (error as { status?: number }).status; + if (status !== undefined && status < 500) { + // The server refused this score. Holding it would resend + // the same refusal every time the page opens. + setMessage( + error instanceof Error ? error.message : "Score refused", + ); + return; + } await holdVote(body); setMessage( error instanceof Error diff --git a/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx b/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx index d6f7256f..c94909b5 100644 --- a/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx +++ b/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx @@ -30,32 +30,64 @@ export function LiveBoard({ }) { 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); - clearTimeout(timer); - timer = setTimeout(() => void pull(), (next.pollSeconds || 5) * 1000); + // 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]); + }, [orgSlug, eventSlug, reload]); if (!snapshot) return

Loading the floor.

; return (
-

{snapshot.phase}

+

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

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

Prizes

From 24a43fdb37bfe9214f3282341145550f76eee22b Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 8 Oct 2026 16:08:03 -0400 Subject: [PATCH 7/8] feat(panel): tell a judge about a recall or a voided visit The desk lost these when the WebSocket hub went away. It now asks /v1/session/status every 15 seconds, but only while a visit is open and the screen is on. Judging is live whenever a visit is open, so Neon is awake anyway, and a phone in a pocket sends nothing. A recall does not void the visit, and dispatch can hand the same visit back, so the status carries the time of the latest recall and the desk acts once per recall. The lookup reads only log rows written since the hand-out, on the existing (event_id, created_at) index. --- docs/judging/README.md | 4 +- packages/judging-server/src/app.ts | 6 +++ packages/judging-server/src/router.ts | 16 ++++++- .../judging-server/src/services/dispatch.ts | 44 ++++++++++++++++- .../services/visit-status.integration.test.ts | 41 ++++++++++++++++ .../panel/[orgSlug]/[eventSlug]/desk.tsx | 47 +++++++++++++++++++ 6 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 packages/judging-server/src/services/visit-status.integration.test.ts diff --git a/docs/judging/README.md b/docs/judging/README.md index 66ce228e..ec5bf6e2 100644 --- a/docs/judging/README.md +++ b/docs/judging/README.md @@ -36,7 +36,9 @@ event an edition uses. Without them the portal shows no panel links. 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. +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 diff --git a/packages/judging-server/src/app.ts b/packages/judging-server/src/app.ts index e6718151..b72251ca 100644 --- a/packages/judging-server/src/app.ts +++ b/packages/judging-server/src/app.ts @@ -41,6 +41,7 @@ const openApi = { "/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." } }, @@ -291,6 +292,11 @@ export function createApp(options: { .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); diff --git a/packages/judging-server/src/router.ts b/packages/judging-server/src/router.ts index 6e3d7e9e..f450fe9b 100644 --- a/packages/judging-server/src/router.ts +++ b/packages/judging-server/src/router.ts @@ -10,7 +10,7 @@ import type { Bus } from "./bus"; import type { Metrics } from "./metrics"; import { log } from "./log"; import { arrive } from "./services/arrive"; -import { dispatchNext, judgeProgress, skipVisit } from "./services/dispatch"; +import { dispatchNext, judgeProgress, skipVisit, visitStatus } from "./services/dispatch"; import { assignJudge, applyAsJudge, @@ -281,6 +281,20 @@ export const appRouter = t.router({ 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") diff --git a/packages/judging-server/src/services/dispatch.ts b/packages/judging-server/src/services/dispatch.ts index 0b6655c4..97d21031 100644 --- a/packages/judging-server/src/services/dispatch.ts +++ b/packages/judging-server/src/services/dispatch.ts @@ -1,5 +1,5 @@ import { randomInt } from "node:crypto"; -import { and, eq, isNull, sql } from "drizzle-orm"; +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"; @@ -391,6 +391,48 @@ export async function skipVisit( }); } +/** + * 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 }, 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/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx b/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx index 838bf5e2..b748cefc 100644 --- a/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx +++ b/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx @@ -127,6 +127,53 @@ export function JudgeDesk({ 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() From bba035402e07d9dd631e215c8459e7464f08a43b Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 8 Oct 2026 16:42:56 -0400 Subject: [PATCH 8/8] feat(panel): judge each hackathon edition from the portal Judging is per hackathon. Staff switch an edition to panel on /admin/judging; that creates its judging event from the hackathon row (organization hacklytics, slug = hackathon id) with default timers and the club's five criteria, then copies in submitted projects and the portal's judges. The PANEL_EVENT_ID / PANEL_ORG_SLUG / PANEL_EVENT_SLUG settings are gone, and the portal routes are keyed by hackathon id. Synced judges were stored as invited, which the desk refuses, so no portal judge could ever score. Approval in the portal now approves them in judging and deactivating suspends them. Deploys run drizzle-kit push against the shared database. With the judging tables there, push stopped at a drop prompt with no TTY, exited 0, and skipped every club schema change; db:check would then fail this branch on the missing judging_backend column. The drizzle config now hides the judging tables (read from the judging migrations) and declares the judging enums. Publishing results drains the outbox, so it no longer needs a manual step, and panel doctor treats PANEL_JWT_SECRET as optional. --- docs/judging/README.md | 34 +++---- docs/judging/deploy.md | 10 ++- packages/api/src/routers/judge/admin.ts | 11 ++- packages/api/src/routers/judge/portal.ts | 15 +++- packages/api/src/services/panel-sync.ts | 88 ++++++++++++++----- packages/api/src/services/panel.ts | 16 +++- packages/db/drizzle.config.ts | 24 ++++- packages/judging-cli/src/main.ts | 6 +- packages/judging-db/src/enums.ts | 39 ++++++++ packages/judging-db/src/schema.ts | 51 ++++------- packages/judging-server/src/index.ts | 2 + packages/judging-server/src/router.ts | 8 ++ .../src/services/event.integration.test.ts | 38 ++++++++ packages/judging-server/src/services/event.ts | 78 ++++++++++++++++ .../app/(portal)/admin/judging/page.tsx | 36 +++++++- .../[eventSlug] => [hackathonId]}/console.tsx | 0 .../[eventSlug] => [hackathonId]}/editors.tsx | 0 .../[eventSlug] => [hackathonId]}/page.tsx | 6 +- .../[eventSlug] => [hackathonId]}/desk.tsx | 0 .../[eventSlug] => [hackathonId]}/page.tsx | 8 +- .../[eventSlug] => [hackathonId]}/board.tsx | 0 .../feedback/[token]/page.tsx | 0 .../[eventSlug] => [hackathonId]}/page.tsx | 10 +-- sites/mainweb/lib/panel.server.ts | 12 ++- turbo.json | 3 - 25 files changed, 386 insertions(+), 109 deletions(-) create mode 100644 packages/judging-db/src/enums.ts create mode 100644 packages/judging-server/src/services/event.integration.test.ts create mode 100644 packages/judging-server/src/services/event.ts rename sites/mainweb/app/(portal)/admin/judging/panel/{[orgSlug]/[eventSlug] => [hackathonId]}/console.tsx (100%) rename sites/mainweb/app/(portal)/admin/judging/panel/{[orgSlug]/[eventSlug] => [hackathonId]}/editors.tsx (100%) rename sites/mainweb/app/(portal)/admin/judging/panel/{[orgSlug]/[eventSlug] => [hackathonId]}/page.tsx (65%) rename sites/mainweb/app/(portal)/judge/panel/{[orgSlug]/[eventSlug] => [hackathonId]}/desk.tsx (100%) rename sites/mainweb/app/(portal)/judge/panel/{[orgSlug]/[eventSlug] => [hackathonId]}/page.tsx (66%) rename sites/mainweb/app/judging/{[orgSlug]/[eventSlug] => [hackathonId]}/board.tsx (100%) rename sites/mainweb/app/judging/{[orgSlug]/[eventSlug] => [hackathonId]}/feedback/[token]/page.tsx (100%) rename sites/mainweb/app/judging/{[orgSlug]/[eventSlug] => [hackathonId]}/page.tsx (65%) diff --git a/docs/judging/README.md b/docs/judging/README.md index ec5bf6e2..823e34c5 100644 --- a/docs/judging/README.md +++ b/docs/judging/README.md @@ -16,23 +16,27 @@ club Postgres, and the portal sign-in decides who is judging or organizing. ## 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/[org]/[event]` | approved judges | Judge desk: next table, QR or table number, scores, pairwise, offline hold. | -| `/admin/judging/panel/[org]/[event]` | staff | Organizer console: phases, floor, tables, rubric, tracks, judges, results. | -| `/judging/[org]/[event]` | anyone | Public board while judging, placements after publish. | -| `/judging/[org]/[event]/feedback/[token]` | a team | That team's feedback card after publish. | +| `/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 for an edition whose -`judging_backend` is `panel`. Projects and judges are copied in process when -an organizer promotes submissions or approves a judge, and published results -are pulled back into `hackathon_result`. - -Set `PANEL_EVENT_ID`, `PANEL_ORG_SLUG`, and `PANEL_EVENT_SLUG` to the judging -event an edition uses. Without them the portal shows no panel links. +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 @@ -62,7 +66,7 @@ pnpm --filter @query/judging-cli panel seed demo pnpm --filter @query/judging-cli panel doctor ``` -Webhooks queue in `outbox`. Drain them with `panel outbox drain`; delivered -rows are deleted, since `event_log` keeps the history. Run it after each event -even without webhooks, so the queue does not sit in the 0.5 GB database. A -Hacklytics-size event is on the order of 20 MB across the judging tables. +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/deploy.md b/docs/judging/deploy.md index bbd89485..f13a850c 100644 --- a/docs/judging/deploy.md +++ b/docs/judging/deploy.md @@ -11,9 +11,13 @@ pnpm --filter @query/judging-cli panel migrate pnpm --filter @query/judging-cli panel doctor ``` -Then set `PANEL_EVENT_ID`, `PANEL_ORG_SLUG`, and `PANEL_EVENT_SLUG` in the -mainweb environment (`apphosting.yaml`) and switch the edition's -`judging_backend` to `panel`. +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/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index c03e4ffc..c0b65d45 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -470,12 +470,11 @@ export const judgeAdminRouter = createTRPCRouter({ } } - if (input.isActive) { - await syncJudgeToPanel(ctx.db as DrizzleDB, { - hackathonId: updated.hackathonId, - email: updated.email, - }); - } + 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 2b2b8aa2..fabca918 100644 --- a/packages/api/src/routers/judge/portal.ts +++ b/packages/api/src/routers/judge/portal.ts @@ -21,7 +21,7 @@ import { lockDispatch, } from "./dispatch"; import type { DrizzleDB } from "@query/db"; -import { panelConsoleUrl, panelDeskUrl } from "../../services/panel-sync"; +import { panelConsoleUrl, panelDeskUrl, setJudgingBackend } from "../../services/panel-sync"; export const judgePortalRouter = createTRPCRouter({ @@ -81,6 +81,19 @@ export const judgePortalRouter = createTRPCRouter({ 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/services/panel-sync.ts b/packages/api/src/services/panel-sync.ts index d88fdc1e..99ed01ff 100644 --- a/packages/api/src/services/panel-sync.ts +++ b/packages/api/src/services/panel-sync.ts @@ -1,8 +1,9 @@ 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 } from "./panel"; +import { panel, panelAsPortal, panelEventFor } from "./panel"; /** * Copies an edition into judging when that edition has opted in. @@ -40,34 +41,78 @@ export async function syncProjectsToPanel(db: DrizzleDB, hackathonId: string) { } } +/** + * 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 }, + judge: { hackathonId: string; email: string | null; isActive: boolean }, ) { try { if (!judge.email) return; const target = await panelTarget(db, judge.hackathonId); if (!target) return; - await panelAsPortal().judge.upsert({ - eventId: target.eventId, - email: judge.email, - name: judge.email, - externalId: null, - }); + 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 eventId = process.env.PANEL_EVENT_ID; - if (!eventId) return null; const [row] = await db - .select({ backend: hackathons.judgingBackend }) + .select({ id: hackathons.id, name: hackathons.name, backend: hackathons.judgingBackend }) .from(hackathons) .where(eq(hackathons.id, hackathonId)); if (row?.backend !== "panel") return null; - return { eventId }; + return panelEventFor(row); } export async function pullPanelResults(db: DrizzleDB, hackathonId: string) { @@ -120,11 +165,8 @@ export async function pullPanelResults(db: DrizzleDB, hackathonId: string) { } export async function panelDeskUrl(db: DrizzleDB, userId: string) { - const org = process.env.PANEL_ORG_SLUG; - const eventSlug = process.env.PANEL_EVENT_SLUG; - if (!org || !eventSlug) return { url: null as string | null }; const [row] = await db - .select({ email: judges.email }) + .select({ hackathonId: judges.hackathonId }) .from(judges) .innerJoin(hackathons, eq(hackathons.id, judges.hackathonId)) .where( @@ -134,18 +176,18 @@ export async function panelDeskUrl(db: DrizzleDB, userId: string) { eq(hackathons.judgingBackend, "panel"), ), ); - if (!row?.email) return { url: null }; - return { url: `/judge/panel/${org}/${eventSlug}` }; + if (!row) return { url: null as string | null }; + return { url: `/judge/panel/${row.hackathonId}` }; } export async function panelConsoleUrl(db: DrizzleDB, hackathonId: string) { - const org = process.env.PANEL_ORG_SLUG; - const eventSlug = process.env.PANEL_EVENT_SLUG; - if (!org || !eventSlug) return { url: null as string | null }; const [row] = await db .select({ backend: hackathons.judgingBackend }) .from(hackathons) .where(eq(hackathons.id, hackathonId)); - if (row?.backend !== "panel") return { url: null }; - return { url: `/admin/judging/panel/${org}/${eventSlug}` }; + 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 index 3735f6ff..f22e18c1 100644 --- a/packages/api/src/services/panel.ts +++ b/packages/api/src/services/panel.ts @@ -8,6 +8,7 @@ import { appRouter, createApp, createMetrics, + ensureEvent, } from "@query/judging-server"; import type { Actor, Role } from "@query/judging-server"; import { createContext } from "../context"; @@ -17,6 +18,19 @@ import { 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; @@ -101,7 +115,7 @@ export async function portalActor(): Promise { sub: ctx.userId, email: judgeRows[0]?.email ?? email, name: ctx.session?.user?.name ?? email, - org: process.env.PANEL_ORG_SLUG ?? null, + org: PANEL_ORG.slug, role: roleFor(admin ?? null), judgeExternalId: null, }; 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/judging-cli/src/main.ts b/packages/judging-cli/src/main.ts index a7d00297..69ac7081 100644 --- a/packages/judging-cli/src/main.ts +++ b/packages/judging-cli/src/main.ts @@ -107,11 +107,13 @@ async function main() { } } + // 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: Boolean(secret), - detail: secret ? "PANEL_JWT_SECRET is set" : "PANEL_JWT_SECRET is not set", + ok: true, + detail: secret ? "PANEL_JWT_SECRET is set" : "not set (portal sign-in only)", }); checks.push({ name: "redis", 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/schema.ts b/packages/judging-db/src/schema.ts index e206ba77..365d76a0 100644 --- a/packages/judging-db/src/schema.ts +++ b/packages/judging-db/src/schema.ts @@ -3,7 +3,6 @@ import { integer, jsonb, numeric, - pgEnum, pgTable, primaryKey, text, @@ -12,39 +11,23 @@ import { uuid, } from "drizzle-orm/pg-core"; import { sql } from "drizzle-orm"; - -export const eventPhase = pgEnum("event_phase", [ - "setup", - "submissions_open", - "submissions_closed", - "judging_live", - "judging_closed", - "published", - "archived", -]); - -export const trackKind = pgEnum("track_kind", ["main", "sponsor", "special"]); - -export const dispatchStrategy = pgEnum("dispatch_strategy", [ - "coverage", - "uncertainty", -]); - -export const judgeStatus = pgEnum("judge_status", [ - "invited", - "applied", - "approved", - "suspended", -]); - -export const comparisonOutcome = pgEnum("comparison_outcome", ["a", "b", "tie"]); - -export const membershipRole = pgEnum("membership_role", [ - "owner", - "admin", - "organizer", - "volunteer", -]); +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(), diff --git a/packages/judging-server/src/index.ts b/packages/judging-server/src/index.ts index 3f3beebb..669d6c96 100644 --- a/packages/judging-server/src/index.ts +++ b/packages/judging-server/src/index.ts @@ -1,7 +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/router.ts b/packages/judging-server/src/router.ts index f450fe9b..90e44911 100644 --- a/packages/judging-server/src/router.ts +++ b/packages/judging-server/src/router.ts @@ -49,6 +49,7 @@ import { saveConfig, } from "./services/catalog"; import { castVote } from "./services/vote"; +import { drainOutbox } from "./services/outbox"; export type Context = { db: PanelDb; @@ -482,6 +483,13 @@ export const appRouter = t.router({ 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); 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/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index fa36d7c7..14e9d414 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -88,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; @@ -257,10 +260,35 @@ export default function AdminResultsPage() { ? "Pick a hackathon to see its results." : "Open and close judging, watch the floor, and compare scores."}

- {panelConsole?.url ? ( - - Open the panel console - + {selectedHackathon && panelConsole ? ( +
+

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

+ {panelConsole.url ? ( + + Open the panel console + + ) : null} + +
) : null} diff --git a/sites/mainweb/app/(portal)/admin/judging/panel/[orgSlug]/[eventSlug]/console.tsx b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/console.tsx similarity index 100% rename from sites/mainweb/app/(portal)/admin/judging/panel/[orgSlug]/[eventSlug]/console.tsx rename to sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/console.tsx diff --git a/sites/mainweb/app/(portal)/admin/judging/panel/[orgSlug]/[eventSlug]/editors.tsx b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/editors.tsx similarity index 100% rename from sites/mainweb/app/(portal)/admin/judging/panel/[orgSlug]/[eventSlug]/editors.tsx rename to sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/editors.tsx diff --git a/sites/mainweb/app/(portal)/admin/judging/panel/[orgSlug]/[eventSlug]/page.tsx b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/page.tsx similarity index 65% rename from sites/mainweb/app/(portal)/admin/judging/panel/[orgSlug]/[eventSlug]/page.tsx rename to sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/page.tsx index 73fbafb1..92ca5b08 100644 --- a/sites/mainweb/app/(portal)/admin/judging/panel/[orgSlug]/[eventSlug]/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/page.tsx @@ -4,10 +4,10 @@ import { loadEvent } from "@/lib/panel.server"; export default async function OrganizerPage({ params, }: { - params: Promise<{ orgSlug: string; eventSlug: string }>; + params: Promise<{ hackathonId: string }>; }) { - const { orgSlug, eventSlug } = await params; - const event = await loadEvent(orgSlug, eventSlug); + const { hackathonId } = await params; + const event = await loadEvent(hackathonId); if (!event) return (
This event is not available.
diff --git a/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx b/sites/mainweb/app/(portal)/judge/panel/[hackathonId]/desk.tsx similarity index 100% rename from sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx rename to sites/mainweb/app/(portal)/judge/panel/[hackathonId]/desk.tsx diff --git a/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/page.tsx b/sites/mainweb/app/(portal)/judge/panel/[hackathonId]/page.tsx similarity index 66% rename from sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/page.tsx rename to sites/mainweb/app/(portal)/judge/panel/[hackathonId]/page.tsx index 7d9dc292..7ccd0db1 100644 --- a/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/page.tsx +++ b/sites/mainweb/app/(portal)/judge/panel/[hackathonId]/page.tsx @@ -6,16 +6,16 @@ import { loadEvent } from "@/lib/panel.server"; export default async function JudgePage({ params, }: { - params: Promise<{ orgSlug: string; eventSlug: string }>; + params: Promise<{ hackathonId: string }>; }) { - const { orgSlug, eventSlug } = await params; + const { hackathonId } = await params; const actor = await portalActor(); if (!actor) { redirect( - `/login?callbackUrl=${encodeURIComponent(`/judge/panel/${orgSlug}/${eventSlug}`)}`, + `/login?callbackUrl=${encodeURIComponent(`/judge/panel/${hackathonId}`)}`, ); } - const event = await loadEvent(orgSlug, eventSlug); + const event = await loadEvent(hackathonId); if (!event) return (
This event is not available.
diff --git a/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx b/sites/mainweb/app/judging/[hackathonId]/board.tsx similarity index 100% rename from sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx rename to sites/mainweb/app/judging/[hackathonId]/board.tsx diff --git a/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx b/sites/mainweb/app/judging/[hackathonId]/feedback/[token]/page.tsx similarity index 100% rename from sites/mainweb/app/judging/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx rename to sites/mainweb/app/judging/[hackathonId]/feedback/[token]/page.tsx diff --git a/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/page.tsx b/sites/mainweb/app/judging/[hackathonId]/page.tsx similarity index 65% rename from sites/mainweb/app/judging/[orgSlug]/[eventSlug]/page.tsx rename to sites/mainweb/app/judging/[hackathonId]/page.tsx index 9aa9f248..7703f5fb 100644 --- a/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/page.tsx +++ b/sites/mainweb/app/judging/[hackathonId]/page.tsx @@ -1,13 +1,13 @@ import { LiveBoard } from "./board"; -import { loadEvent } from "@/lib/panel.server"; +import { PANEL_ORG, loadEvent } from "@/lib/panel.server"; export default async function PublicBoard({ params, }: { - params: Promise<{ orgSlug: string; eventSlug: string }>; + params: Promise<{ hackathonId: string }>; }) { - const { orgSlug, eventSlug } = await params; - const event = await loadEvent(orgSlug, eventSlug); + const { hackathonId } = await params; + const event = await loadEvent(hackathonId); if (!event) return (
This event is not available.
@@ -21,7 +21,7 @@ export default async function PublicBoard({ ? "Results are published." : "Judging is in progress."}

- +
); } diff --git a/sites/mainweb/lib/panel.server.ts b/sites/mainweb/lib/panel.server.ts index 00fe571f..257d221a 100644 --- a/sites/mainweb/lib/panel.server.ts +++ b/sites/mainweb/lib/panel.server.ts @@ -1,4 +1,4 @@ -import { panel } from "@query/api/panel"; +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. */ @@ -6,11 +6,15 @@ 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( - orgSlug: string, - eventSlug: string, + hackathonId: string, ): Promise { - const response = await panelRequest(`/v1/public/${orgSlug}/${eventSlug}`); + 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/turbo.json b/turbo.json index 2e5c92e6..51ec52db 100644 --- a/turbo.json +++ b/turbo.json @@ -99,9 +99,6 @@ "PANEL_JWT_SECRET", "PANEL_SMTP_URL", "PANEL_SMTP_FROM", - "PANEL_EVENT_ID", - "PANEL_ORG_SLUG", - "PANEL_EVENT_SLUG", "REDIS_URL" ] }