diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0d58879..07bec3eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: run: pnpm turbo run build - name: Upload Hacklytics build - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: hacklytics-out path: sites/hacklytics2027/out @@ -69,7 +69,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: hacklytics-out path: sites/hacklytics2027/out @@ -94,7 +94,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: hacklytics-out path: sites/hacklytics2027/out diff --git a/.gitignore b/.gitignore index a278d653..f7fd22b2 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ build .DS_Store *.pem nul +__pycache__/ # Debug npm-debug.log* diff --git a/docs/architecture.md b/docs/architecture.md index 64914af3..919aa8c9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,7 +54,11 @@ Edition-scoped event operations. Everything hangs off `hackathon`. - Editions, interest list, registration, teams, project submission - Weekend schedule (`hackathon_event`) and badge scans -- Judging (`judge`, `judging_project`, `judge_vote`, `judge_queue`, `hackathon_result`) +- Judge roster (`judge`, `judge_assignment`): applications and approval +- Judging: room-based judging with Devpost import — `judging_*` tables (`judging.ts`). The older pool-dispatch tables (`judging_project`, `judge_vote`, `judge_queue`, `hackathon_result`) are retired and kept for past editions' results +- Points: `hackathon_event` points, staff adjustments and the point store (`points.ts`) +- Event check-in rules: nobody is checked into two events that run at the same time, or into two less than 30 minutes apart (meals are exempt). `recordEventAttendance` enforces this for pass scans, event codes and manual check-in +- Check-in rule-break flags: each refused attempt is recorded (`hackathon_check_in_flag`, `services/check-in-flags.ts`) and shown in judging on the hacker's team, in full to organisers and as a bare marker to judges. Organisers decide whether to pull the team from prizes (`judging_entry.prize_ineligible_at`). A flag outlives the events it names (deleting one clears its id and the organiser view shows "A deleted event"). Hackers never see flags, and they stay out of public and cached payloads - Announcements and acceptance waves Hackathon participation is **open to non-members**. Membership is not a registration requirement. @@ -68,7 +72,7 @@ Hackathon participation is **open to non-members**. Membership is not a registra 3. Browser calls `/api/trpc/*` via `@trpc/react-query`. Superjson is the transformer. 4. `createContext` loads the NextAuth session (when `db` exists), attaches `userId`, client IP, and the in-process cache. 5. Procedures run through DB-required, sanitizer, content-type, rate-limit / DDoS, and (for mutations) cache invalidation middleware. -6. Role gates (`isAdmin`, `isScanner`, `isJudge`, `isProjectLeader`, `isSuperAdmin`) live in `packages/api/src/middleware/procedures.ts`. There is no `adminProcedure` alias that skips a role check. +6. Role gates (`isAdmin`, `isScanner`, `isProjectLeader`, `isSuperAdmin`) live in `packages/api/src/middleware/procedures.ts`. There is no `adminProcedure` alias that skips a role check. ## Auth diff --git a/docs/contributing.md b/docs/contributing.md index 71d0e2a6..0c00a9ed 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -18,7 +18,7 @@ Code owners: `.github/CODEOWNERS` assigns `*` to `@aamoghS`. 1. Branch from `dev` unless you are fixing production. 2. Keep club and hackathon concerns separate. Do not add `hackathon_id` to club tables. 3. Put shared rules in the package that every caller can import. Membership grant/link logic belongs in `@query/db/services/membership`, not copied into auth, Stripe, and tRPC. -4. Role checks go through `isAdmin` / `isScanner` / `isJudge` / `isProjectLeader` / `isSuperAdmin`. Do not invent an `adminProcedure` that is only `protectedProcedure`. +4. Role checks go through `isAdmin` / `isScanner` / `isProjectLeader` / `isSuperAdmin`. Do not invent an `adminProcedure` that is only `protectedProcedure`. 5. Dangerous HTML in tRPC input is **rejected**, not stripped. See [Security](./operations/security.md). 6. Prices live in `packages/api/src/services/pricing.ts`. Do not hard-code dollar amounts in UI or Stripe calls. diff --git a/docs/glossary.md b/docs/glossary.md index 3795f227..c4968752 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -13,11 +13,11 @@ | **Staff** | Active admin whose role is not `volunteer`. | | **Project leader** | `project_leader` row. Runs **club projects**. Not a staff role. | | **Club project** | `initiative` row. Members apply to join with a pitch and an optional resume. Never judged. Distinct from a hackathon **project**, which is a judged submission. The UI says "club project"; the table is still `initiative`. | -| **Hackathon project** | Team/solo submission (`hackathon_project`). Promoted into `judging_project` for scoring. | +| **Hackathon project** | Team/solo submission (`hackathon_project`). Promoted into a `judging_entry` for judging, alongside projects imported from Devpost. | | **Interest** | “Tell me when registration opens” (`hackathon_interest`). Requires a signed-in user. | | **Current edition** | In-progress hackathon if one exists; otherwise the newest edition that is not `draft` or `announced`. | | **Announced** | Public landing + interest, registration closed, **not** current for membership resolution. | | **Wave** | Batch accept of oldest pending applicants; acceptance email is stamped per participant so retries are safe. | -| **Judging queue** | Per-judge ordered tables. `startedAt` is a short claim; `arrivedAt` is the QR scan at the table. | +| **Judging room** | `judging_room`. Judges sit in a room that serves one challenge; projects present to it on the schedule. The QR code on its door seats a judge, or starts a guest judge's session. | | **Results snapshot** | `hackathon_result` — frozen placing. Live z-score is only used when computing that snapshot. | | **Bootcamp term** | String like `2026-fall`. Access checks this, not the never-expiring `bootcamp_member` boolean. | diff --git a/docs/operations/environment.md b/docs/operations/environment.md index cc5310df..84a42174 100644 --- a/docs/operations/environment.md +++ b/docs/operations/environment.md @@ -58,6 +58,13 @@ Without `DATABASE_URL`, `db` is null, sessions fall back to JWT, and tRPC proced | `RESUME_BUCKET` | Cloud Storage bucket holding resume PDFs (App Hosting sets `dsgt-resumes`). Unset means uploads return 503 rather than failing obscurely. Credentials are ADC — the runtime service account needs `roles/storage.objectAdmin`. See [Resume book](../resume-book.md) | | `BOOTCAMP_BUCKET` | Cloud Storage bucket holding bootcamp workshop ZIPs (App Hosting sets `dsgt-bootcamp`). Unset means uploads and downloads return 503. Same ADC and same `roles/storage.objectAdmin` as above. Downloads are proxied and gated on the caller's bootcamp term, so the bucket keeps public access prevention on | +## Judging schedule + +| Variable | Default / notes | +| --- | --- | +| `JUDGING_CPSAT_PYTHON` | A Python with OR-Tools (`python3` or a path). Set, the schedule section can run `packages/api/scripts/judging-schedule/cpsat.py` on the server (`judging.schedule.solveCpsat`), and `judging.schedule.assignJudges` uses it to pick judges. Unset means CP-SAT is download, run on a laptop, upload, and the app assigns judges itself | +| `JUDGING_CPSAT_SCRIPT` | Path to `cpsat.py`. Default: found by walking up from the working directory | + ## Security / proxy | Variable | Default / notes | diff --git a/docs/operations/security.md b/docs/operations/security.md index 8fc656e4..aa4af276 100644 --- a/docs/operations/security.md +++ b/docs/operations/security.md @@ -20,7 +20,6 @@ Roles are rows, not JWT claims. | `isScanner` | Any active `admin` including `volunteer` | | `isAdmin` | Active admin whose role is **not** `volunteer` | | `isSuperAdmin` | `super_admin` | -| `isJudge` | Active `judge` for the resolved edition | | `isProjectLeader` | Active `project_leader` or staff | Volunteers can staff check-in desks. They cannot delete editions, grant memberships, or pass `isAdmin`. diff --git a/docs/operations/testing.md b/docs/operations/testing.md index d115b462..d6f0f425 100644 --- a/docs/operations/testing.md +++ b/docs/operations/testing.md @@ -28,10 +28,11 @@ Config: `packages/api/vitest.config.ts`. Includes `src/**/*.test.ts` and `src/.i | Hackathon flow | `hackathon-flow.test.ts`, `hackathon-admin-edge.test.ts`, `hackathon-interest.test.ts` | | Payments | `stripe-payments.test.ts` | | QR / check-in | `qr-checkin.test.ts` | +| Check-in rule-break flags | `services/check-in-flags.test.ts`, `.internal-tests/check-in-flags-*.test.ts` | | Security / sanitizer / rate limit | `security.test.ts`, `resilience.test.ts` | | Judges / participants / initiatives / bootcamp / announcements | matching `*-edge.test.ts` / `bootcamp.test.ts` / `announcements.test.ts` | | Portal context | `services/portal-context.test.ts`, `routers/user/portal-context.test.ts` | -| Judge helpers | `routers/judge/helpers.test.ts` | +| Judging scoring, scope, room links | `services/judging/*.test.ts` | Tests use `src/test/create-mock-context.ts` and `.internal-tests/_db-tx-mock.ts` rather than a live database. diff --git a/docs/packages/api.md b/docs/packages/api.md index ecdbd101..554a0b46 100644 --- a/docs/packages/api.md +++ b/docs/packages/api.md @@ -16,7 +16,9 @@ Exports: `.`, `./context`, `./trpc`, `./pricing`, plus a few middleware paths (s | `member` | `routers/member.ts` | Membership profile, pass, admin grant/revoke/search | | `hackathon` | `routers/hackathon/*` | Editions, registration, interest, events, content, announcements, staff ops | | `events` | `routers/events.ts` | Club events and QR check-in | -| `judge` | `routers/judge/*` | Judge portal, rankings/results, admin judging | +| `judge` | `routers/judge/*` | Judge roster: apply, approve, add, remove | +| `judging` | `routers/judging/*` | Room judging: `admin`, `schedule`, `desk`, `hacker`, `results` | +| `points` | `routers/points/*` | Hackathon points: `admin`, `store`, `me` | | `stripe` | `routers/stripe.ts` | Checkout, PaymentIntent, link, reconcile | | `audit` | `routers/audit.ts` | Audit log list (admin) | | `team` | `routers/team.ts` | Teams and project submission | @@ -37,7 +39,6 @@ Defined in `src/trpc.ts` and `src/middleware/procedures.ts`. | `isScanner` | Active `admin` row | Volunteers included. Badge / door scanning only | | `isAdmin` | Active staff `admin` | Rejects `volunteer` | | `isSuperAdmin` | `role === "super_admin"` | Staff create/remove, judge activate/remove, set project leader | -| `isJudge` | Active `judge` for the resolved hackathon | Resolves edition from input `hackathonId` / `projectId` / `queueId`, else current edition | | `isProjectLeader` | Active `project_leader` **or** staff | Staff can cover a quiet leader; leaders cannot use this to pass `isAdmin` | There is deliberately **no** `adminProcedure` that equals `protectedProcedure`. That name shipped open admin endpoints in the past. @@ -86,11 +87,17 @@ Batch status updates allow up to **2500** ids. The global array cap in `scrubMar ## Judge procedures -**Portal** (`portal.ts`): `isJudge`, assignments, applications, QR start, next table, vote, skip, overtime skip, progress. `toggleJudging` is admin. +**Portal** (`portal.ts`): `isJudge` (for the named or current edition), `myApplications`, `register` (apply to judge). -**Rankings** (`rankings.ts`): `getRankings`, `computeResults`, draft/publish/unpublish. Results are a **frozen snapshot** (`hackathon_result`), not a live z-score on every page load. +**Admin** (`admin.ts`): `list`, `create` (add by email), `setActive` (approve or suspend), `remove` (super admin). -**Admin** (`admin.ts`): CRUD judges, promote submissions into judging tables, initialize queues, assign tracks/projects, table cards, live progress, analytics. `setActive` and `remove` are super admin. `register` is protected (apply to judge). +Judging itself is the `judging` namespace; its shared helpers are in `services/judging/` (`room-link`, `scope`, `scoring`, `config`). `hackathon.getResults` returns published placings from both the retired `hackathon_result` table and `judging_placement`, in one shape. It leaves out entries pulled from judging (`deleted_at`) or from prizes (`prize_ineligible_at`, set by `judging.admin.setPrizeEligibility`). Check-in rule-break flags (`services/check-in-flags.ts`) reach organisers through `judging.admin.listEntries` and `judging.results.list`, and judges through `judging.desk` as a bare `flagged` boolean; no hacker-facing, public or cached procedure returns them. + +Placings rank on scores adjusted for judge bias (`services/judging/calibration.ts`): each evaluation is calibrated within its root challenge for its judge's strictness, spread and drift, on the assumption that `judging.desk.nextProject` (`services/judging/queue.ts`) hands every judge a random sample of projects. `judging_placement.raw_score` keeps the plain mean; only `judging.results.list` (organisers) returns it and the per-judge calibration, and `judging.desk` stays on raw means. An evaluation counts when it holds a rating for every rating item (`scoring.hasEveryRating`, and `scoring-sql.holdsEveryRating` where the database counts), whether or not a written response is missing, so a draft submitted at the hard end still places its project. Organiser counts, the board and hacker itinerary status, hacker feedback, and the desk's scores, judged count and shortlist all use that rule. `judging.results.list` keeps the calibration for 60 seconds per edition, keyed on the edition's evaluation count and latest save and on its ranked entries, while `judging.results.compute` always calibrates fresh. `judging.admin.reopenGrade` deletes one evaluation and the drafts for its project and challenge so another judge can grade it, and clears the edition's unpublished placings, so publishing needs a fresh compute; it is refused while results are published. + +At an untimed challenge that random sample is enforced (`services/judging/queue-store.ts`): `judging.desk.nextProject` offers only the first `QUEUE_WINDOW` (3) open projects of the judge's own queue and takes a `skip` list for a team away from its table, and a judge's first `startGrading`, `saveDraft` or `submit` on a project outside that window is refused. `judging.desk.startGrading` holds a project from the moment it is opened, with an empty draft. A table judge grades a sponsor's challenge only from a seat in a room that serves it (`scope.isSponsorChallenge`); `judging.desk.context` lists only the challenges they may pick. + +A scheduled presentation can have one assigned judge (`judging_appointment.assigned_judge_id`, set by `judging.schedule`; `services/judging/schedule/judges.ts` decides who may be assigned it). The desk never writes it and never tells a judge which other judge is assigned to, holds or graded a project. From the presentation's start until `TAKEOVER_AFTER_MS` (5 minutes) later only the assigned judge may open it (`services/judging/timing.ts`), and other judges see it as `reserved` with the time it opens. After that any judge who may grade it there can take it over, unless the assigned judge has started: their draft holds it (`grading-hold.ts`). Every claim checks this under the grading lock. `judging.desk.nextProject` offers the judge's own running assignments first; the queue window does not apply to scheduled challenges. `judging.desk.itinerary` lists the caller's assigned presentations that have not ended, soonest first, with the room and whether they are seated there. `judging.desk.releaseGrading` deletes the caller's own draft while it is still the empty one `startGrading` made, so a skipped or accidentally opened project is free again. `judging.schedule.get` gives organisers each started assignment's `judgeProgress` (grading, graded, taken over, not started). ## Club procedures (selected) diff --git a/docs/packages/db.md b/docs/packages/db.md index 12e5c566..31b19450 100644 --- a/docs/packages/db.md +++ b/docs/packages/db.md @@ -27,8 +27,11 @@ Files in `src/schemas/`, re-exported from `schemas/index.ts`. `drizzle.config.ts | `auth.ts` | `user`, `account`, `session`, `verificationToken` | | `members.ts` | `user_profile`, `member`, `membership_history` | | `admins.ts` | `admin` | -| `hackathons.ts` | `hackathon`, `hackathon_team`, `hackathon_participant`, `hackathon_project`, `hackathon_interest`, `hackathon_event`, `hackathon_event_attendee`, `hackathon_announcement`, `hackathon_announcement_recipient` | -| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue`, `hackathon_result` | +| `hackathons.ts` | `hackathon`, `hackathon_team`, `hackathon_participant`, `hackathon_project`, `hackathon_interest`, `hackathon_event`, `hackathon_event_attendee`, `hackathon_check_in_flag`, `hackathon_announcement`, `hackathon_announcement_recipient` | +| `judge.ts` | `judge`, `judge_assignment` (live roster); `judging_project`, `judge_vote`, `judge_queue`, `hackathon_result` (retired, kept for past editions) | +| `judging.ts` | `judging_*`: config, entries and members, challenges, buildings, rooms and room links, judges, guest sessions, presence, schedule and appointments, rubric, evaluations, deliberation, placements | +| `points.ts` | `hackathon_point_adjustment`, `hackathon_store_item`, `hackathon_store_purchase` | +| `retired.ts` | Enum types the retired panel judging service left in the database, declared so push does not try to drop them | | `initiatives.ts` | `project_leader`, `initiative`, `initiative_application` | | `events.ts` | `event`, `event_check_in` | | `bootcamp.ts` | `bootcamp_workshop` (metadata; the ZIPs are in Cloud Storage). `bootcamp_material` is legacy and unused | diff --git a/docs/sites/hacklytics2027.md b/docs/sites/hacklytics2027.md index ec29f8ec..05f612b0 100644 --- a/docs/sites/hacklytics2027.md +++ b/docs/sites/hacklytics2027.md @@ -32,9 +32,11 @@ Schedule copy lives in `components/sections/Schedule/data.ts`. ## Interest CTA -`lib/links.ts` is the only outbound destination. The Typeform this replaced was pasted in four files and drifted. +`lib/links.ts` is the only outbound destination. The Typeform URL was once pasted in four files and drifted. -Interest requires a portal account (verified email). The CTA is: +Every Notify me button and link (header, mobile menu, hero, FAQ, footer) points at `INTEREST_URL`, a Typeform, for now. On phones 356px and wider the header keeps a compact Notify me button beside the menu toggle, so the CTA does not hide inside the hamburger menu. + +Once registration opens on the portal, swap `INTEREST_URL` back to `PORTAL_INTEREST_URL`, which signs the visitor in first: ``` {PORTAL_ORIGIN}/login?callbackUrl=/hacklytics @@ -42,6 +44,8 @@ Interest requires a portal account (verified email). The CTA is: `callbackUrl` is encoded so it survives the email-code hop through `/verify`. The portal only honors same-origin paths. +The footer also links to the portal for the two audiences that are not waiting on the list: **Hacker portal** (`PORTAL_HACKATHON_URL`, `{PORTAL_ORIGIN}/hacklytics`) and **Judge Hacklytics** (`JUDGE_URL`, sign-in with `callbackUrl=/judge`). + ## Deploy Firebase Hosting target `hacklytics`, public dir `sites/hacklytics2027/out` (`firebase.json`). diff --git a/docs/sites/mainweb.md b/docs/sites/mainweb.md index 0d95daa8..c29d22a8 100644 --- a/docs/sites/mainweb.md +++ b/docs/sites/mainweb.md @@ -38,17 +38,17 @@ Unauthenticated and authenticated product UI. `proxy.ts` marks these prefixes `p | `/club/bootcamp` | Bootcamp (paid add-on, term-gated) | | `/initiatives` | Browse / apply | | `/lead`, `/lead/[id]` | Project leader | -| `/hackathons`, `/hackathons/[id]` | Edition pages (info, schedule, teams, projects, results) | -| `/hackathons/[id]/judge` | Judging for that edition | +| `/hackathons`, `/hackathons/[id]` | Edition pages (info, schedule, teams, projects, judging, points, results) | | `/hacklytics` | Current Hacklytics interest / portal landing | | `/submit` | Project submission | | `/judge`, `/judge/register` | Judge home / apply | +| `/judge/[hackathonId]` | Judge desk for one edition | | `/scan` | QR scanning | | `/admin` | Staff home | -| `/admin/hackathons`, `/admin/hackathons/[id]` | Edition admin (attendees, waves, announcements, analytics, events). `[id]` is the name slug (`hacklytics-digital-bloom`), not a percent-encoded name. | +| `/admin/hackathons`, `/admin/hackathons/[id]` | Edition admin (attendees, waves, announcements, analytics, events, judges, points, store). `[id]` is the name slug (`hacklytics-digital-bloom`), not a percent-encoded name. | | `/admin/members` | Membership admin | | `/admin/attendees` | Attendee tools | -| `/admin/judging` | Judging admin (sync submissions + assign judges live here) | +| `/admin/judging`, `/admin/judging/[hackathonId]` | Pick an edition, then run its judging: import, rooms, schedule, results | | `/admin/initiatives` | Initiative / proposal review | | `/admin/bootcamp` | Bootcamp attendance grid and the weekly workshop table | | `/admin/staff` | Admin users | diff --git a/monitoring/README.md b/monitoring/README.md index 69cb9edc..46503c91 100644 --- a/monitoring/README.md +++ b/monitoring/README.md @@ -42,6 +42,12 @@ number of instances is irrelevant: - `dsgt_members_active`, `dsgt_members_lapsed` - `dsgt_bootcamp_enrolled{term}` - `dsgt_payments_by_plan{plan}` — the yearly/semester split +- `dsgt_hackathon_participants{edition,phase,status}`, `dsgt_hackathon_projects`, + `dsgt_hackathon_checkins_today`, `dsgt_hackathon_judging_projects`, + `dsgt_hackathon_votes`, `dsgt_hackathon_judging_active` — only editions that + are `open`, `closed`, or `in_progress`. A completed edition disappears on + the next refresh. Badge scans are hackathon QR rows since midnight Eastern, + not club door check-ins. - `dsgt_payments_unlinked` — paid, claimed by nobody. This is **not** zero and is not supposed to be: as of August 2026 it sits around 428 of 443 paid rows, nearly all from the August–September 2025 and January 2026 drives. Those are @@ -85,6 +91,8 @@ The alerts that matter: - `MembershipGrantFailing` — a payment was recorded and the membership was not. - `UnlinkedPaymentsClimbing` — the link paths are not claiming payments. - `ReconcileRecoveringOften` — the backstop is carrying the fast path. +- `HackathonJudgingIdle` — judging is on, tables are assigned, and no scores + have landed for 30 minutes. To route them into ClickUp, run Alertmanager with a webhook receiver and pipe its payload to `node scripts/clickup-task.mjs --stdin`. That step is not built diff --git a/monitoring/clickhouse/init/02-hackathon.sql b/monitoring/clickhouse/init/02-hackathon.sql new file mode 100644 index 00000000..12d1c5fa --- /dev/null +++ b/monitoring/clickhouse/init/02-hackathon.sql @@ -0,0 +1,57 @@ +-- Hackathon history. Same reason as the payment tables: aggregate it here, +-- not on the 0.5 GB application database. `export:clickhouse` creates these +-- too, so a volume that already ran 01-schema.sql still picks them up. + +CREATE DATABASE IF NOT EXISTS dsgt; + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_registrations +( + id String, + hackathon String, + status LowCardinality(String), + registered_at DateTime64(3), + updated_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (hackathon, id); + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_projects +( + id String, + hackathon String, + status LowCardinality(String), + submitted_at Nullable(DateTime64(3)), + updated_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (hackathon, id); + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_scans +( + id String, + hackathon String, + event_name String, + checked_in_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(checked_in_at) +ORDER BY (hackathon, id); + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_votes +( + id String, + hackathon String, + score Int32, + voted_at DateTime64(3), + updated_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (hackathon, id); + +CREATE VIEW IF NOT EXISTS dsgt.hackathon_registrations_by_status AS +SELECT + hackathon, + status, + count() AS registrations +FROM dsgt.hackathon_registrations FINAL +GROUP BY hackathon, status +ORDER BY hackathon, status; diff --git a/monitoring/grafana/dashboards/dsgt-portal.json b/monitoring/grafana/dashboards/dsgt-portal.json index 1adf141f..c7a726b0 100644 --- a/monitoring/grafana/dashboards/dsgt-portal.json +++ b/monitoring/grafana/dashboards/dsgt-portal.json @@ -144,17 +144,112 @@ }, { "type": "row", - "title": "Payments (per-instance counters — a sample in production)", + "title": "Hackathon (read from the database — correct in production)", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 }, "collapsed": false, "panels": [] }, + { + "type": "stat", + "title": "Checked in", + "description": "Accepted participants whose badge was scanned, for each live edition.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 0, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_participants{env=~\"$env\",status=\"checked_in\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Approved, not checked in", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 4, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_participants{env=~\"$env\",status=\"approved\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Projects submitted", + "description": "Not drafts, and not withdrawn by an organiser.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 8, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_projects{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Badge scans today", + "description": "Hackathon QR scans since midnight Eastern. Club door check-ins are not in this number.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 12, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_checkins_today{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Judging tables", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 16, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_judging_projects{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Judge scores", + "description": "Zero while judging is switched on is what HackathonJudgingIdle fires on.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 20, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_votes{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "row", + "title": "Payments (per-instance counters — a sample in production)", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 12 }, + "collapsed": false, + "panels": [] + }, { "type": "timeseries", "title": "Grant failures", "description": "Payment recorded, membership not. Non-zero means money taken with nothing given.", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 0, "y": 7 }, + "gridPos": { "h": 7, "w": 8, "x": 0, "y": 13 }, "fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 60, "lineWidth": 1 }, @@ -174,7 +269,7 @@ "type": "timeseries", "title": "Grants by source", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 8, "y": 7 }, + "gridPos": { "h": 7, "w": 8, "x": 8, "y": 13 }, "fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 50, "stacking": { "mode": "normal" } } }, "overrides": [] @@ -191,7 +286,7 @@ "type": "timeseries", "title": "Intents minted, by what was bought", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 16, "y": 7 }, + "gridPos": { "h": 7, "w": 8, "x": 16, "y": 13 }, "fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 50 } }, "overrides": [] @@ -207,7 +302,7 @@ { "type": "row", "title": "App health", - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 20 }, "collapsed": false, "panels": [] }, @@ -215,7 +310,7 @@ "type": "timeseries", "title": "Portal API calls / sec", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 0, "y": 15 }, + "gridPos": { "h": 7, "w": 8, "x": 0, "y": 21 }, "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }, "targets": [ { @@ -229,7 +324,7 @@ "type": "timeseries", "title": "Latency p50 / p95 / p99", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 8, "y": 15 }, + "gridPos": { "h": 7, "w": 8, "x": 8, "y": 21 }, "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, "targets": [ { @@ -253,7 +348,7 @@ "type": "timeseries", "title": "Slowest procedures (p99)", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 16, "y": 15 }, + "gridPos": { "h": 7, "w": 8, "x": 16, "y": 21 }, "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, "targets": [ { @@ -267,7 +362,7 @@ "type": "timeseries", "title": "Process memory", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 6, "w": 12, "x": 0, "y": 22 }, + "gridPos": { "h": 6, "w": 12, "x": 0, "y": 28 }, "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }, "targets": [ { @@ -286,7 +381,7 @@ "type": "timeseries", "title": "Event loop lag (p99)", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 6, "w": 12, "x": 12, "y": 22 }, + "gridPos": { "h": 6, "w": 12, "x": 12, "y": 28 }, "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, "targets": [ { diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml index fd446485..dc988376 100644 --- a/monitoring/prometheus.yml +++ b/monitoring/prometheus.yml @@ -29,8 +29,9 @@ scrape_configs: # # Read the numbers it returns with the instance model in mind. App Hosting # autoscales, so each scrape lands on one arbitrary instance: - # - dsgt_members_*, dsgt_payments_* are read from the database on scrape and - # are therefore correct regardless of which instance answered. + # - dsgt_members_*, dsgt_payments_*, and dsgt_hackathon_* are read from the + # database on scrape and are therefore correct regardless of which + # instance answered. # - counters and histograms are that one instance's, since its own start, # and vanish when it scales down. Treat them as a sample, not a total. # diff --git a/monitoring/rules/hackathon.yml b/monitoring/rules/hackathon.yml new file mode 100644 index 00000000..2f275809 --- /dev/null +++ b/monitoring/rules/hackathon.yml @@ -0,0 +1,26 @@ +# Alerts for a live hackathon edition. The series are database gauges, so +# they are the same number on every instance. See monitoring/README.md. + +groups: + - name: hackathon + rules: + # Judging switched on, tables assigned, and still no scores. Thirty + # minutes is long enough that flipping the switch before judges reach + # the room does not page, and short enough that a broken submit does + # not sit through a whole judging block. + - alert: HackathonJudgingIdle + expr: >- + ( + dsgt_hackathon_judging_active > 0 + and on (edition) (dsgt_hackathon_judging_projects > 0) + ) + unless on (edition) (dsgt_hackathon_votes > 0) + for: 30m + labels: + severity: page + annotations: + summary: "Judging is on for {{ $labels.edition }} and no scores have landed" + description: >- + The edition has judging switched on and projects on the floor, and + the vote count is still zero. Judges may be blocked from + submitting, or judging was opened before anyone was at a table. diff --git a/package.json b/package.json index 6dcbb690..f05f0787 100644 --- a/package.json +++ b/package.json @@ -12,16 +12,16 @@ "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 sites/mainweb/components" }, "dependencies": { - "next": "16.3.7", + "next": "16.4.0", "typescript": "^7.0.2" }, "devDependencies": { - "turbo": "^2.11.3", + "turbo": "^2.11.7", "vite": "^7.3.5", - "vitest": "^5.0.1" + "vitest": "^5.0.3" }, "pnpm": { "overrides": { @@ -38,7 +38,8 @@ "sharp": "^0.35.0", "vite": "^7.3.5", "brace-expansion": "^5.0.9", - "gaxios>uuid": "^11.1.1" + "gaxios>uuid": "^11.1.1", + "source-map-js": "^1.2.2" } } } diff --git a/packages/api/package.json b/packages/api/package.json index 4f8b792d..b664b658 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", + "./judging-access": "./src/services/judging/principal.ts" }, "scripts": { "lint": "eslint . --max-warnings 0", @@ -36,16 +37,16 @@ "drizzle-orm": "0.45.3", "image-size": "2.0.4", "prom-client": "15.1.3", - "sanitize-html": "2.17.7", - "stripe": "^22.6.2", + "sanitize-html": "2.18.1", + "stripe": "^23.0.0", "superjson": "2.2.6", "zod": "4.6.5" }, "devDependencies": { "@query/tsconfig": "workspace:*", - "@types/sanitize-html": "^2.16.0", + "@types/sanitize-html": "^2.16.2", "typescript": "7.0.2", "vite": "^7.3.5", - "vitest": "^5.0.1" + "vitest": "^5.0.3" } } diff --git a/packages/api/scripts/judging-schedule/cpsat.py b/packages/api/scripts/judging-schedule/cpsat.py new file mode 100644 index 00000000..f8002323 --- /dev/null +++ b/packages/api/scripts/judging-schedule/cpsat.py @@ -0,0 +1,1135 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["ortools>=9.10"] +# /// +"""Judging schedule fallback: solve with Google OR-Tools CP-SAT. + +The portal schedules room judging with a TypeScript solver +(packages/api/src/services/judging/schedule). That solver is greedy plus local +search: fast and repeatable, but it can miss a schedule that exists, and it +never proves one is the best. When it fails, or an organiser wants a better +schedule, export the problem from the schedule section of the judging +console, run this on a laptop, and upload the solution it writes. The portal +re-validates every placement before saving, so a bad file cannot produce a +bad schedule. + + uv run packages/api/scripts/judging-schedule/cpsat.py problem.json -o solution.json + +Without uv: `pip install ortools`, then `python cpsat.py problem.json -o solution.json`. +Try it on the bundled example first: `uv run cpsat.py example-problem.json`. + +It solves the same model as the TypeScript solver: + + hard every presentation in exactly one room that serves its challenge; + one presentation per room per slot; + between two presentations of one team, at least `sameGap` empty + slots, or `crossGap` when they are in different buildings. + goals in this order, each one never traded for a later one: + 1. sponsor judging finishes early (last sponsor slot) + 2. sponsor rooms sit idle as little as possible + 3. everything finishes early + 4. teams change buildings as little as possible + 5. teams get as much break time as possible + +If no schedule keeps the cross-building break, it retries giving +cross-building walks only the same-building break and marks the solution +`relaxed`; the portal makes an organiser confirm that before saving. + +Judges +------ +When the problem lists `judges`, the script goes on to the second stage: one +judge for every presentation, solved as its own CP-SAT model once the +placement is known. + + hard a judge grades only challenges they may grade, and a guest only in + their own room; + one presentation per judge per slot; + moving to another room in the same building needs `sameGap` empty + slots, to another building `crossGap`; staying in one room needs none; + at most `maxConsecutive` slots in a row, then a free slot. + goals in this order, each one never traded for a later one: + 1. every presentation has a judge (the rest is reported, not failed) + 2. sponsor presentations go to the sponsor's own judges, not to table + judges (members) + 3. the busiest judge grades as little as possible + 4. judges of one pool (same challenges, same room) grade evenly + 5. a judge repeats a room as little as possible + 6. a judge's presentations cover as much of the day as possible, as + judge-bias calibration assumes every judge sees a random sample + +The placement stage then also keeps, in every slot, the presentations of a +challenge within the number of judges who may grade it, as far as it can +(goal 0, `judgeShortfall`, ahead of the five above). + +A problem with `placements` is a schedule already made: the placement stage is +skipped and only the judges are assigned. `locked` lists assignments that must +stay as they are. Files without `judges` work as before. + +Exit codes: 0 solved, 2 no schedule exists, 3 the time limit ran out before +any schedule was found, 1 bad input. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from collections import defaultdict +from dataclasses import dataclass, field + +PROBLEM_FORMAT = "query-judging-schedule-problem" +SOLUTION_FORMAT = "query-judging-schedule-solution" +VERSION = 1 + +# Same as MAX_CONSECUTIVE and DAY_PARTS in judges.ts. +MAX_CONSECUTIVE = 4 +DAY_PARTS = 4 + + +class InputError(Exception): + pass + + +@dataclass(frozen=True) +class Room: + id: str + challenge_id: str + building_id: str + sponsor: bool + + +@dataclass(frozen=True) +class Task: + entry_id: str + challenge_id: str + sponsor: bool + + +@dataclass(frozen=True) +class Judge: + id: str + kind: str # "member" or "guest" + challenge_ids: frozenset[str] + room_id: str | None + max_consecutive: int + + +@dataclass +class Problem: + fingerprint: str + slot_count: int + same_gap: int + cross_gap: int + rooms: list[Room] + tasks: list[Task] + hint: dict[tuple[str, str], tuple[str, int]] + judges: list[Judge] = field(default_factory=list) + # A finished schedule to assign judges to, instead of one to look for. + fixed: dict[tuple[str, str], tuple[str, int]] | None = None + # Assignments to keep: (entry id, challenge id) -> judge id. + locked: dict[tuple[str, str], str] = field(default_factory=dict) + # Assignments to start from, as `hint` is for the placement. + assignment_hint: dict[tuple[str, str], str] = field(default_factory=dict) + + +def _require(obj: dict, key: str, kind: type, where: str): + value = obj.get(key) + # bool is an int in Python; never accept it where a count is expected. + if not isinstance(value, kind) or (kind is int and isinstance(value, bool)): + raise InputError(f"{where}.{key} must be a {kind.__name__}") + return value + + +def load_problem(raw: dict) -> Problem: + if raw.get("format") != PROBLEM_FORMAT or raw.get("version") != VERSION: + raise InputError( + f"not a {PROBLEM_FORMAT} v{VERSION} file; export it again from the judging console" + ) + slot_count = _require(raw, "slotCount", int, "problem") + same_gap = _require(raw, "sameGap", int, "problem") + cross_gap = _require(raw, "crossGap", int, "problem") + if slot_count < 1 or same_gap < 0 or cross_gap < same_gap: + raise InputError("need slotCount >= 1 and 0 <= sameGap <= crossGap") + + rooms = [] + for i, r in enumerate(_require(raw, "rooms", list, "problem")): + where = f"rooms[{i}]" + rooms.append( + Room( + _require(r, "id", str, where), + _require(r, "challengeId", str, where), + _require(r, "buildingId", str, where), + _require(r, "sponsor", bool, where), + ) + ) + if len({r.id for r in rooms}) != len(rooms): + raise InputError("room ids repeat") + + tasks = [] + for i, t in enumerate(_require(raw, "tasks", list, "problem")): + where = f"tasks[{i}]" + tasks.append( + Task( + _require(t, "entryId", str, where), + _require(t, "challengeId", str, where), + _require(t, "sponsor", bool, where), + ) + ) + if len({(t.entry_id, t.challenge_id) for t in tasks}) != len(tasks): + raise InputError("a project appears twice for one challenge") + + hint = {} + for p in raw.get("hint") or []: + hint[(p["entryId"], p["challengeId"])] = (p["roomId"], p["slot"]) + + judges = [] + for i, j in enumerate(_require(raw, "judges", list, "problem") if "judges" in raw else []): + where = f"judges[{i}]" + if not isinstance(j, dict): + raise InputError(f"{where} must be an object") + kind = _require(j, "kind", str, where) + if kind not in ("member", "guest"): + raise InputError(f"{where}.kind must be member or guest") + challenge_ids = _require(j, "challengeIds", list, where) + if not all(isinstance(c, str) for c in challenge_ids): + raise InputError(f"{where}.challengeIds must be strings") + room_id = j.get("roomId") + if room_id is not None and not isinstance(room_id, str): + raise InputError(f"{where}.roomId must be a string or null") + max_consecutive = j.get("maxConsecutive", MAX_CONSECUTIVE) + if not isinstance(max_consecutive, int) or isinstance(max_consecutive, bool) or max_consecutive < 1: + raise InputError(f"{where}.maxConsecutive must be a positive integer") + judges.append( + Judge(_require(j, "id", str, where), kind, frozenset(challenge_ids), room_id, max_consecutive) + ) + if len({j.id for j in judges}) != len(judges): + raise InputError("judge ids repeat") + + fixed = None + if raw.get("placements") is not None: + room_ids = {r.id for r in rooms} + known = {(t.entry_id, t.challenge_id) for t in tasks} + fixed = {} + for i, p in enumerate(_require(raw, "placements", list, "problem")): + where = f"placements[{i}]" + key = (_require(p, "entryId", str, where), _require(p, "challengeId", str, where)) + room_id = _require(p, "roomId", str, where) + slot = _require(p, "slot", int, where) + if key not in known or room_id not in room_ids or key in fixed: + raise InputError(f"{where} does not match a presentation and room of this problem") + if not 0 <= slot < slot_count: + raise InputError(f"{where}.slot is outside the window") + fixed[key] = (room_id, slot) + if set(fixed) != known: + raise InputError("placements must place every presentation") + if not judges: + raise InputError("placements without judges leave nothing to solve") + + locked = {} + for i, row in enumerate(_require(raw, "locked", list, "problem") if "locked" in raw else []): + where = f"locked[{i}]" + locked[(_require(row, "entryId", str, where), _require(row, "challengeId", str, where))] = _require( + row, "judgeId", str, where + ) + + assignment_hint = {} + for i, row in enumerate(_require(raw, "assignmentHint", list, "problem") if "assignmentHint" in raw else []): + where = f"assignmentHint[{i}]" + assignment_hint[(_require(row, "entryId", str, where), _require(row, "challengeId", str, where))] = _require( + row, "judgeId", str, where + ) + + return Problem( + fingerprint=_require(raw, "fingerprint", str, "problem"), + slot_count=slot_count, + same_gap=same_gap, + cross_gap=cross_gap, + rooms=rooms, + tasks=tasks, + hint=hint, + judges=judges, + fixed=fixed, + locked=locked, + assignment_hint=assignment_hint, + ) + + +def capacity_problems(problem: Problem, gap: int) -> list[str]: + """Reasons no schedule can exist, found by counting alone.""" + reasons = [] + rooms_per_challenge = defaultdict(int) + for room in problem.rooms: + rooms_per_challenge[room.challenge_id] += 1 + tasks_per_challenge = defaultdict(int) + tasks_per_entry = defaultdict(int) + for task in problem.tasks: + tasks_per_challenge[task.challenge_id] += 1 + tasks_per_entry[task.entry_id] += 1 + + for challenge, count in sorted(tasks_per_challenge.items()): + capacity = rooms_per_challenge[challenge] * problem.slot_count + if capacity == 0: + reasons.append(f"challenge {challenge}: no active room serves it") + elif count > capacity: + reasons.append( + f"challenge {challenge}: {count} presentations but only {capacity} room slots" + ) + for entry, count in sorted(tasks_per_entry.items()): + needed = count + (count - 1) * gap + if needed > problem.slot_count: + reasons.append( + f"project {entry}: {count} presentations with breaks need {needed} slots, " + f"the window has {problem.slot_count}" + ) + return reasons + + +# Placement: task index -> (room index, slot). +Placement = dict[int, tuple[int, int]] + + +def score(problem: Problem, placement: Placement) -> dict[str, int]: + """The five goals for a finished schedule, computed from placements alone.""" + rooms, tasks = problem.rooms, problem.tasks + sponsor_finish = max( + (placement[i][1] + 1 for i, t in enumerate(tasks) if t.sponsor), default=0 + ) + finish = max((slot + 1 for _, slot in placement.values()), default=0) + + slots_by_room = defaultdict(list) + for room_index, slot in placement.values(): + slots_by_room[room_index].append(slot) + sponsor_idle = sum( + max(slots) - min(slots) + 1 - len(slots) + for room_index, slots in slots_by_room.items() + if rooms[room_index].sponsor + ) + + visits = defaultdict(list) + for i, task in enumerate(tasks): + room_index, slot = placement[i] + visits[task.entry_id].append((slot, rooms[room_index].building_id)) + changes = breaks = 0 + for seq in visits.values(): + seq.sort() + for (s1, b1), (s2, b2) in zip(seq, seq[1:]): + changes += b1 != b2 + breaks += s2 - s1 - 1 + goals = { + "sponsorFinish": sponsor_finish, + "sponsorIdle": sponsor_idle, + "finish": finish, + "buildingChanges": changes, + "breakSlots": breaks, + } + if problem.judges: + # Ahead of the rest, as in solve(). + goals = {"judgeShortfall": shortfall(problem, placement), **goals} + return goals + + +def judge_capacity(problem: Problem) -> dict[str, int]: + """How many judges may grade each challenge that has presentations and any judge.""" + counts = defaultdict(int) + for judge in problem.judges: + for challenge in judge.challenge_ids: + counts[challenge] += 1 + return {c: counts[c] for c in sorted({t.challenge_id for t in problem.tasks}) if counts[c] > 0} + + +def shortfall(problem: Problem, placement: Placement) -> int: + """Presentations of a challenge in one slot beyond its judges, for the worst + slot of each challenge, summed. 0 when judges could cover every slot.""" + capacity = judge_capacity(problem) + per_slot = defaultdict(int) + for i, task in enumerate(problem.tasks): + per_slot[(task.challenge_id, placement[i][1])] += 1 + worst = defaultdict(int) + for (challenge, _slot), count in per_slot.items(): + if challenge in capacity: + worst[challenge] = max(worst[challenge], count - capacity[challenge]) + return sum(worst.values()) + + +def violations(problem: Problem, placement: Placement, cross_gap: int) -> list[str]: + """Independent re-check of the hard rules; empty when the schedule is valid.""" + rooms, tasks = problem.rooms, problem.tasks + found = [] + taken = {} + for i, task in enumerate(tasks): + if i not in placement: + found.append(f"{task.entry_id}/{task.challenge_id} is not placed") + continue + room_index, slot = placement[i] + if rooms[room_index].challenge_id != task.challenge_id: + found.append(f"{task.entry_id} is in a room for another challenge") + if not 0 <= slot < problem.slot_count: + found.append(f"{task.entry_id} is outside the window") + if (room_index, slot) in taken: + found.append(f"room {rooms[room_index].id} slot {slot} is double-booked") + taken[(room_index, slot)] = i + + visits = defaultdict(list) + for i, task in enumerate(tasks): + if i in placement: + room_index, slot = placement[i] + visits[task.entry_id].append((slot, rooms[room_index].building_id)) + for entry, seq in visits.items(): + seq.sort() + for (s1, b1), (s2, b2) in zip(seq, seq[1:]): + gap = problem.same_gap if b1 == b2 else cross_gap + if s2 - s1 - 1 < gap: + found.append(f"project {entry} has {s2 - s1 - 1} empty slots, needs {gap}") + return found + + +GOALS = ["sponsorFinish", "sponsorIdle", "finish", "buildingChanges", "breakSlots"] + + +def solve(problem: Problem, cross_gap: int, seconds: float, workers: int, log): + """Lexicographic CP-SAT solve. Returns (status, placement or None).""" + from ortools.sat.python import cp_model + + rooms, tasks = problem.rooms, problem.tasks + last_slot = problem.slot_count - 1 + model = cp_model.CpModel() + tracked = [] + + def int_var(lo: int, hi: int, name: str): + v = model.new_int_var(lo, hi, name) + tracked.append(v) + return v + + def flag(name: str): + v = model.new_bool_var(name) + tracked.append(v) + return v + + building_index = {b: i for i, b in enumerate(sorted({r.building_id for r in rooms}))} + room_index_by_id = {r.id: i for i, r in enumerate(rooms)} + rooms_for = defaultdict(list) + for i, room in enumerate(rooms): + rooms_for[room.challenge_id].append(i) + + start, building, choices = [], [], [] + in_room = defaultdict(list) # room index -> [(task index, literal)] + for i, task in enumerate(tasks): + eligible = rooms_for[task.challenge_id] + s = int_var(0, last_slot, f"start_{i}") + picks = [] + for r in eligible: + lit = flag(f"task_{i}_room_{r}") + picks.append((r, lit)) + in_room[r].append((i, lit)) + model.add_exactly_one(lit for _, lit in picks) + values = [building_index[rooms[r].building_id] for r in eligible] + b = int_var(min(values), max(values), f"building_{i}") + model.add(b == sum(building_index[rooms[r].building_id] * lit for r, lit in picks)) + start.append(s) + building.append(b) + choices.append(picks) + + # A room holds one presentation per slot. Every presentation is one slot + # long, so this is "all different" over the presentations it holds. + for r, members in in_room.items(): + model.add_no_overlap( + model.new_optional_fixed_size_interval_var(start[i], 1, lit, f"in_{r}_{i}") + for i, lit in members + ) + + # Each team's presentations form a chain in time. A circuit through a depot + # picks the order; each arc taken says "this one comes right after that + # one", which is where the break (and any building change) happens. + by_entry = defaultdict(list) + for i, task in enumerate(tasks): + by_entry[task.entry_id].append(i) + + changes, break_terms = [], [] + for entry, members in sorted(by_entry.items()): + n = len(members) + if n < 2: + continue + differs = {} + for a in range(n): + for c in range(a + 1, n): + i, j = members[a], members[c] + d = flag(f"differ_{i}_{j}") + model.add(building[i] != building[j]).only_enforce_if(d) + model.add(building[i] == building[j]).only_enforce_if(d.Not()) + differs[(i, j)] = differs[(j, i)] = d + arcs = [] + for a, i in enumerate(members): + arcs.append((0, a + 1, flag(f"first_{i}"))) + arcs.append((a + 1, 0, flag(f"last_{i}"))) + for c, j in enumerate(members): + if i == j: + continue + follows = flag(f"next_{i}_{j}") + arcs.append((a + 1, c + 1, follows)) + d = differs[(i, j)] + model.add(start[j] >= start[i] + 1 + problem.same_gap).only_enforce_if(follows) + if cross_gap > problem.same_gap: + model.add(start[j] >= start[i] + 1 + cross_gap).only_enforce_if([follows, d]) + moved = flag(f"moved_{i}_{j}") + model.add_bool_or([follows.Not(), d.Not(), moved]) + model.add_implication(moved, follows) + model.add_implication(moved, d) + changes.append(moved) + model.add_circuit(arcs) + + first = int_var(0, last_slot, f"entry_first_{entry}") + last = int_var(0, last_slot, f"entry_last_{entry}") + model.add_min_equality(first, [start[i] for i in members]) + model.add_max_equality(last, [start[i] for i in members]) + # Along a chain the breaks add up to (last - first) - (n - 1). + break_terms.append(last - first - (n - 1)) + + # Goal variables, each defined exactly (not just bounded) so the values a + # finished stage reports are a consistent hint for the next one. + finish = int_var(0, problem.slot_count, "finish") + model.add_max_equality(finish, [s + 1 for s in start]) + sponsor_starts = [start[i] + 1 for i, task in enumerate(tasks) if task.sponsor] + sponsor_finish = int_var(0, problem.slot_count, "sponsor_finish") + model.add_max_equality(sponsor_finish, sponsor_starts or [0]) + + # Lower bounds from counting alone. Redundant, but they let CP-SAT prove a + # goal optimal, and move on, the moment a schedule reaches them. + def busiest(selected) -> int: + per_challenge = defaultdict(int) + for task in selected: + per_challenge[task.challenge_id] += 1 + return max( + (-(-count // len(rooms_for[c])) for c, count in per_challenge.items()), + default=0, + ) + + longest_chain = max( + (n + (n - 1) * problem.same_gap for n in map(len, by_entry.values())), default=0 + ) + model.add(finish >= max(busiest(tasks), longest_chain)) + model.add(sponsor_finish >= busiest([t for t in tasks if t.sponsor])) + + # Idle slots in a sponsor room: its span minus what it holds, 0 if unused. + idle_terms = [] + for r, members in in_room.items(): + if not rooms[r].sponsor: + continue + used = flag(f"room_used_{r}") + model.add_max_equality(used, [lit for _, lit in members]) + lows, highs = [], [] + for i, lit in members: + low = int_var(0, last_slot, f"room_{r}_low_{i}") + high = int_var(0, last_slot, f"room_{r}_high_{i}") + model.add(low == start[i]).only_enforce_if(lit) + model.add(low == last_slot).only_enforce_if(lit.Not()) + model.add(high == start[i]).only_enforce_if(lit) + model.add(high == 0).only_enforce_if(lit.Not()) + lows.append(low) + highs.append(high) + lo = int_var(0, last_slot, f"room_first_{r}") + hi = int_var(0, last_slot, f"room_last_{r}") + model.add_min_equality(lo, lows) + model.add_max_equality(hi, highs) + idle = int_var(0, problem.slot_count, f"room_idle_{r}") + model.add(idle == hi - lo + 1 - sum(lit for _, lit in members)).only_enforce_if(used) + model.add(idle == 0).only_enforce_if(used.Not()) + idle_terms.append(idle) + + # Judges: in any slot, a challenge's presentations should not outnumber the + # judges who may grade it. Kept as a goal, the first, rather than a rule: too + # few judges must not make a schedule impossible. Each challenge's capacity + # is its judges plus an extra, the most it is over in any slot. + overloads = [] + for challenge, judges_for in judge_capacity(problem).items(): + members = [i for i, task in enumerate(tasks) if task.challenge_id == challenge] + if len(members) <= judges_for: + continue + extra = int_var(0, len(members) - judges_for, f"judge_extra_{challenge}") + model.add_cumulative( + [model.new_fixed_size_interval_var(start[i], 1, f"at_{i}") for i in members], + [1] * len(members), + judges_for + extra, + ) + overloads.append(extra) + + goals = { + "judgeShortfall": sum(overloads), + "sponsorFinish": sponsor_finish, + "sponsorIdle": sum(idle_terms), + "finish": finish, + "buildingChanges": sum(changes), + "breakSlots": -sum(break_terms), + } + stages = [g for g in GOALS if not (g == "sponsorIdle" and not idle_terms) + and not (g == "sponsorFinish" and not any(t.sponsor for t in tasks)) + and not (g in ("buildingChanges", "breakSlots") and not break_terms)] + if overloads: + stages.insert(0, "judgeShortfall") + + def hint_placement(placement: Placement): + model.clear_hints() + for i, (r, slot) in placement.items(): + model.add_hint(start[i], slot) + for room, lit in choices[i]: + model.add_hint(lit, room == r) + + hinted: Placement = {} + if problem.hint: + for i, task in enumerate(tasks): + got = problem.hint.get((task.entry_id, task.challenge_id)) + if got and got[0] in room_index_by_id: + hinted[i] = (room_index_by_id[got[0]], got[1]) + hint_placement(hinted) + + def new_solver(limit: float): + solver = cp_model.CpSolver() + solver.parameters.max_time_in_seconds = max(1.0, limit) + solver.parameters.num_workers = workers + solver.parameters.random_seed = 7 + return solver + + def take(solver) -> Placement: + """The solver's schedule, also hinted in full for the next stage.""" + model.clear_hints() + for v in tracked: + model.add_hint(v, solver.value(v)) + return { + i: (next(room for room, lit in choices[i] if solver.boolean_value(lit)), + solver.value(start[i])) + for i in range(len(tasks)) + } + + def model_value(goal: str, placement: Placement) -> int: + return score(problem, placement)[goal] * (-1 if goal == "breakSlots" else 1) + + deadline = time.monotonic() + seconds + # A complete hint that keeps every rule is already a schedule. Start from + # it, so running out of time can only fail to improve it. + best: Placement | None = None + if hinted and not violations(problem, hinted, cross_gap): + best = hinted + else: + # Nothing to start from: look for any schedule first, with the whole + # attempt if it takes that, and let the goals share what is left. + solver = new_solver(deadline - time.monotonic()) + solver.parameters.stop_after_first_solution = True + model.minimize(goals[stages[0]]) + status = solver.solve(model) + if status == cp_model.INFEASIBLE: + return ("infeasible", None) + if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): + return ("unknown", None) + best = take(solver) + + all_optimal = True + for position, goal in enumerate(stages): + # Hold this goal where the schedule in hand has it, so this stage can + # only improve on it and later goals cannot trade it away. + model.add(goals[goal] <= model_value(goal, best)) + remaining = deadline - time.monotonic() + # The first goal outranks all the others, so it gets at least half; + # later stages split what is left evenly. + share = remaining / (len(stages) - position) + if position == 0: + share = max(share, remaining / 2) + solver = new_solver(share) + # Presolve costs seconds on a big event and every stage already starts + # from a complete schedule, so skip it. + solver.parameters.cp_model_presolve = False + model.minimize(goals[goal]) + status = solver.solve(model) + + if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): + all_optimal = False + log(f" {goal}: {abs(model_value(goal, best))} " + f"(no improvement within its time share, {solver.status_name(status)})") + continue + + all_optimal = all_optimal and status == cp_model.OPTIMAL + best = take(solver) + value = int(solver.objective_value) + log(f" {goal}: {abs(value)}{'' if status == cp_model.OPTIMAL else ' (time limit)'}") + model.add(goals[goal] <= value) + + return ("optimal" if all_optimal else "feasible", best) + + +# Assignment: task index -> judge index, or None when nobody grades it. +Assignment = dict[int, "int | None"] + +# The second stage's goals, in order. Lower is better except the last. +JUDGE_GOALS = ["unassigned", "memberOnSponsor", "maxLoad", "loadSpread", "repeats", "dayPartsCovered"] + + +def may_grade(judge: Judge, task: Task, room: Room) -> bool: + if task.challenge_id not in judge.challenge_ids: + return False + # A guest grades only in their own room, and with no room grades nowhere. + if judge.room_id is not None: + return judge.room_id == room.id + return judge.kind != "guest" + + +def day_part(problem: Problem, slot: int) -> int: + return min(DAY_PARTS - 1, slot * DAY_PARTS // problem.slot_count) + + +def judge_pools(problem: Problem) -> list[list[int]]: + """Judges who are interchangeable (same challenges, same room); only within + one of these can the load be evened out.""" + grouped = defaultdict(list) + for k, judge in enumerate(problem.judges): + grouped[(tuple(sorted(judge.challenge_ids)), judge.room_id or "")].append(k) + return list(grouped.values()) + + +def locked_tasks(problem: Problem) -> dict[int, int]: + """Task index -> judge index for the assignments to keep that name a known + presentation and judge.""" + task_index = {(t.entry_id, t.challenge_id): i for i, t in enumerate(problem.tasks)} + judge_index = {j.id: k for k, j in enumerate(problem.judges)} + return { + task_index[key]: judge_index[judge_id] + for key, judge_id in problem.locked.items() + if key in task_index and judge_id in judge_index + } + + +def judges_clash(problem: Problem, placement: Placement, i: int, q: int) -> bool: + """Whether one judge cannot grade both: at the same time, or too soon to walk + between their rooms. Staying in one room needs no walk.""" + (room_i, slot_i), (room_q, slot_q) = placement[i], placement[q] + apart = abs(slot_i - slot_q) + if apart == 0: + return True + if room_i == room_q: + return False + same = problem.rooms[room_i].building_id == problem.rooms[room_q].building_id + return apart <= (problem.same_gap if same else problem.cross_gap) + + +def assignment_score(problem: Problem, placement: Placement, assignment: Assignment) -> dict[str, float]: + """The second stage's goals for a finished assignment, from the assignment alone.""" + judges, tasks = problem.judges, problem.tasks + load = [0] * len(judges) + rooms_seen = [defaultdict(int) for _ in judges] + parts_seen = [set() for _ in judges] + unassigned = member_on_sponsor = 0 + for i, task in enumerate(tasks): + k = assignment.get(i) + if k is None: + unassigned += 1 + continue + load[k] += 1 + rooms_seen[k][placement[i][0]] += 1 + parts_seen[k].add(day_part(problem, placement[i][1])) + member_on_sponsor += task.sponsor and judges[k].kind == "member" + spread = sum( + max(load[k] for k in pool) - min(load[k] for k in pool) + for pool in judge_pools(problem) + if len(pool) > 1 + ) + graders = [k for k in range(len(judges)) if load[k]] + return { + "unassigned": unassigned, + "memberOnSponsor": member_on_sponsor, + "maxLoad": max(load, default=0), + "loadSpread": spread, + "repeats": sum(count - 1 for seen in rooms_seen for count in seen.values()), + "dayPartsCovered": sum(len(parts) for parts in parts_seen), + "avgDistinctRooms": round(sum(len(rooms_seen[k]) for k in graders) / len(graders), 2) if graders else 0, + } + + +def assignment_violations(problem: Problem, placement: Placement, assignment: Assignment) -> list[str]: + """Independent re-check of the second stage's hard rules.""" + judges, tasks, rooms = problem.judges, problem.tasks, problem.rooms + kept = locked_tasks(problem) + found = [] + mine = defaultdict(list) + for i, k in assignment.items(): + if k is None: + continue + mine[k].append(i) + if i not in kept and not may_grade(judges[k], tasks[i], rooms[placement[i][0]]): + found.append(f"judge {judges[k].id} may not grade {tasks[i].entry_id}/{tasks[i].challenge_id}") + max_gap = max(problem.same_gap, problem.cross_gap) + for k, members in mine.items(): + members.sort(key=lambda i: placement[i][1]) + for a, i in enumerate(members): + for q in members[a + 1:]: + if placement[q][1] - placement[i][1] > max_gap: + break + if judges_clash(problem, placement, i, q): + found.append(f"judge {judges[k].id} cannot be at both {tasks[i].entry_id} and {tasks[q].entry_id}") + run, previous = 0, -2 + for slot in sorted({placement[i][1] for i in members}): + run = run + 1 if slot == previous + 1 else 1 + previous = slot + if run == judges[k].max_consecutive + 1: + found.append(f"judge {judges[k].id} grades more than {judges[k].max_consecutive} slots in a row") + return found + + +def assign(problem: Problem, placement: Placement, seconds: float, workers: int, log): + """Lexicographic CP-SAT solve of who grades what, once the schedule is fixed. + Returns (status, assignment or None). A presentation nobody may grade, or + that no judge has time for, is left out rather than failing the solve.""" + from ortools.sat.python import cp_model + + judges, tasks, rooms = problem.judges, problem.tasks, problem.rooms + n = len(tasks) + slot_of = [placement[i][1] for i in range(n)] + room_of = [placement[i][0] for i in range(n)] + kept = locked_tasks(problem) + + model = cp_model.CpModel() + tracked = [] + + def int_var(lo: int, hi: int, name: str): + v = model.new_int_var(lo, hi, name) + tracked.append(v) + return v + + def flag(name: str): + v = model.new_bool_var(name) + tracked.append(v) + return v + + # A kept presentation may go to its judge alone; the rest to whoever may grade it. + eligible = [ + [kept[i]] if i in kept + else [k for k, judge in enumerate(judges) if may_grade(judge, tasks[i], rooms[room_of[i]])] + for i in range(n) + ] + pick = {(i, k): flag(f"task_{i}_judge_{k}") for i in range(n) for k in eligible[i]} + left_out = [flag(f"left_out_{i}") for i in range(n)] + for i in range(n): + model.add_exactly_one([pick[i, k] for k in eligible[i]] + [left_out[i]]) + if i in kept: + model.add(pick[i, kept[i]] == 1) + + max_gap = max(problem.same_gap, problem.cross_gap) + + def walk(room: int, other: int) -> int: + """Free slots a judge needs between grading in `room` and in `other`.""" + if room == other: + return 0 + same = rooms[room].building_id == rooms[other].building_id + return problem.same_gap if same else problem.cross_gap + + mine = [] # judge -> {task: literal} + for k, judge in enumerate(judges): + grades = {i: pick[i, k] for i in range(n) if (i, k) in pick} + mine.append(grades) + # Where this judge could be: slot -> room -> literals. Each rule below + # is a set of which at most one holds, the same as forbidding every + # pair that judges_clash() forbids, in far fewer constraints. + at = defaultdict(lambda: defaultdict(list)) + for i, lit in grades.items(): + at[slot_of[i]][room_of[i]].append(lit) + for slot, here in at.items(): + # One place at a time. + everywhere = [lit for lits in here.values() for lit in lits] + if len(everywhere) > 1: + model.add_at_most_one(everywhere) + # Time to walk: grading in a room rules out, in each of the next + # few slots, every room too far to reach by then. + for room, lits in here.items(): + for ahead in range(1, max_gap + 1): + later = at.get(slot + ahead, {}) + far = [lit for other, group in later.items() + if ahead <= walk(room, other) for lit in group] + if far: + model.add_at_most_one(lits + far) + # No more than max_consecutive slots in a row: any window one longer + # holds at most max_consecutive. + run = judge.max_consecutive + in_slot = defaultdict(list) + for i, lit in grades.items(): + in_slot[slot_of[i]].append(lit) + for first in range(problem.slot_count - run): + window = [lit for slot in range(first, first + run + 1) for lit in in_slot.get(slot, ())] + if len(window) > run: + model.add(sum(window) <= run) + + load = [] + for k, grades in enumerate(mine): + v = int_var(0, len(grades), f"load_{k}") + model.add(v == sum(grades.values())) + load.append(v) + + # Goals, each (name, expression to minimise, whether it has anything to decide). + member_on_sponsor = [ + pick[i, k] for (i, k) in pick if tasks[i].sponsor and judges[k].kind == "member" + ] + busiest = int_var(0, n, "busiest") + if load: + model.add_max_equality(busiest, load) + spreads = [] + for pool in judge_pools(problem): + if len(pool) < 2: + continue + high, low = int_var(0, n, "pool_high"), int_var(0, n, "pool_low") + for k in pool: + model.add(high >= load[k]) + model.add(low <= load[k]) + spreads.append(high - low) + rooms_used, parts_used = [], [] + for k, grades in enumerate(mine): + in_room, in_part = defaultdict(list), defaultdict(list) + for i, lit in grades.items(): + in_room[room_of[i]].append(lit) + in_part[day_part(problem, slot_of[i])].append(lit) + for lits, used in ((in_room, rooms_used), (in_part, parts_used)): + for group in lits.values(): + seen = flag(f"seen_{k}_{len(used)}") + model.add(seen <= sum(group)) + used.append(seen) + + stages = [ + ("unassigned", sum(left_out), n > 0), + ("memberOnSponsor", sum(member_on_sponsor), bool(member_on_sponsor)), + ("maxLoad", busiest, bool(load)), + ("loadSpread", sum(spreads), bool(spreads)), + ("repeats", -sum(rooms_used), bool(rooms_used)), + ("dayPartsCovered", -sum(parts_used), bool(parts_used)), + ] + stages = [(name, expression) for name, expression, decides in stages if decides] + + def new_solver(limit: float): + solver = cp_model.CpSolver() + solver.parameters.max_time_in_seconds = max(1.0, limit) + solver.parameters.num_workers = workers + solver.parameters.random_seed = 7 + # Presolve and the symmetry search cost seconds on a big event, longer + # than a stage's share, and every stage after the first starts from a + # complete assignment, so skip both. + solver.parameters.cp_model_presolve = False + solver.parameters.symmetry_level = 0 + return solver + + def take(solver) -> Assignment: + model.clear_hints() + for v in tracked: + model.add_hint(v, solver.value(v)) + return { + i: next((k for k in eligible[i] if solver.boolean_value(pick[i, k])), None) + for i in range(n) + } + + # Start from the hinted assignment where it names a pair the model has. One + # that keeps every rule is already an answer, as a good `hint` is for the + # placement, so running out of time can only fail to improve it. + judge_index = {j.id: k for k, j in enumerate(judges)} + task_index = {(t.entry_id, t.challenge_id): i for i, t in enumerate(tasks)} + hinted: Assignment = {i: kept.get(i) for i in range(n)} + for key, judge_id in problem.assignment_hint.items(): + i, k = task_index.get(key), judge_index.get(judge_id) + if i is not None and k is not None and (i, k) in pick and i not in kept: + hinted[i] = k + deadline = time.monotonic() + seconds + best: Assignment | None = None + if problem.assignment_hint: + model.clear_hints() + for (i, k), lit in pick.items(): + model.add_hint(lit, hinted[i] == k) + for i in range(n): + model.add_hint(left_out[i], hinted[i] is None) + if not assignment_violations(problem, placement, hinted): + best = hinted + + def model_value(goal: str, assignment: Assignment) -> int: + """`goal` for `assignment` as the stage's expression counts it.""" + score = assignment_score(problem, placement, assignment) + if goal == "repeats": + # Minus the rooms each judge grades in, summed: repeats less the + # presentations graded. + return score["repeats"] - (n - score["unassigned"]) + if goal == "dayPartsCovered": + return -score["dayPartsCovered"] + return score[goal] + + all_optimal = True + for position, (name, expression) in enumerate(stages): + if position == 0 and best is not None: + # The hint already reaches this; the search can only improve on it. + model.add(expression <= model_value(name, best)) + remaining = deadline - time.monotonic() + share = remaining / (len(stages) - position) + if position == 0: + share = max(share, remaining / 2) + solver = new_solver(share) + model.minimize(expression) + status = solver.solve(model) + if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): + if status == cp_model.INFEASIBLE and position == 0: + return ("infeasible", None) + if best is None: + return ("unknown", None) + all_optimal = False + log(f" {name}: no improvement within its time share, {solver.status_name(status)}") + # Hold this goal where the assignment in hand has it, so the + # stages after it cannot trade it away. + model.add(expression <= model_value(name, best)) + continue + all_optimal = all_optimal and status == cp_model.OPTIMAL + best = take(solver) + value = int(solver.objective_value) + shown = assignment_score(problem, placement, best)[name] + log(f" {name}: {shown}{'' if status == cp_model.OPTIMAL else ' (time limit)'}") + model.add(expression <= value) + + if best is None: + # Nothing to decide: every presentation is left out or settled by its kept judge. + best = {i: kept.get(i) for i in range(n)} + return ("optimal" if all_optimal else "feasible", best) + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("problem", help="problem.json exported from the judging console, " + "or - to read it from stdin") + parser.add_argument("-o", "--output", help="where to write solution.json (default: stdout)") + parser.add_argument("--time-limit", type=float, default=120.0, + help="seconds for each attempt (strict, then relaxed); default 120") + parser.add_argument("--workers", type=int, default=8, + help="search threads; default 8. With 1, a run repeats exactly only if " + "no goal is cut short by the time limit") + args = parser.parse_args(argv) + + def log(message: str): + print(message, file=sys.stderr) + + from_stdin = args.problem == "-" + try: + if from_stdin: + text = sys.stdin.buffer.read().decode("utf-8-sig") + else: + with open(args.problem, encoding="utf-8-sig") as handle: + text = handle.read() + problem = load_problem(json.loads(text)) + except (OSError, ValueError, KeyError, TypeError, InputError) as error: + log(f"cannot read {'stdin' if from_stdin else args.problem}: {error}") + return 1 + + try: + import ortools + except ImportError: + log("OR-Tools is not installed. Run this with `uv run`, or `pip install ortools` first.") + return 1 + + log(f"{len(problem.tasks)} presentations, {len(problem.rooms)} rooms, " + f"{problem.slot_count} slots (OR-Tools {ortools.__version__})") + + began = time.monotonic() + status, placement, relaxed = "infeasible", None, False + reasons: list[str] = [] + if problem.fixed is not None: + # The schedule is decided; only the judges remain. + room_index = {r.id: i for i, r in enumerate(problem.rooms)} + placement = { + i: (room_index[problem.fixed[t.entry_id, t.challenge_id][0]], + problem.fixed[t.entry_id, t.challenge_id][1]) + for i, t in enumerate(problem.tasks) + } + status = "optimal" + else: + attempts = [(False, problem.cross_gap)] + if problem.cross_gap > problem.same_gap: + attempts.append((True, problem.same_gap)) + + reasons = capacity_problems(problem, problem.same_gap) + for relaxed, cross_gap in [] if reasons else attempts: + log("relaxed: cross-building walks get the same-building break" if relaxed + else "strict: full cross-building breaks") + status, placement = solve(problem, cross_gap, args.time_limit, args.workers, log) + if placement is not None: + broken = violations(problem, placement, cross_gap) + if broken: + # Would be a bug in the model above; never hand the portal a bad file. + log("internal check failed: " + "; ".join(broken[:5])) + return 1 + break + if status == "unknown": + # Running out of time proves nothing, so the breaks are only + # shortened once the full ones are shown to be impossible. + break + + assignment: Assignment | None = None + assignment_status = None + if placement is not None and problem.judges: + log(f"{len(problem.judges)} judges") + assignment_status, assignment = assign(problem, placement, args.time_limit, args.workers, log) + if assignment is not None: + broken = assignment_violations(problem, placement, assignment) + if broken: + log("internal check failed: " + "; ".join(broken[:5])) + return 1 + elif problem.fixed is not None: + # Nothing else to hand back. + status = assignment_status + reasons = ["the assignments to keep break the judges' rules"] if status == "infeasible" else [] + + solved = placement is not None and (problem.fixed is None or assignment is not None) + solution = { + "format": SOLUTION_FORMAT, + "version": VERSION, + "fingerprint": problem.fingerprint, + "status": status if not (solved and problem.fixed is not None) else assignment_status, + "relaxed": relaxed if solved else False, + "objective": score(problem, placement) if solved else None, + "placements": [ + { + "entryId": problem.tasks[i].entry_id, + "challengeId": problem.tasks[i].challenge_id, + "roomId": problem.rooms[r].id, + "slot": slot, + } + for i, (r, slot) in sorted(placement.items()) + ] if solved else [], + "reasons": reasons, + "solver": { + "name": "cp-sat", + "ortools": ortools.__version__, + "seconds": round(time.monotonic() - began, 2), + }, + } + if assignment is not None: + solution["assignmentStatus"] = assignment_status + solution["judging"] = assignment_score(problem, placement, assignment) + solution["assignments"] = [ + { + "entryId": problem.tasks[i].entry_id, + "challengeId": problem.tasks[i].challenge_id, + "judgeId": problem.judges[k].id, + } + for i, k in sorted(assignment.items()) + if k is not None + ] + text = json.dumps(solution, indent=2) + "\n" + if args.output: + with open(args.output, "w", encoding="utf-8") as handle: + handle.write(text) + else: + sys.stdout.write(text) + + if solved: + log(f"{solution['status']}{', relaxed' if solution['relaxed'] else ''}: " + + ", ".join(f"{k} {v}" for k, v in solution["objective"].items())) + if assignment is not None: + log(f"judges ({assignment_status}): " + + ", ".join(f"{k} {v}" for k, v in solution["judging"].items())) + return 0 + if status == "unknown": + log("no schedule found within the time limit; try a larger --time-limit") + return 3 + log("no schedule exists for this window and these rooms") + for reason in reasons: + log(f" {reason}") + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/packages/api/scripts/judging-schedule/example-problem.json b/packages/api/scripts/judging-schedule/example-problem.json new file mode 100644 index 00000000..1393d07a --- /dev/null +++ b/packages/api/scripts/judging-schedule/example-problem.json @@ -0,0 +1,232 @@ +{ + "format": "query-judging-schedule-problem", + "version": 1, + "fingerprint": "example", + "slotCount": 18, + "sameGap": 1, + "crossGap": 2, + "rooms": [ + { + "id": "room-klaus-1", + "challengeId": "general", + "buildingId": "klaus", + "sponsor": false + }, + { + "id": "room-klaus-2", + "challengeId": "general", + "buildingId": "klaus", + "sponsor": false + }, + { + "id": "room-coc-1", + "challengeId": "general", + "buildingId": "coc", + "sponsor": false + }, + { + "id": "room-coc-2", + "challengeId": "best-use-of-data", + "buildingId": "coc", + "sponsor": true + }, + { + "id": "room-klaus-3", + "challengeId": "best-hardware-hack", + "buildingId": "klaus", + "sponsor": true + } + ], + "tasks": [ + { + "entryId": "entry-01", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-02", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-03", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-03", + "challengeId": "best-use-of-data", + "sponsor": true + }, + { + "entryId": "entry-04", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-04", + "challengeId": "best-hardware-hack", + "sponsor": true + }, + { + "entryId": "entry-05", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-06", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-06", + "challengeId": "best-use-of-data", + "sponsor": true + }, + { + "entryId": "entry-07", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-08", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-08", + "challengeId": "best-hardware-hack", + "sponsor": true + }, + { + "entryId": "entry-09", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-09", + "challengeId": "best-use-of-data", + "sponsor": true + }, + { + "entryId": "entry-10", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-11", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-12", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-12", + "challengeId": "best-use-of-data", + "sponsor": true + }, + { + "entryId": "entry-12", + "challengeId": "best-hardware-hack", + "sponsor": true + }, + { + "entryId": "entry-13", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-14", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-15", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-15", + "challengeId": "best-use-of-data", + "sponsor": true + }, + { + "entryId": "entry-16", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-16", + "challengeId": "best-hardware-hack", + "sponsor": true + }, + { + "entryId": "entry-17", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-18", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-18", + "challengeId": "best-use-of-data", + "sponsor": true + }, + { + "entryId": "entry-19", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-20", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-20", + "challengeId": "best-hardware-hack", + "sponsor": true + }, + { + "entryId": "entry-21", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-21", + "challengeId": "best-use-of-data", + "sponsor": true + }, + { + "entryId": "entry-22", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-23", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-24", + "challengeId": "general", + "sponsor": false + }, + { + "entryId": "entry-24", + "challengeId": "best-use-of-data", + "sponsor": true + }, + { + "entryId": "entry-24", + "challengeId": "best-hardware-hack", + "sponsor": true + } + ] +} diff --git a/packages/api/scripts/judging-schedule/test_cpsat.py b/packages/api/scripts/judging-schedule/test_cpsat.py new file mode 100644 index 00000000..69b458b2 --- /dev/null +++ b/packages/api/scripts/judging-schedule/test_cpsat.py @@ -0,0 +1,494 @@ +"""Tests for cpsat.py. From this directory: python -I -m unittest + +The main check is against brute force: on problems small enough to enumerate +every schedule, the CP-SAT model must find the same best score in the same +goal order. The rest covers the command line (stdin, exit codes), hints that +must survive a timeout, and when the cross-building break may be relaxed. +""" + +from __future__ import annotations + +import contextlib +import io +import itertools +import json +import random +import subprocess +import sys +import unittest +from pathlib import Path +from unittest import mock + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +import cpsat # noqa: E402 + +try: + import ortools # noqa: F401 + + HAVE_ORTOOLS = True +except ImportError: + HAVE_ORTOOLS = False + +KEYS = ["sponsorFinish", "sponsorIdle", "finish", "buildingChanges", "breakSlots"] + + +def lexicographic(objective: dict[str, int]) -> tuple[int, ...]: + """rules.ts scoreSchedule order: lower is better, breaks count negative.""" + return tuple(-objective[k] if k == "breakSlots" else objective[k] for k in KEYS) + + +def small_problem(seed: int) -> dict: + """A random problem small enough to enumerate. Two identical general rooms + and two identical sponsor rooms make interchangeable placements, which + the model must not count as different scores.""" + rng = random.Random(seed) + rooms = [ + {"id": "g1", "challengeId": "general", "buildingId": "a", "sponsor": False}, + {"id": "g2", "challengeId": "general", "buildingId": "a", "sponsor": False}, + {"id": "g3", "challengeId": "general", "buildingId": "b", "sponsor": False}, + {"id": "s1", "challengeId": "acme", "buildingId": "a", "sponsor": True}, + {"id": "s2", "challengeId": "acme", "buildingId": "a", "sponsor": True}, + {"id": "s3", "challengeId": "globex", "buildingId": "b", "sponsor": True}, + ] + rng.shuffle(rooms) + tasks = [] + for p in range(rng.randint(3, 4)): + entry = f"p{p}" + tasks.append({"entryId": entry, "challengeId": "general", "sponsor": False}) + for challenge in ("acme", "globex"): + if rng.random() < 0.45 and len(tasks) < 6: + tasks.append({"entryId": entry, "challengeId": challenge, "sponsor": True}) + same_gap = rng.randint(0, 1) + return { + "format": cpsat.PROBLEM_FORMAT, + "version": cpsat.VERSION, + "fingerprint": f"small-{seed}", + "slotCount": rng.randint(5, 6), + "sameGap": same_gap, + "crossGap": same_gap + rng.randint(0, 2), + "rooms": rooms, + "tasks": tasks, + } + + +def brute_force(problem: cpsat.Problem, cross_gap: int): + """Best lexicographic score over every valid schedule, or None.""" + rooms, tasks = problem.rooms, problem.tasks + options = [ + [(r, s) for r, room in enumerate(rooms) if room.challenge_id == task.challenge_id + for s in range(problem.slot_count)] + for task in tasks + ] + placement: cpsat.Placement = {} + taken: set[tuple[int, int]] = set() + best = None + + def fits(i: int, r: int, s: int) -> bool: + if (r, s) in taken: + return False + # Pairwise is enough to prune: any two presentations of a team need + # at least the break between neighbours. + for k, (rk, sk) in placement.items(): + if tasks[k].entry_id != tasks[i].entry_id: + continue + gap = problem.same_gap if rooms[rk].building_id == rooms[r].building_id else cross_gap + if abs(sk - s) - 1 < gap: + return False + return True + + def place(i: int): + nonlocal best + if i == len(tasks): + assert not cpsat.violations(problem, placement, cross_gap) + key = lexicographic(cpsat.score(problem, placement)) + if best is None or key < best: + best = key + return + for r, s in options[i]: + if fits(i, r, s): + placement[i] = (r, s) + taken.add((r, s)) + place(i + 1) + del placement[i] + taken.discard((r, s)) + + place(0) + return best + + +def quiet(_message: str): + pass + + +@unittest.skipUnless(HAVE_ORTOOLS, "OR-Tools is not installed") +class SolveTest(unittest.TestCase): + def test_matches_brute_force(self): + for seed in range(12): + problem = cpsat.load_problem(small_problem(seed)) + with self.subTest(seed=seed): + expected = brute_force(problem, problem.cross_gap) + status, placement = cpsat.solve(problem, problem.cross_gap, 20, 1, quiet) + if expected is None: + self.assertEqual(status, "infeasible") + continue + self.assertEqual(status, "optimal") + self.assertEqual(cpsat.violations(problem, placement, problem.cross_gap), []) + self.assertEqual(lexicographic(cpsat.score(problem, placement)), expected) + + def test_repeatable_with_one_worker(self): + # Every goal here is proved optimal well inside the limit, which is + # what one worker needs to repeat a run exactly. + problem = cpsat.load_problem(small_problem(3)) + first = cpsat.solve(problem, problem.cross_gap, 20, 1, quiet) + second = cpsat.solve(problem, problem.cross_gap, 20, 1, quiet) + self.assertEqual(first, second) + + def test_keeps_a_good_hint(self): + raw = json.loads((HERE / "example-problem.json").read_text(encoding="utf-8")) + problem = cpsat.load_problem(raw) + _, hinted = cpsat.solve(problem, problem.cross_gap, 10, 8, quiet) + raw["hint"] = [ + { + "entryId": problem.tasks[i].entry_id, + "challengeId": problem.tasks[i].challenge_id, + "roomId": problem.rooms[r].id, + "slot": slot, + } + for i, (r, slot) in hinted.items() + ] + problem = cpsat.load_problem(raw) + _, placement = cpsat.solve(problem, problem.cross_gap, 3, 8, quiet) + self.assertEqual(cpsat.violations(problem, placement, problem.cross_gap), []) + self.assertLessEqual( + lexicographic(cpsat.score(problem, placement)), + lexicographic(cpsat.score(problem, hinted)), + ) + + def test_a_valid_hint_survives_running_out_of_time(self): + from ortools.sat.python import cp_model + + raw = small_problem(1) + problem = cpsat.load_problem(raw) + _, hinted = cpsat.solve(problem, problem.cross_gap, 20, 1, quiet) + raw["hint"] = [ + { + "entryId": problem.tasks[i].entry_id, + "challengeId": problem.tasks[i].challenge_id, + "roomId": problem.rooms[r].id, + "slot": slot, + } + for i, (r, slot) in hinted.items() + ] + problem = cpsat.load_problem(raw) + # Every solve runs out of time without finding anything. + with mock.patch.object(cp_model.CpSolver, "solve", return_value=cp_model.UNKNOWN): + status, placement = cpsat.solve(problem, problem.cross_gap, 1, 1, quiet) + self.assertEqual(status, "feasible") + self.assertEqual(placement, hinted) + + +def run_cli(problem: dict | str, *args: str): + text = problem if isinstance(problem, str) else json.dumps(problem) + return subprocess.run( + [sys.executable, "-I", str(HERE / "cpsat.py"), "-", *args], + input=text.encode("utf-8"), + capture_output=True, + timeout=120, + ) + + +@unittest.skipUnless(HAVE_ORTOOLS, "OR-Tools is not installed") +class CommandLineTest(unittest.TestCase): + def run_script(self, problem: dict | str, *args: str): + return run_cli(problem, *args) + + def test_reads_the_problem_from_stdin(self): + raw = small_problem(1) + done = self.run_script(raw, "--time-limit", "10", "--workers", "1") + self.assertEqual(done.returncode, 0, done.stderr.decode()) + solution = json.loads(done.stdout) + self.assertEqual(solution["format"], cpsat.SOLUTION_FORMAT) + self.assertEqual(solution["fingerprint"], raw["fingerprint"]) + self.assertEqual(len(solution["placements"]), len(raw["tasks"])) + self.assertEqual(list(solution["objective"]), KEYS) + + def test_relaxes_cross_building_breaks_when_it_must(self): + # One team, a room in each building, three slots: only a one-slot + # break fits, so the two-slot walk has to be relaxed. + raw = { + "format": cpsat.PROBLEM_FORMAT, "version": cpsat.VERSION, "fingerprint": "walk", + "slotCount": 3, "sameGap": 1, "crossGap": 2, + "rooms": [ + {"id": "r1", "challengeId": "c1", "buildingId": "a", "sponsor": False}, + {"id": "r2", "challengeId": "c2", "buildingId": "b", "sponsor": False}, + ], + "tasks": [ + {"entryId": "p", "challengeId": "c1", "sponsor": False}, + {"entryId": "p", "challengeId": "c2", "sponsor": False}, + ], + } + done = self.run_script(raw, "--time-limit", "5", "--workers", "1") + self.assertEqual(done.returncode, 0, done.stderr.decode()) + self.assertTrue(json.loads(done.stdout)["relaxed"]) + + def test_does_not_relax_after_a_timeout(self): + # Running out of time on the full breaks is not proof they cannot be + # kept, so the script stops there instead of shortening them. + tried = [] + + def out_of_time(problem, cross_gap, seconds, workers, log): + tried.append(cross_gap) + return ("unknown", None) + + raw = small_problem(1) + raw["crossGap"] = raw["sameGap"] + 1 + stdin = io.TextIOWrapper(io.BytesIO(json.dumps(raw).encode("utf-8"))) + out = io.StringIO() + with mock.patch.object(cpsat, "solve", out_of_time), mock.patch("sys.stdin", stdin), \ + contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()): + code = cpsat.main(["-"]) + self.assertEqual(code, 3) + self.assertEqual(tried, [raw["crossGap"]]) + solution = json.loads(out.getvalue()) + self.assertEqual(solution["status"], "unknown") + self.assertFalse(solution["relaxed"]) + + def test_exit_codes(self): + self.assertEqual(self.run_script("not json").returncode, 1) + raw = small_problem(1) + raw["slotCount"] = 1 + done = self.run_script(raw, "--time-limit", "5") + self.assertEqual(done.returncode, 2) + self.assertTrue(json.loads(done.stdout)["reasons"]) + + +JUDGE_KEYS = cpsat.JUDGE_GOALS + + +def judge_problem(seed: int) -> dict: + """small_problem with a finished schedule and judges: two table judges (one + with a short run limit) and a guest in one sponsor room.""" + rng = random.Random(seed * 7 + 1) + raw = small_problem(seed) + free = [(r["id"], s) for r in raw["rooms"] for s in range(raw["slotCount"])] + rng.shuffle(free) + placements, taken = [], set() + for task in raw["tasks"]: + room, slot = next( + (r, s) for r, s in free + if (r, s) not in taken + and next(x for x in raw["rooms"] if x["id"] == r)["challengeId"] == task["challengeId"] + ) + taken.add((room, slot)) + placements.append( + {"entryId": task["entryId"], "challengeId": task["challengeId"], "roomId": room, "slot": slot} + ) + everything = ["general", "acme", "globex"] + raw["placements"] = placements + raw["judges"] = [ + {"id": "m1", "kind": "member", "challengeIds": everything, "roomId": None}, + {"id": "m2", "kind": "member", "challengeIds": everything, "roomId": None, + "maxConsecutive": rng.choice([1, 2, 4])}, + {"id": "gs", "kind": "guest", "challengeIds": ["acme"], "roomId": "s1"}, + ] + return raw + + +def judge_key(score: dict) -> tuple[int, ...]: + """Lower is better, in goal order; the last goal counts what is covered.""" + return tuple(-score[k] if k == "dayPartsCovered" else score[k] for k in JUDGE_KEYS) + + +def brute_force_assign(problem: cpsat.Problem, placement: cpsat.Placement): + """Best goal vector over every assignment that keeps the judges' rules.""" + rooms, tasks, judges = problem.rooms, problem.tasks, problem.judges + options = [ + [None] + [k for k, judge in enumerate(judges) + if cpsat.may_grade(judge, tasks[i], rooms[placement[i][0]])] + for i in range(len(tasks)) + ] + best = None + for combo in itertools.product(*options): + assignment = dict(enumerate(combo)) + if cpsat.assignment_violations(problem, placement, assignment): + continue + key = judge_key(cpsat.assignment_score(problem, placement, assignment)) + if best is None or key < best: + best = key + return best + + +@unittest.skipUnless(HAVE_ORTOOLS, "OR-Tools is not installed") +class JudgeTest(unittest.TestCase): + def fixed(self, raw: dict) -> tuple[cpsat.Problem, cpsat.Placement]: + problem = cpsat.load_problem(raw) + index = {r.id: i for i, r in enumerate(problem.rooms)} + placement = { + i: (index[problem.fixed[t.entry_id, t.challenge_id][0]], + problem.fixed[t.entry_id, t.challenge_id][1]) + for i, t in enumerate(problem.tasks) + } + return problem, placement + + def test_assignment_matches_brute_force(self): + for seed in range(12): + problem, placement = self.fixed(judge_problem(seed)) + with self.subTest(seed=seed): + status, assignment = cpsat.assign(problem, placement, 20, 1, quiet) + self.assertEqual(status, "optimal") + self.assertEqual(cpsat.assignment_violations(problem, placement, assignment), []) + self.assertEqual( + judge_key(cpsat.assignment_score(problem, placement, assignment)), + brute_force_assign(problem, placement), + ) + + def test_judges_are_assigned_from_the_command_line(self): + raw = judge_problem(2) + done = run_cli(raw, "--time-limit", "10", "--workers", "1") + self.assertEqual(done.returncode, 0, done.stderr.decode()) + solution = json.loads(done.stdout) + self.assertEqual(solution["status"], solution["assignmentStatus"]) + self.assertEqual(solution["placements"], raw["placements"]) + self.assertEqual(list(solution["judging"])[:6], JUDGE_KEYS) + # One judge at most per presentation, and the rest counted as left out. + done_for = {(a["entryId"], a["challengeId"]) for a in solution["assignments"]} + self.assertEqual(len(done_for), len(solution["assignments"])) + self.assertEqual( + len(solution["assignments"]) + solution["judging"]["unassigned"], len(raw["tasks"]) + ) + + def test_locked_assignments_stay(self): + raw = judge_problem(4) + first = raw["tasks"][0] + raw["locked"] = [{"entryId": first["entryId"], "challengeId": first["challengeId"], "judgeId": "m2"}] + problem, placement = self.fixed(raw) + _, assignment = cpsat.assign(problem, placement, 20, 1, quiet) + self.assertEqual(problem.judges[assignment[0]].id, "m2") + + def test_a_valid_hint_survives_running_out_of_time(self): + from ortools.sat.python import cp_model + + raw = judge_problem(1) + problem, placement = self.fixed(raw) + _, assignment = cpsat.assign(problem, placement, 20, 1, quiet) + raw["assignmentHint"] = [ + { + "entryId": problem.tasks[i].entry_id, + "challengeId": problem.tasks[i].challenge_id, + "judgeId": problem.judges[k].id, + } + for i, k in assignment.items() + if k is not None + ] + problem, placement = self.fixed(raw) + # Every solve runs out of time without finding anything. + with mock.patch.object(cp_model.CpSolver, "solve", return_value=cp_model.UNKNOWN): + status, kept = cpsat.assign(problem, placement, 1, 1, quiet) + self.assertEqual(status, "feasible") + self.assertEqual(kept, assignment) + + def test_a_goal_cut_short_is_not_traded_away_by_the_next(self): + from ortools.sat.python import cp_model + + solve = cp_model.CpSolver.solve + calls = [] + + for seed in range(12): + raw = judge_problem(seed) + problem, placement = self.fixed(raw) + _, best = cpsat.assign(problem, placement, 20, 1, quiet) + raw["assignmentHint"] = [ + { + "entryId": problem.tasks[i].entry_id, + "challengeId": problem.tasks[i].challenge_id, + "judgeId": problem.judges[k].id, + } + for i, k in best.items() + if k is not None + ] + problem, placement = self.fixed(raw) + # The hint is the best there is. Whichever goal runs out of time, + # the goals after it must not make it worse to do better themselves. + for cut in (1, 2): + + def runs_out(solver, model, *args, **kwargs): + calls.append(1) + if len(calls) == cut: + return cp_model.UNKNOWN + return solve(solver, model, *args, **kwargs) + + calls.clear() + with mock.patch.object(cp_model.CpSolver, "solve", autospec=True, side_effect=runs_out): + _, assignment = cpsat.assign(problem, placement, 20, 1, quiet) + with self.subTest(seed=seed, cut=cut): + self.assertEqual( + judge_key(cpsat.assignment_score(problem, placement, assignment)), + judge_key(cpsat.assignment_score(problem, placement, best)), + ) + + def test_a_presentation_nobody_may_grade_is_left_out_not_failed(self): + raw = judge_problem(1) + for judge in raw["judges"]: + judge["challengeIds"] = ["general"] + problem, placement = self.fixed(raw) + status, assignment = cpsat.assign(problem, placement, 20, 1, quiet) + self.assertEqual(status, "optimal") + sponsor = [i for i, t in enumerate(problem.tasks) if t.challenge_id != "general"] + self.assertTrue(sponsor) + self.assertTrue(all(assignment[i] is None for i in sponsor)) + + def test_placement_keeps_a_challenge_within_its_judges(self): + # Three teams, each in general once, three tables, one judge for general. + # Without judges all three go at once; with one they must take turns. + raw = { + "format": cpsat.PROBLEM_FORMAT, "version": cpsat.VERSION, "fingerprint": "capacity", + "slotCount": 4, "sameGap": 0, "crossGap": 0, + "rooms": [{"id": f"g{n}", "challengeId": "general", "buildingId": "a", "sponsor": False} + for n in range(3)], + "tasks": [{"entryId": f"p{n}", "challengeId": "general", "sponsor": False} + for n in range(3)], + } + problem = cpsat.load_problem(raw) + _, together = cpsat.solve(problem, 0, 10, 1, quiet) + self.assertEqual({slot for _, slot in together.values()}, {0}) + self.assertEqual(list(cpsat.score(problem, together)), KEYS) + + raw["judges"] = [{"id": "m1", "kind": "member", "challengeIds": ["general"], "roomId": None}] + problem = cpsat.load_problem(raw) + _, turns = cpsat.solve(problem, 0, 10, 1, quiet) + self.assertEqual(sorted(slot for _, slot in turns.values()), [0, 1, 2]) + self.assertEqual(cpsat.score(problem, turns)["judgeShortfall"], 0) + + def test_too_few_judges_do_not_make_a_schedule_impossible(self): + raw = { + "format": cpsat.PROBLEM_FORMAT, "version": cpsat.VERSION, "fingerprint": "short", + "slotCount": 2, "sameGap": 0, "crossGap": 0, + "rooms": [{"id": f"g{n}", "challengeId": "general", "buildingId": "a", "sponsor": False} + for n in range(3)], + "tasks": [{"entryId": f"p{n}", "challengeId": "general", "sponsor": False} + for n in range(5)], + "judges": [{"id": "m1", "kind": "member", "challengeIds": ["general"], "roomId": None}], + } + problem = cpsat.load_problem(raw) + status, placement = cpsat.solve(problem, 0, 10, 1, quiet) + self.assertIn(status, ("optimal", "feasible")) + # Five presentations in two slots: at best three and two at a time, so + # the worst slot has two more than the one judge. + self.assertEqual(cpsat.score(problem, placement)["judgeShortfall"], 2) + + def test_rejects_bad_judges_and_placements(self): + raw = judge_problem(0) + with self.assertRaises(cpsat.InputError): + cpsat.load_problem({**raw, "judges": [{**raw["judges"][0], "kind": "robot"}]}) + with self.assertRaises(cpsat.InputError): + cpsat.load_problem({**raw, "judges": [raw["judges"][0], raw["judges"][0]]}) + with self.assertRaises(cpsat.InputError): + cpsat.load_problem({**raw, "placements": raw["placements"][:-1]}) + with self.assertRaises(cpsat.InputError): + cpsat.load_problem({k: v for k, v in raw.items() if k != "judges"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/api/src/.internal-tests/_db-tx-mock.ts b/packages/api/src/.internal-tests/_db-tx-mock.ts index 9af27af6..804f78dc 100644 --- a/packages/api/src/.internal-tests/_db-tx-mock.ts +++ b/packages/api/src/.internal-tests/_db-tx-mock.ts @@ -147,6 +147,14 @@ export const createTransactionMock = ({ if (select) { tx.select = (...selectArgs: any[]) => ({ from: (...fromArgs: any[]) => ({ + // A join is a plain read, never a locking one; the hook gets the + // joined table as a fifth argument. + innerJoin: (...joinArgs: any[]) => ({ + where: (...wArgs: any[]) => + Promise.resolve( + select("select", selectArgs, fromArgs, wArgs, joinArgs), + ), + }), // Lazy: `.for("update")` must be the thing that takes the lock, and // eagerly building the unlocked promise would run the query twice. where: (...wArgs: any[]) => { diff --git a/packages/api/src/.internal-tests/announcements.test.ts b/packages/api/src/.internal-tests/announcements.test.ts index bf41e4d0..39df9907 100644 --- a/packages/api/src/.internal-tests/announcements.test.ts +++ b/packages/api/src/.internal-tests/announcements.test.ts @@ -19,6 +19,9 @@ const mockInsert = vi.fn(); const mockUpdate = vi.fn(); const mockSelectRows = vi.fn(() => [] as unknown[]); const mockSendAnnouncement = vi.fn(); +// Every condition handed to a select (join and where), so a test can see which +// user and hackathon a query was scoped to even though the rows are canned. +const mockSelectConditions = vi.fn(); vi.mock("@query/auth/email", () => ({ sendAnnouncementEmail: (...args: unknown[]) => mockSendAnnouncement(...args), @@ -28,9 +31,15 @@ vi.mock("@query/db", () => { const selectChain = () => { const node: any = { from: () => node, - innerJoin: () => node, + innerJoin: (...args: unknown[]) => { + mockSelectConditions(...args); + return node; + }, leftJoin: () => node, - where: () => node, + where: (...args: unknown[]) => { + mockSelectConditions(...args); + return node; + }, groupBy: () => node, orderBy: () => node, limit: () => Promise.resolve(mockSelectRows()), @@ -125,6 +134,10 @@ vi.mock("@query/db", () => { id: "id", hackathonId: "hackathon_id", subject: "subject", + heading: "heading", + body: "body", + ctaLabel: "cta_label", + ctaUrl: "cta_url", audience: "audience", createdAt: "created_at", }, @@ -135,17 +148,31 @@ vi.mock("@query/db", () => { email: "email", sentAt: "sent_at", failedAt: "failed_at", + claimedAt: "claimed_at", }, }; }); import { db } from "@query/db"; +import { PgDialect } from "drizzle-orm/pg-core"; const HACK = "33333333-3333-4333-8333-333333333333"; +const OTHER_HACK = "55555555-5555-4555-8555-555555555555"; const ANNOUNCEMENT = "44444444-4444-4444-8444-444444444444"; const ADMIN = "user_admin"; const VISITOR = "user_visitor"; +// The bound values of every SQL condition a query was built with, for asserting +// what it was scoped to. +const boundValues = (calls: unknown[][]) => + calls + .flat() + .filter( + (arg): arg is object => + !!arg && typeof arg === "object" && "queryChunks" in arg, + ) + .flatMap((condition) => new PgDialect().sqlToQuery(condition as never).params); + const callerFor = (userId?: string) => appRouter.createCaller({ db, @@ -178,6 +205,7 @@ describe("Announcements", () => { mockInsert.mockReset().mockReturnValue([{ id: ANNOUNCEMENT }]); mockUpdate.mockReset().mockReturnValue([]); mockSelectRows.mockReset().mockReturnValue([]); + mockSelectConditions.mockReset(); mockSendAnnouncement.mockReset().mockResolvedValue(undefined); cache.clear(); }); @@ -231,6 +259,24 @@ describe("Announcements", () => { ).rejects.toMatchObject({ code: "BAD_REQUEST" }); }); + // The button is also a link on hackers' event pages, which only link a web + // address; `.url()` alone passes these. + it.each(["mailto:team@example.com", "data:text/html,hello"])( + "refuses a button link that is not a web address (%s)", + async (ctaUrl) => { + asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); + + await expect( + callerFor(ADMIN).hackathon.createAnnouncement({ + ...compose, + ctaLabel: "Open", + ctaUrl, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mockInsert).not.toHaveBeenCalled(); + }, + ); + it("refuses an audience with nobody in it", async () => { asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); mockSelectRows.mockReturnValue([]); @@ -273,13 +319,41 @@ describe("Announcements", () => { expect(announcementRow.audience).toBe("not_accepted"); }); + /** + * Waitlisted and rejected people need different messages — "no place for + * you" is wrong for someone still in line — so each is selectable on its + * own, not only through the combined not_accepted audience. + */ + it.each(["waitlisted", "rejected"] as const)( + "accepts the %s audience on its own", + async (audience) => { + asAdmin((table) => + table === "hackathons" ? { id: HACK } : undefined, + ); + mockSelectRows.mockReturnValue([ + { userId: "u1", email: "applicant@example.com" }, + ]); + + const res = await callerFor(ADMIN).hackathon.createAnnouncement({ + ...compose, + audience, + }); + + expect(res.totalRecipients).toBe(1); + const announcementRow = mockInsert.mock.calls + .map((c) => c[2]?.[0]) + .find((row) => row && !Array.isArray(row) && "audience" in row); + expect(announcementRow.audience).toBe(audience); + }, + ); + it("refuses an audience name that does not exist", async () => { asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); await expect( callerFor(ADMIN).hackathon.createAnnouncement({ ...compose, - // @ts-expect-error deliberately not one of the five audiences + // @ts-expect-error deliberately not one of the seven audiences audience: "everyone", }), ).rejects.toBeDefined(); @@ -394,4 +468,309 @@ describe("Announcements", () => { ).rejects.toMatchObject({ code: "NOT_FOUND" }); }); }); + + describe("In app only", () => { + const recipientRowsOf = () => + mockInsert.mock.calls + .map((c) => c[2]?.[0]) + .find((rows) => Array.isArray(rows)) as Record[]; + + /** + * Recipient rows without `sentAt` are an unfinished send: the tab offers + * "Resume sending" and sendBatch would email them. An in-app announcement + * must be born delivered, or one click mails the whole audience. + */ + it("writes recipients already delivered and sends nothing", async () => { + asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); + mockSelectRows.mockReturnValue([ + { userId: "u1", email: "ada@example.com" }, + { userId: "u2", email: "grace@example.com" }, + ]); + + const res = await callerFor(ADMIN).hackathon.createAnnouncement({ + ...compose, + inAppOnly: true, + }); + + expect(res).toMatchObject({ totalRecipients: 2, inAppOnly: true }); + expect(mockSendAnnouncement).not.toHaveBeenCalled(); + expect(recipientRowsOf()).toHaveLength(2); + for (const row of recipientRowsOf()) { + expect(row.sentAt).toBeInstanceOf(Date); + // Every emailed row was claimed first, so an unclaimed delivered row + // is how an in-app one is told apart. + expect(row).not.toHaveProperty("claimedAt"); + } + }); + + it("leaves recipients pending for a normal announcement", async () => { + asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); + mockSelectRows.mockReturnValue([ + { userId: "u1", email: "ada@example.com" }, + ]); + + const res = await callerFor(ADMIN).hackathon.createAnnouncement(compose); + + expect(res.inAppOnly).toBe(false); + expect(recipientRowsOf()[0]).not.toHaveProperty("sentAt"); + }); + + /** + * The counts are SQL aggregates, so the rows here are canned as the query + * would return them. What this pins is the router's side: an all-stamped, + * never-claimed announcement is reported as in-app with nothing pending, + * and `pending` is taken from the query rather than derived from + * total - sent - failed, which would read 2 here and put it under + * "Unfinished sends". + */ + it("lists an in-app announcement as published, not as an unfinished send", async () => { + asAdmin(); + mockSelectRows.mockReturnValue([ + { + id: ANNOUNCEMENT, + subject: "Registration is open", + audience: "interested", + createdAt: new Date("2026-02-01T12:00:00Z"), + total: 2, + sent: 0, + failed: 0, + pending: 0, + inAppOnly: true, + }, + ]); + + const [first] = await callerFor(ADMIN).hackathon.listAnnouncements({ + hackathonId: HACK, + }); + + expect(first).toMatchObject({ + total: 2, + sent: 0, + failed: 0, + pending: 0, + inAppOnly: true, + }); + }); + }); + + /** + * The hacker's own view of what organisers sent them. The audience was frozen + * into recipient rows at compose time; this reads those rows and never + * recomputes who should have received what. The rows themselves are canned + * here, so what is asserted is what the query was scoped to. + */ + describe("Announcements shown to a hacker", () => { + const row = { + subject: "Registration is open", + heading: "Registration is open", + body: "Applications close soon.", + ctaLabel: "Apply now", + ctaUrl: "https://datasciencegt.org/hacklytics", + createdAt: new Date("2026-02-01T12:00:00Z"), + deliveredAt: new Date("2026-02-01T12:05:00Z"), + }; + + it("is scoped to the caller and the requested hackathon", async () => { + mockSelectRows.mockReturnValue([row]); + + await callerFor(VISITOR).hackathon.listForMe({ hackathonId: HACK }); + + const bound = boundValues(mockSelectConditions.mock.calls); + expect(bound).toContain(VISITOR); + expect(bound).toContain(HACK); + expect(bound).not.toContain(ADMIN); + }); + + // Another edition's announcements must not surface on this event's page. + it("asks only for the hackathon it was given", async () => { + await callerFor(VISITOR).hackathon.listForMe({ hackathonId: OTHER_HACK }); + + const bound = boundValues(mockSelectConditions.mock.calls); + expect(bound).toContain(OTHER_HACK); + expect(bound).not.toContain(HACK); + }); + + it("returns the message and nothing about the send", async () => { + mockSelectRows.mockReturnValue([row]); + + const res = await callerFor(VISITOR).hackathon.listForMe({ + hackathonId: HACK, + }); + + expect(res).toEqual([ + { + subject: "Registration is open", + heading: "Registration is open", + body: "Applications close soon.", + cta: { + label: "Apply now", + url: "https://datasciencegt.org/hacklytics", + }, + sentAt: row.deliveredAt, + }, + ]); + }); + + it("has no button when the announcement had none, and dates from composing when not yet emailed", async () => { + mockSelectRows.mockReturnValue([ + { ...row, ctaLabel: null, ctaUrl: null, deliveredAt: null }, + ]); + + const [first] = await callerFor(VISITOR).hackathon.listForMe({ + hackathonId: HACK, + }); + + expect(first?.cta).toBeNull(); + expect(first?.sentAt).toEqual(row.createdAt); + }); + + it("is empty when nothing was addressed to the caller", async () => { + const res = await callerFor(VISITOR).hackathon.listForMe({ + hackathonId: HACK, + }); + + expect(res).toEqual([]); + }); + + it("is refused to a signed-out caller", async () => { + await expect( + callerFor().hackathon.listForMe({ hackathonId: HACK }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + }); + + /** + * "Send test to me" puts the composed message in the organiser's own inbox. + * It must reach nobody else and leave no trace: a recipient row would be a + * pending send that "Resume sending" then mails to someone. + */ + describe("Test send", () => { + const message = { + subject: "Registration is open", + heading: "Registration is open", + body: "Applications close soon.", + }; + + // The limiter is per user and lives for the process, so each test signs in + // as somebody new. + let organiser = 0; + const nextOrganiser = () => `user_organiser_${++organiser}`; + + const asOrganiser = (email: string | null = "organiser@example.com") => + asAdmin((table) => (table === "users" ? { email } : undefined)); + + it("sends to the caller's own address only, through the real send function", async () => { + asOrganiser(); + const caller = nextOrganiser(); + + const res = await callerFor(caller).hackathon.sendTestToMe({ + ...message, + ctaLabel: "Apply now", + ctaUrl: "https://datasciencegt.org/hacklytics", + }); + + expect(res).toEqual({ sentTo: "organiser@example.com" }); + expect(mockSendAnnouncement).toHaveBeenCalledTimes(1); + expect(mockSendAnnouncement).toHaveBeenCalledWith({ + email: "organiser@example.com", + subject: "[Test] Registration is open", + heading: "Registration is open", + body: "Applications close soon.", + ctaLabel: "Apply now", + ctaUrl: "https://datasciencegt.org/hacklytics", + }); + + // The address is looked up for the caller, not taken from the request. + const lookup = mockFindFirst.mock.calls.find(([t]) => t === "users"); + expect(boundValues([[lookup?.[1]?.where]])).toContain(caller); + }); + + it("writes no announcement and no recipient rows", async () => { + asOrganiser(); + + await callerFor(nextOrganiser()).hackathon.sendTestToMe(message); + + expect(mockInsert).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockSelectConditions).not.toHaveBeenCalled(); + }); + + // The input has no place for an address; an extra key must not become one. + it("ignores an address supplied in the request", async () => { + asOrganiser(); + + await callerFor(nextOrganiser()).hackathon.sendTestToMe({ + ...message, + email: "victim@example.com", + } as never); + + expect(mockSendAnnouncement).toHaveBeenCalledTimes(1); + expect(mockSendAnnouncement.mock.calls[0]![0].email).toBe( + "organiser@example.com", + ); + }); + + it("refuses half a call-to-action, as the real send does", async () => { + asOrganiser(); + + await expect( + callerFor(nextOrganiser()).hackathon.sendTestToMe({ + ...message, + ctaLabel: "Apply now", + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mockSendAnnouncement).not.toHaveBeenCalled(); + }); + + it("is refused to a caller who is not an admin", async () => { + mockFindFirst.mockImplementation(() => undefined); + + await expect( + callerFor(VISITOR).hackathon.sendTestToMe(message), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(mockSendAnnouncement).not.toHaveBeenCalled(); + }); + + it("refuses when the account has no email address", async () => { + asOrganiser(null); + + await expect( + callerFor(nextOrganiser()).hackathon.sendTestToMe(message), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mockSendAnnouncement).not.toHaveBeenCalled(); + }); + + it("reports a failed send without writing anything", async () => { + asOrganiser(); + mockSendAnnouncement.mockRejectedValueOnce(new Error("550 rejected")); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + callerFor(nextOrganiser()).hackathon.sendTestToMe(message), + ).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR" }); + + logged.mockRestore(); + expect(mockInsert).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + // Three in a burst, then one every ten seconds: each is a real SMTP message. + it("rate limits one organiser who keeps sending", async () => { + asOrganiser(); + const caller = nextOrganiser(); + + for (let i = 0; i < 3; i++) { + await callerFor(caller).hackathon.sendTestToMe(message); + } + + await expect( + callerFor(caller).hackathon.sendTestToMe(message), + ).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" }); + expect(mockSendAnnouncement).toHaveBeenCalledTimes(3); + + // Somebody else is not held up by it. + await callerFor(nextOrganiser()).hackathon.sendTestToMe(message); + expect(mockSendAnnouncement).toHaveBeenCalledTimes(4); + }); + }); }); diff --git a/packages/api/src/.internal-tests/assigned-judge.test.ts b/packages/api/src/.internal-tests/assigned-judge.test.ts new file mode 100644 index 00000000..c81910c7 --- /dev/null +++ b/packages/api/src/.internal-tests/assigned-judge.test.ts @@ -0,0 +1,1139 @@ +/** + * A presentation assigned to a judge at the judge desk: the assigned judge + * has it to themselves until TAKEOVER_AFTER_MS after it starts, then any judge + * who may grade it there can take it unless the assigned judge has started. + * Also what the lists and the sheet say about it, the next project a judge is + * sent to, the judge's itinerary, giving a hold back (releaseGrading), and the + * scores the desk shows. + * + * The router runs for real against a stand-in database that answers each select + * by the tables it reads and keeps every statement in order (see + * graded-once.test.ts, whose stand-in this follows). + */ +import { describe, it, expect } from "vitest"; +import { getTableName } from "drizzle-orm"; +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { + judgingAppointments, + judgingBuildings, + judgingChallenges, + judgingConfigs, + judgingEntries, + judgingEntryChallenges, + judgingEntryMembers, + judgingEvaluationDrafts, + judgingEvaluationRatings, + judgingEvaluations, + judgingJudges, + judges, + judgingPresence, + judgingRooms, + judgingRubricItems, + judgingSchedules, +} from "@query/db"; +import { judgingDeskRouter } from "../routers/judging/desk"; +import { reconcileDueDrafts } from "../services/judging/evaluation-store"; +import { GRADING_HOLD_MS } from "../services/judging/grading-hold"; +import { + DEADLINE_GRACE_MS, + TAKEOVER_AFTER_MS, +} from "../services/judging/timing"; + +const HACK = "6f1d2c3b-4a5e-4f60-8a7b-9c0d1e2f3a4b"; +const GENERAL = "0c4a8e10-2b3d-4c5e-9f60-7a8b9c0d1e2f"; +const SPONSOR = "0c4a8e11-2b3d-4c5e-9f60-7a8b9c0d1e2f"; +const ME = "1d5b9f21-3c4e-4d6f-8a70-8b9c0d1e2f3a"; +const OTHER = "1d5b9f22-3c4e-4d6f-8a70-8b9c0d1e2f3a"; +const ROOM = "2e6ca032-4d5f-4e70-9b81-9c0d1e2f3a4b"; +const ELSEWHERE = "2e6ca033-4d5f-4e70-9b81-9c0d1e2f3a4b"; +const RATING = "3f7db143-5e60-4f81-8c92-0d1e2f3a4b5c"; +const RATING_2 = "3f7db144-5e60-4f81-8c92-0d1e2f3a4b5c"; + +const E_ONE = "40e8c254-6f71-4092-9da3-1e2f3a4b5c01"; +const E_TWO = "40e8c254-6f71-4092-9da3-1e2f3a4b5c02"; +const E_THREE = "40e8c254-6f71-4092-9da3-1e2f3a4b5c03"; + +const challenge = ( + id: string, + label: string, + mode: "scheduled" | "unscheduled" | "remote", +) => ({ + id, + parentId: null, + mode, + label, + isGeneral: id === GENERAL, + isGroup: false, +}); +const general = challenge(GENERAL, "General", "unscheduled"); +const sponsor = challenge(SPONSOR, "Best Use of AI", "scheduled"); + +const entry = (id: string, title: string) => ({ + id, + title, + description: "", + submissionUrl: null, + demoLinks: [], + videoUrl: null, + technologies: [], + participantCount: 2, +}); + +const name = (table: unknown) => getTableName(table as never); +const via = (...tables: unknown[]) => tables.map(name).join(">"); + +type Rows = unknown[] | ((call: number) => unknown[]); +type Answers = Record; +type Event = { + kind: "select" | "execute" | "insert" | "update" | "delete"; + table?: string; + where?: SQL; + values?: Record; + /** A select that takes row locks. */ + locking?: boolean; +}; + +/** The nth read of a table gets the nth list. */ +const byCall = + (...lists: unknown[][]) => + (call: number) => + lists[call] ?? []; + +function fakeDb(answers: Answers) { + const events: Event[] = []; + const reads = new Map(); + const rowsFor = (key: string) => { + const call = reads.get(key) ?? 0; + reads.set(key, call + 1); + const answer = answers[key] ?? []; + return typeof answer === "function" ? answer(call) : answer; + }; + + const select = () => { + const joined: unknown[] = []; + let from: unknown; + let where: SQL | undefined; + const event: Event = { kind: "select" }; + const chain: Record = { + from: (table: unknown) => { + from = table; + return chain; + }, + where: (clause: SQL) => { + where = clause; + return chain; + }, + for: () => { + event.locking = true; + return chain; + }, + then: ( + ok: (rows: unknown[]) => unknown, + fail: (error: unknown) => unknown, + ) => { + const key = via(from, ...joined); + events.push({ ...event, table: key, where }); + return Promise.resolve(rowsFor(key)).then(ok, fail); + }, + }; + for (const join of ["innerJoin", "leftJoin"]) { + chain[join] = (table: unknown) => { + joined.push(table); + return chain; + }; + } + for (const method of ["orderBy", "limit"]) { + chain[method] = () => chain; + } + return chain; + }; + const db: Record = { + select, + execute: () => { + events.push({ kind: "execute" }); + return Promise.resolve(); + }, + insert: (table: unknown) => ({ + values: (values: Record) => { + events.push({ kind: "insert", table: via(table), values }); + const done = Promise.resolve([{ id: "new" }]); + return Object.assign(done, { returning: () => done }); + }, + }), + update: (table: unknown) => ({ + set: (values: Record) => ({ + where: (where: SQL) => { + events.push({ kind: "update", table: via(table), where, values }); + return Promise.resolve(); + }, + }), + }), + delete: (table: unknown) => ({ + where: (where: SQL) => { + events.push({ kind: "delete", table: via(table), where }); + return Promise.resolve(); + }, + }), + transaction: (run: (tx: unknown) => Promise) => run(db), + }; + return { db, events }; +} + +const rating = { + id: RATING, + kind: "rating", + label: "Impact", + description: "", + required: true, + guestVisibility: null, +}; + +const evaluations = name(judgingEvaluations); +const drafts = name(judgingEvaluationDrafts); +const entriesKey = via(judgingEntries, judgingEntryChallenges); +const presenceKey = via(judgingPresence, judgingRooms); +const slotsKey = via(judgingAppointments); +const listedSlotsKey = via(judgingAppointments, judgingRooms, judgingBuildings); + +const seatAt = (challengeId: string, roomId = ROOM) => [ + { roomId, roomName: "Table 4", challengeId }, +]; + +/** A table judge seated at the sponsor's room, with a schedule saved. */ +const world = (over: Answers = {}): Answers => ({ + [via(judges)]: [{ name: "Grace Hopper" }], + [via(judgingJudges)]: [{ id: ME, displayName: "Grace Hopper" }], + [via(judgingChallenges)]: [general, sponsor], + [via(judgingConfigs)]: [ + { state: "open", showAllScores: false, closedAt: null }, + ], + [via(judgingRubricItems)]: [rating], + [entriesKey]: [entry(E_ONE, "Tidewater")], + [via(judgingSchedules)]: [{ id: "schedule" }], + [presenceKey]: seatAt(SPONSOR), + ...over, +}); + +// A new user for each context: the desk's flood guard counts calls per user. +let callers = 0; +const memberCtx = (db: unknown) => { + const userId = `assigned-user-${(callers += 1)}`; + return { + db, + session: { user: { id: userId, name: "Grace Hopper" } }, + userId, + clientIp: "127.0.0.1", + req: { headers: { get: () => null } }, + } as never; +}; + +const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60_000); +const minutesAhead = (minutes: number) => + new Date(Date.now() + minutes * 60_000); +const takeoverMinutes = TAKEOVER_AFTER_MS / 60_000; + +/** A presentation for E_ONE that started `startedMinutesAgo` minutes ago. */ +const slot = ( + assignedJudgeId: string | null, + startedMinutesAgo: number, + over: Record = {}, +) => ({ + id: "slot-1", + entryId: E_ONE, + roomId: ROOM, + startsAt: minutesAgo(startedMinutesAgo), + deadlineAt: minutesAhead(10 - startedMinutesAgo), + endsAt: minutesAhead(12 - startedMinutesAgo), + assignedJudgeId, + ...over, +}); + +const reservedRefusal = + "Another judge is assigned to this presentation. If they have not started 5 minutes in, you can take it."; +const beingGraded = + "Another judge is grading this project right now. Pick another one."; +const gradedElsewhere = + "Another judge has already graded this project for Best Use of AI. Each project is graded once."; + +const wrote = (events: Event[], table: unknown) => + events.some((e) => e.kind === "insert" && e.table === name(table)); + +const save = { + hackathonId: HACK, + entryId: E_ONE, + challengeId: SPONSOR, + ratings: { [RATING]: 4 }, + responses: {}, +}; + +const grade = (answers: Answers) => { + const fake = fakeDb(answers); + const caller = judgingDeskRouter.createCaller(memberCtx(fake.db)); + return { + ...fake, + caller, + save: () => caller.saveDraft({ ...save, revision: 0 }), + start: () => + caller.startGrading({ + hackathonId: HACK, + entryId: E_ONE, + challengeId: SPONSOR, + }), + submit: () => caller.submit({ ...save, expectedRevision: 0 }), + }; +}; + +describe("claiming a presentation assigned to a judge", () => { + it("lets the assigned judge claim it as it starts", async () => { + const { save: go, events } = grade(world({ [slotsKey]: [slot(ME, 0)] })); + + await expect(go()).resolves.toMatchObject({ revision: 1 }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(true); + }); + + it("keeps another judge off until the takeover time, on every way in", async () => { + const answers = world({ [slotsKey]: [slot(OTHER, takeoverMinutes - 1)] }); + const refused = { + code: "PRECONDITION_FAILED", + message: reservedRefusal, + }; + + for (const way of ["save", "start", "submit"] as const) { + const attempt = grade(answers); + await expect(attempt[way]()).rejects.toMatchObject(refused); + expect(wrote(attempt.events, judgingEvaluationDrafts)).toBe(false); + expect(wrote(attempt.events, judgingEvaluations)).toBe(false); + } + }); + + it("refuses another judge from the very start of the presentation", async () => { + const { save: go } = grade(world({ [slotsKey]: [slot(OTHER, 0)] })); + + await expect(go()).rejects.toMatchObject({ message: reservedRefusal }); + }); + + it("reads the assignment under the grading lock, after the judge's own draft", async () => { + // Two judges reaching for it at the takeover time queue on the lock, and + // the second then sees the first one's draft. + for (const way of ["save", "start", "submit"] as const) { + const attempt = grade(world({ [slotsKey]: [slot(OTHER, 1)] })); + await expect(attempt[way]()).rejects.toMatchObject({ + message: reservedRefusal, + }); + + const at = (match: (e: Event) => boolean) => + attempt.events.findIndex(match); + const ownDraft = at( + (e) => e.kind === "select" && e.table === drafts && !!e.locking, + ); + const lock = at((e) => e.kind === "execute"); + const assignment = at((e) => e.table === slotsKey); + expect(ownDraft).toBeGreaterThan(-1); + expect(lock).toBeGreaterThan(ownDraft); + expect(assignment).toBeGreaterThan(lock); + } + }); + + it("lets another judge take it once the takeover time has passed", async () => { + const answers = world({ [slotsKey]: [slot(OTHER, takeoverMinutes + 1)] }); + + for (const way of ["save", "start", "submit"] as const) { + const attempt = grade(answers); + await expect(attempt[way]()).resolves.toBeDefined(); + } + const { save: go, events } = grade(answers); + await go(); + expect(wrote(events, judgingEvaluationDrafts)).toBe(true); + }); + + it("refuses a takeover when the assigned judge already holds a draft", async () => { + const answers = (draft: Record) => + world({ + [slotsKey]: [slot(OTHER, takeoverMinutes + 1)], + [drafts]: byCall([], [{ judgeId: OTHER, ...draft }]), + }); + // Opened with nothing saved holds until the deadline, as a saved one does. + const held = [ + { updatedAt: minutesAgo(takeoverMinutes), deadlineAt: minutesAhead(4) }, + { updatedAt: minutesAgo(1), deadlineAt: null }, + ]; + + for (const draft of held) { + for (const way of ["save", "start", "submit"] as const) { + const attempt = grade(answers(draft)); + await expect(attempt[way]()).rejects.toMatchObject({ + code: "CONFLICT", + message: beingGraded, + }); + expect(wrote(attempt.events, judgingEvaluationDrafts)).toBe(false); + } + } + }); + + it("refuses a takeover when the assigned judge already graded it", async () => { + const { save: go, events } = grade( + world({ + [slotsKey]: [slot(OTHER, takeoverMinutes + 1)], + [evaluations]: byCall([], [{ judgeId: OTHER }]), + }), + ); + + await expect(go()).rejects.toMatchObject({ + code: "CONFLICT", + message: gradedElsewhere, + }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(false); + }); + + it("leaves a presentation nobody is assigned to open from the start", async () => { + const { save: go, events } = grade(world({ [slotsKey]: [slot(null, 1)] })); + + await expect(go()).resolves.toMatchObject({ revision: 1 }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(true); + }); + + it("leaves a judge editing their own evaluation alone, whoever was assigned", async () => { + const { save: go } = grade( + world({ + [slotsKey]: [slot(OTHER, 1)], + [evaluations]: byCall([{ appointmentId: "slot-1" }]), + }), + ); + + await expect(go()).resolves.toMatchObject({ revision: 1 }); + }); + + it("does not care who is assigned at a challenge that is not timed", async () => { + const { save: go } = grade( + world({ + [via(judgingChallenges)]: [ + general, + { ...sponsor, mode: "unscheduled" }, + ], + [slotsKey]: [slot(OTHER, 1)], + }), + ); + + await expect(go()).resolves.toMatchObject({ revision: 1 }); + }); +}); + +describe("a first save in the write grace window", () => { + // The deadline passed five seconds ago, inside DEADLINE_GRACE_MS, so a + // write still lands. The draft has to carry the deadline, or nothing ever + // submits it for the judge. + const late = () => { + const deadlineAt = new Date(Date.now() - 5_000); + return { + deadlineAt, + answers: world({ [slotsKey]: [slot(ME, 8, { deadlineAt })] }), + }; + }; + const draftWritten = (events: Event[]) => + events.find((e) => e.kind === "insert" && e.table === drafts)?.values; + + it("stamps the presentation's deadline on the draft", async () => { + const { deadlineAt, answers } = late(); + const { save: go, events } = grade(answers); + + await expect(go()).resolves.toMatchObject({ revision: 1, deadlineAt }); + expect(draftWritten(events)).toMatchObject({ + appointmentId: "slot-1", + deadlineAt, + }); + }); + + it("stamps it when the judge opens the project", async () => { + const { deadlineAt, answers } = late(); + const { start, events } = grade(answers); + + await expect(start()).resolves.toMatchObject({ held: true, deadlineAt }); + expect(draftWritten(events)).toMatchObject({ deadlineAt }); + }); + + it("is submitted for the judge as of the deadline", async () => { + const { deadlineAt, answers } = late(); + const { save: go, events } = grade(answers); + await go(); + const saved = draftWritten(events); + expect(saved?.deadlineAt).toEqual(deadlineAt); + + const later = fakeDb( + world({ + [via(judgingEvaluationDrafts, judgingConfigs)]: [ + { id: "draft-1", ...saved }, + ], + [via(judgingJudges)]: [{ id: ME, kind: "member" }], + }), + ); + await reconcileDueDrafts( + later.db as never, + HACK, + new Date(deadlineAt.getTime() + DEADLINE_GRACE_MS), + ); + + const evaluation = later.events.find( + (e) => e.kind === "insert" && e.table === evaluations, + ); + expect(evaluation?.values).toMatchObject({ + judgeId: ME, + appointmentId: "slot-1", + submittedAt: deadlineAt, + autoSubmittedAt: deadlineAt, + }); + expect( + later.events.some((e) => e.kind === "delete" && e.table === drafts), + ).toBe(true); + }); + + it("still never times an edit of a submitted evaluation", async () => { + const { answers } = late(); + const { save: go, events } = grade({ + ...answers, + [evaluations]: byCall([{ appointmentId: "slot-1" }]), + }); + + await expect(go()).resolves.toMatchObject({ + revision: 1, + deadlineAt: null, + }); + expect(draftWritten(events)).toMatchObject({ deadlineAt: null }); + }); +}); + +describe("desk.project for a presentation assigned to a judge", () => { + const open = async (answers: Answers) => { + const { db } = fakeDb({ + [via(judgingEntryMembers)]: [{ name: "Ada Lovelace" }], + ...answers, + }); + return judgingDeskRouter.createCaller(memberCtx(db)).project({ + hackathonId: HACK, + entryId: E_ONE, + challengeId: SPONSOR, + }); + }; + + it("is reserved for another judge, with when it opens and no name", async () => { + const startsAt = minutesAgo(2); + const sheet = await open( + world({ [slotsKey]: [slot(OTHER, 2, { startsAt })] }), + ); + + expect(sheet.grading).toBe("reserved"); + expect(sheet.access).toMatchObject({ + canEdit: false, + reason: reservedRefusal, + gradedElsewhere: false, + needsRoom: false, + ended: false, + deadlineAt: null, + }); + expect(sheet.access.opensAt).toEqual( + new Date(startsAt.getTime() + TAKEOVER_AFTER_MS), + ); + // Still there to read. + expect(sheet.entry.title).toBe("Tidewater"); + expect(JSON.stringify(sheet)).not.toContain(OTHER); + }); + + it("is open once the takeover time has passed", async () => { + const sheet = await open( + world({ [slotsKey]: [slot(OTHER, takeoverMinutes + 1)] }), + ); + + expect(sheet.grading).toBe("open"); + expect(sheet.access).toMatchObject({ canEdit: true, reason: null }); + expect(JSON.stringify(sheet)).not.toContain(OTHER); + }); + + it("is being graded, not reserved, once the assigned judge has started", async () => { + const sheet = await open( + world({ + [slotsKey]: [slot(OTHER, 2)], + [drafts]: byCall( + [], + [{ judgeId: OTHER, updatedAt: minutesAgo(1), deadlineAt: null }], + ), + }), + ); + + expect(sheet.grading).toBe("in_progress"); + expect(sheet.access).toMatchObject({ + canEdit: false, + reason: beingGraded, + gradedElsewhere: true, + }); + expect(JSON.stringify(sheet)).not.toContain(OTHER); + }); + + it("shows the assigned judge nothing new: it is theirs to write", async () => { + const sheet = await open(world({ [slotsKey]: [slot(ME, 2)] })); + + expect(sheet.grading).toBe("open"); + expect(sheet.access).toMatchObject({ + canEdit: true, + reason: null, + opensAt: null, + gradedElsewhere: false, + }); + }); + + it("is open as before where nobody is assigned", async () => { + const sheet = await open(world({ [slotsKey]: [slot(null, 2)] })); + + expect(sheet.grading).toBe("open"); + expect(sheet.access.canEdit).toBe(true); + }); +}); + +describe("desk.projects with assigned presentations", () => { + const list = async (answers: Answers, over = {}) => { + const { db } = fakeDb(answers); + return judgingDeskRouter.createCaller(memberCtx(db)).projects({ + hackathonId: HACK, + challengeId: SPONSOR, + ...over, + }); + }; + const listed = ( + entryId: string, + assignedJudgeId: string | null, + startsAt: Date, + ) => ({ + id: `slot-${entryId}`, + entryId, + roomId: ROOM, + roomName: "Table 4", + buildingName: null, + startsAt, + deadlineAt: new Date(startsAt.getTime() + 10 * 60_000), + endsAt: new Date(startsAt.getTime() + 12 * 60_000), + assignedJudgeId, + }); + const crowded = (over: Answers = {}) => + world({ + [entriesKey]: [ + entry(E_ONE, "Alpha"), + entry(E_TWO, "Bravo"), + entry(E_THREE, "Charlie"), + ], + [listedSlotsKey]: [ + listed(E_ONE, null, minutesAhead(10)), + listed(E_TWO, ME, minutesAhead(40)), + listed(E_THREE, ME, minutesAhead(20)), + ], + ...over, + }); + + it("puts the judge's assigned presentations first, in start order, marked", async () => { + const result = await list(crowded()); + + expect(result.projects.map((p) => p.id)).toEqual([E_THREE, E_TWO, E_ONE]); + expect(result.projects.map((p) => p.assignedToYou)).toEqual([ + true, + true, + false, + ]); + }); + + it("lists the rest in start order as before", async () => { + const result = await list( + crowded({ + [listedSlotsKey]: [ + listed(E_ONE, null, minutesAhead(30)), + listed(E_TWO, null, minutesAhead(10)), + listed(E_THREE, null, minutesAhead(20)), + ], + }), + ); + + expect(result.projects.map((p) => p.id)).toEqual([E_TWO, E_THREE, E_ONE]); + expect(result.projects.some((p) => p.assignedToYou)).toBe(false); + }); + + it("shows a presentation kept for another judge as reserved, after the open ones", async () => { + const started = minutesAgo(2); + const result = await list( + crowded({ + [listedSlotsKey]: [ + listed(E_ONE, OTHER, started), + listed(E_TWO, null, minutesAhead(40)), + listed(E_THREE, ME, minutesAhead(20)), + ], + }), + ); + + expect( + result.projects.map((p) => [p.id, p.grading, p.assignedToYou]), + ).toEqual([ + [E_THREE, "open", true], + [E_TWO, "open", false], + [E_ONE, "reserved", false], + ]); + const reserved = result.projects.find((p) => p.id === E_ONE); + expect(reserved?.reservedUntil).toEqual( + new Date(started.getTime() + TAKEOVER_AFTER_MS), + ); + expect( + result.projects.find((p) => p.id === E_TWO)?.reservedUntil, + ).toBeNull(); + }); + + it("opens it to the judge once the takeover time has passed", async () => { + const result = await list( + crowded({ + [listedSlotsKey]: [ + listed(E_ONE, OTHER, minutesAgo(takeoverMinutes + 1)), + listed(E_TWO, ME, minutesAhead(40)), + listed(E_THREE, ME, minutesAhead(20)), + ], + }), + ); + + expect(result.projects.find((p) => p.id === E_ONE)).toMatchObject({ + grading: "open", + reservedUntil: null, + assignedToYou: false, + }); + }); + + it("never says which judge", async () => { + const result = await list( + crowded({ + [listedSlotsKey]: [ + listed(E_ONE, OTHER, minutesAgo(2)), + listed(E_TWO, OTHER, minutesAgo(takeoverMinutes + 1)), + listed(E_THREE, ME, minutesAhead(20)), + ], + }), + ); + + expect(JSON.stringify(result)).not.toContain(OTHER); + }); +}); + +describe("desk.nextProject with assigned presentations", () => { + const POOL = [E_ONE, E_TWO, E_THREE]; + const next = async (answers: Answers) => { + const { db } = fakeDb(answers); + return judgingDeskRouter + .createCaller(memberCtx(db)) + .nextProject({ hackathonId: HACK, challengeId: SPONSOR }); + }; + const running = ( + entryId: string, + assignedJudgeId: string | null, + startedMinutesAgo: number, + ) => slot(assignedJudgeId, startedMinutesAgo, { id: entryId, entryId }); + const crowded = (slots: unknown[]) => + world({ + [entriesKey]: POOL.map((id) => ({ id })), + [slotsKey]: slots, + }); + + it("sends the judge to their own assigned presentation first", async () => { + // Whatever place the queue would give each, the assigned one leads. + for (const mine of POOL) { + const result = await next( + crowded(POOL.map((id) => running(id, id === mine ? ME : null, 2))), + ); + expect(result.entryId).toBe(mine); + } + }); + + it("then offers one open for takeover, never one still reserved", async () => { + const result = await next( + crowded([ + running(E_ONE, OTHER, 2), + running(E_TWO, OTHER, takeoverMinutes + 1), + running(E_THREE, OTHER, 1), + ]), + ); + + expect(result.entryId).toBe(E_TWO); + }); + + it("is nothing when every running presentation is reserved for others", async () => { + const result = await next( + crowded([running(E_ONE, OTHER, 2), running(E_TWO, OTHER, 1)]), + ); + + expect(result.entryId).toBeNull(); + expect(result.needsRoom).toBe(false); + }); + + it("passes over an assigned presentation the judge skipped", async () => { + const { db } = fakeDb( + crowded([ + running(E_ONE, ME, 2), + running(E_TWO, null, 2), + running(E_THREE, OTHER, 1), + ]), + ); + + const result = await judgingDeskRouter + .createCaller(memberCtx(db)) + .nextProject({ hackathonId: HACK, challengeId: SPONSOR, skip: [E_ONE] }); + + expect(result.entryId).toBe(E_TWO); + }); + + it("leaves an untimed challenge to its queue window", async () => { + const result = await next( + world({ + [via(judgingChallenges)]: [ + general, + { ...sponsor, mode: "unscheduled" }, + ], + [entriesKey]: POOL.map((id) => ({ id })), + // Assignments mean nothing where nothing is timed. + [slotsKey]: POOL.map((id) => running(id, OTHER, 1)), + }), + ); + + expect(POOL).toContain(result.entryId); + }); +}); + +describe("desk.itinerary", () => { + const row = (over: Record = {}) => ({ + id: "slot-1", + entryId: E_ONE, + challengeId: SPONSOR, + title: "Tidewater", + roomId: ROOM, + roomName: "Table 4", + roomKind: "table", + buildingName: "Klaus", + startsAt: minutesAhead(10), + deadlineAt: minutesAhead(20), + ...over, + }); + const itineraryKey = via( + judgingAppointments, + judgingEntries, + judgingRooms, + judgingBuildings, + ); + const itinerary = async (answers: Answers) => { + const fake = fakeDb(world(answers)); + const result = await judgingDeskRouter + .createCaller(memberCtx(fake.db)) + .itinerary({ hackathonId: HACK }); + return { result, ...fake }; + }; + + it("lists the judge's own presentations with where, when and what challenge", async () => { + const { result } = await itinerary({ + [itineraryKey]: [ + row(), + row({ + id: "slot-2", + entryId: E_TWO, + title: "Undertow", + challengeId: GENERAL, + roomId: ELSEWHERE, + roomName: "1116", + roomKind: "room", + buildingName: null, + }), + ], + }); + + expect(result.presentations).toHaveLength(2); + expect(result.presentations[0]).toMatchObject({ + entryId: E_ONE, + title: "Tidewater", + roomId: ROOM, + roomName: "Table 4", + roomKind: "table", + buildingName: "Klaus", + challengeId: SPONSOR, + challengeLabel: "Best Use of AI", + graded: false, + takenOver: false, + inRoom: true, + }); + expect(result.presentations[0]?.startsAt).toBeInstanceOf(Date); + expect(result.presentations[0]?.deadlineAt).toBeInstanceOf(Date); + // Seated at the sponsor's table: the other is in a room the judge is not in. + expect(result.presentations[1]).toMatchObject({ + challengeLabel: "General", + inRoom: false, + }); + }); + + it("asks only for the judge's own presentations that have not ended", async () => { + const { events } = await itinerary({ [itineraryKey]: [row()] }); + + const read = events.find((e) => e.table === itineraryKey); + const { sql, params } = new PgDialect().sqlToQuery(read?.where as SQL); + expect(sql).toContain('"assigned_judge_id" = $'); + expect(params).toContain(ME); + expect(sql).toContain('"deadline_at" > $'); + expect(sql).toContain('"deleted_at" is null'); + }); + + it("says a presentation is graded, or that another judge has taken it, without who", async () => { + const { result } = await itinerary({ + [itineraryKey]: [ + row({ id: "a", entryId: E_ONE }), + row({ id: "b", entryId: E_TWO }), + row({ id: "c", entryId: E_THREE }), + ], + [evaluations]: [{ entryId: E_ONE, challengeId: SPONSOR, judgeId: OTHER }], + [drafts]: [ + { + entryId: E_TWO, + challengeId: SPONSOR, + judgeId: OTHER, + updatedAt: minutesAgo(1), + deadlineAt: minutesAhead(5), + }, + // A stale draft holds nothing. + { + entryId: E_THREE, + challengeId: SPONSOR, + judgeId: OTHER, + updatedAt: new Date(Date.now() - GRADING_HOLD_MS - 60_000), + deadlineAt: null, + }, + ], + }); + + expect( + result.presentations.map((p) => [p.entryId, p.graded, p.takenOver]), + ).toEqual([ + [E_ONE, true, false], + [E_TWO, false, true], + [E_THREE, false, false], + ]); + expect(JSON.stringify(result)).not.toContain(OTHER); + }); + + it("is empty when the judge has nothing assigned, and before a schedule is saved", async () => { + const none = await itinerary({ [itineraryKey]: [] }); + expect(none.result.presentations).toEqual([]); + + const unscheduled = await itinerary({ + [via(judgingSchedules)]: [], + [itineraryKey]: [row()], + }); + expect(unscheduled.result.presentations).toEqual([]); + }); +}); + +describe("desk.releaseGrading", () => { + const release = async (answers: Answers, input = {}) => { + const fake = fakeDb(world(answers)); + const result = await judgingDeskRouter + .createCaller(memberCtx(fake.db)) + .releaseGrading({ + hackathonId: HACK, + entryId: E_ONE, + challengeId: SPONSOR, + ...input, + }); + return { result, ...fake }; + }; + const opened = { id: "draft-1", revision: 0 }; + const deleted = (events: Event[]) => + events.some((e) => e.kind === "delete" && e.table === drafts); + + it("deletes a draft the judge opened and never saved, locking it first", async () => { + const { result, events } = await release({ [drafts]: [opened] }); + + expect(result).toEqual({ released: true }); + expect(deleted(events)).toBe(true); + const read = events.findIndex( + (e) => e.kind === "select" && e.table === drafts, + ); + expect(events[read]?.locking).toBe(true); + expect(read).toBeLessThan(events.findIndex((e) => e.kind === "delete")); + }); + + it("only touches the judge's own draft", async () => { + const { events } = await release({ [drafts]: [opened] }); + + const read = events.find((e) => e.kind === "select" && e.table === drafts); + const { params } = new PgDialect().sqlToQuery(read?.where as SQL); + expect(params).toContain(ME); + expect(params).toContain(E_ONE); + expect(params).toContain(SPONSOR); + const gone = events.find((e) => e.kind === "delete"); + expect(new PgDialect().sqlToQuery(gone?.where as SQL).params).toEqual([ + "draft-1", + ]); + }); + + it("keeps a draft the judge has saved to", async () => { + const { result, events } = await release({ + [drafts]: [{ id: "draft-1", revision: 1 }], + }); + + expect(result).toEqual({ released: false }); + expect(deleted(events)).toBe(false); + }); + + it("keeps a draft of a project the judge has graded", async () => { + const { result, events } = await release({ + [drafts]: [opened], + [evaluations]: [{ id: "evaluation-1" }], + }); + + expect(result).toEqual({ released: false }); + expect(deleted(events)).toBe(false); + }); + + it("is nothing when the judge holds no draft", async () => { + const { result, events } = await release({}); + + expect(result).toEqual({ released: false }); + expect(deleted(events)).toBe(false); + }); + + it("gives back a sponsor's project from a judge who has left its room", async () => { + const { result } = await release({ + [drafts]: [opened], + [presenceKey]: [], + }); + + expect(result).toEqual({ released: true }); + }); + + it("finds the challenge by the judge's seat when none is passed", async () => { + const { result } = await release( + { [drafts]: [opened] }, + { challengeId: undefined }, + ); + + expect(result).toEqual({ released: true }); + }); + + it("leaves a challenge of another edition alone", async () => { + const { result, events } = await release( + { [drafts]: [opened] }, + { challengeId: "0c4a8e99-2b3d-4c5e-9f60-7a8b9c0d1e2f" }, + ); + + expect(result).toEqual({ released: false }); + expect(deleted(events)).toBe(false); + }); + + it("takes only the draft's row lock, never the grading lock", async () => { + // A save racing it waits on the same row: one that lands first leaves a + // saved draft this keeps, and one that lands after starts a new draft. + // With no grading lock, it cannot deadlock with a judge's claim. + const { events } = await release({ [drafts]: [opened] }); + + expect(events.some((e) => e.kind === "execute")).toBe(false); + expect( + events + .filter((e) => e.kind === "select" && e.locking) + .map((e) => e.table), + ).toEqual([drafts]); + }); +}); + +describe("the scores the desk shows count what placings count", () => { + const MINE = "evaluation-mine"; + const two = [rating, { ...rating, id: RATING_2, label: "Polish" }]; + const own = (isComplete: boolean) => ({ + id: MINE, + revision: 1, + isComplete, + appointmentId: null, + submittedAt: minutesAgo(30), + autoSubmittedAt: minutesAgo(30), + updatedAt: minutesAgo(30), + }); + const rated = (...items: string[]) => + items.map((itemId) => ({ evaluationId: MINE, itemId, value: 4 })); + + const sheet = async (answers: Answers) => { + const { db } = fakeDb( + world({ + [via(judgingChallenges)]: [general], + [via(judgingEntryMembers)]: [{ name: "Ada Lovelace" }], + [slotsKey]: [], + [via(judgingSchedules)]: [], + ...answers, + }), + ); + return judgingDeskRouter.createCaller(memberCtx(db)).project({ + hackathonId: HACK, + entryId: E_ONE, + challengeId: GENERAL, + }); + }; + const withEvaluation = ( + isComplete: boolean, + items: string[], + rubric = two, + ) => ({ + [via(judgingRubricItems)]: rubric, + [evaluations]: byCall( + [own(isComplete)], + [{ judgeId: ME }], + [{ id: MINE, challengeId: GENERAL }], + ), + [via(judgingEvaluationRatings)]: rated(...items), + }); + + it("scores an evaluation that rated every item though a response was left empty", async () => { + const result = await sheet(withEvaluation(false, [RATING, RATING_2])); + + expect(result.evaluation).toMatchObject({ isComplete: false, score: 4 }); + expect(result.scores?.challenge).toEqual({ score: 4, count: 1 }); + expect(result.scores?.overall).toEqual({ score: 4, count: 1 }); + }); + + it("leaves out an evaluation that is missing a rating", async () => { + const result = await sheet(withEvaluation(false, [RATING])); + + expect(result.evaluation?.score).toBeNull(); + expect(result.scores?.challenge).toEqual({ score: null, count: 0 }); + }); + + it("scores a whole evaluation as before", async () => { + const result = await sheet(withEvaluation(true, [RATING], [rating])); + + expect(result.evaluation?.score).toBe(4); + expect(result.scores?.challenge).toEqual({ score: 4, count: 1 }); + }); + + it("scores the same in the judge's submitted list", async () => { + const mine = async (items: string[]) => { + const { db } = fakeDb( + world({ + [via(judgingRubricItems)]: two, + [via(judgingEvaluations, judgingEntries)]: [], + [via(judgingEvaluations, judgingEntries, judgingChallenges)]: [ + { + id: MINE, + entryId: E_ONE, + challengeId: GENERAL, + challengeLabel: "General", + title: "Tidewater", + deletedAt: null, + revision: 1, + isComplete: false, + submittedAt: minutesAgo(30), + autoSubmittedAt: minutesAgo(30), + updatedAt: minutesAgo(30), + }, + ], + [via(judgingEvaluationRatings)]: rated(...items), + }), + ); + return judgingDeskRouter + .createCaller(memberCtx(db)) + .myEvaluations({ hackathonId: HACK }); + }; + + expect((await mine([RATING, RATING_2]))[0]?.score).toBe(4); + expect((await mine([RATING]))[0]?.score).toBeNull(); + }); +}); diff --git a/packages/api/src/.internal-tests/check-in-flags-desk.test.ts b/packages/api/src/.internal-tests/check-in-flags-desk.test.ts new file mode 100644 index 00000000..c4f2db71 --- /dev/null +++ b/packages/api/src/.internal-tests/check-in-flags-desk.test.ts @@ -0,0 +1,437 @@ +/** + * Check-in rule-break flags at the judge desk. A judge, member or guest, is + * told only that a team is flagged: one boolean on each project in the list and + * on the project being judged. The hacker, the rule and the events stay with + * organisers, so these tests read the whole serialized response for them. + * + * The router runs for real against a stand-in database that answers each + * select by the tables it reads, which is also how the tests see what the desk + * did and did not read. + */ +import { describe, it, expect } from "vitest"; +import { getTableName } from "drizzle-orm"; +import { + hackathonCheckInFlags, + hackathonEvents, + hackathonParticipants, + hackathonProjects, + judgingChallenges, + judgingEntries, + judgingEntryChallenges, + judgingEntryMembers, + judgingGuestSessions, + judgingJudges, + judges, + judgingRoomLinks, + judgingRooms, + users, +} from "@query/db"; +import { judgingDeskRouter } from "../routers/judging/desk"; + +const HACK = "6f1d2c3b-4a5e-4f60-8a7b-9c0d1e2f3a4b"; +const CHALLENGE = "0c4a8e10-2b3d-4c5e-9f60-7a8b9c0d1e2f"; +const JUDGE_ID = "1d5b9f21-3c4e-4d6f-8a70-8b9c0d1e2f3a"; +const ROOM = "2e6ca032-4d5f-4e70-9b81-9c0d1e2f3a4b"; + +// The two projects on the desk. One belongs to the flagged hacker's team. +const FLAGGED_ENTRY = "3f7db143-5e60-4f81-8c92-0d1e2f3a4b5c"; +const CLEAN_ENTRY = "40e8c254-6f71-4092-9da3-1e2f3a4b5c6d"; + +// Everything an organiser would see about the flag. None of it may reach a +// judge. +const FLAG_ID = "51f9d365-7082-41a3-8eb4-2f3a4b5c6d7e"; +const PARTICIPANT_ID = "62a0e476-8193-42b4-9fc5-3a4b5c6d7e8f"; +const HACKER_USER_ID = "73b1f587-92a4-43c5-80d6-4b5c6d7e8f90"; +const FLAGGED_TEAM = "84c2a698-a3b5-44d6-91e7-5c6d7e8f90a1"; +const OTHER_TEAM = "95d3b7a9-b4c6-45e7-a2f8-6d7e8f90a1b2"; +const HACKER_EMAIL = "mia.okafor@example.com"; +const HACKER_NAME = "Mia Okafor"; +const TRIED_EVENT = "Opening Ceremony"; +const CONFLICT_EVENT = "BNY Mellon Workshop"; + +const challenges = [ + { + id: CHALLENGE, + parentId: null, + mode: "unscheduled", + label: "General", + isGeneral: true, + isGroup: false, + }, +]; + +const entry = (id: string, title: string, participantCount: number) => ({ + id, + title, + description: "", + submissionUrl: null, + demoLinks: [], + videoUrl: null, + technologies: [], + participantCount, +}); + +const flaggedEntry = entry(FLAGGED_ENTRY, "Tidewater", 3); +const cleanEntry = entry(CLEAN_ENTRY, "Quiet Garden", 2); + +/** The key a stand-in answer is filed under: the table read, then its joins. */ +const via = (from: unknown, ...joins: unknown[]) => + [from, ...joins].map((table) => getTableName(table as never)).join(">"); + +type Answers = Record; + +/** + * A database whose selects resolve from `answers`, looked up by the tables the + * query reads and defaulting to no rows. Every select is kept, so a test can + * see which tables were read and how often. + */ +function fakeDb(answers: Answers) { + const reads: { key: string; from: string; fields: string[] }[] = []; + const db = { + select: (fields: Record) => { + const joined: unknown[] = []; + let from: unknown; + const chain: Record = { + from: (table: unknown) => { + from = table; + return chain; + }, + then: ( + ok: (rows: unknown[]) => unknown, + fail: (error: unknown) => unknown, + ) => { + const key = via(from, ...joined); + reads.push({ + key, + from: getTableName(from as never), + fields: Object.keys(fields ?? {}), + }); + return Promise.resolve(answers[key] ?? []).then(ok, fail); + }, + }; + for (const join of ["innerJoin", "leftJoin"]) { + chain[join] = (table: unknown) => { + joined.push(table); + return chain; + }; + } + for (const method of ["where", "orderBy", "limit", "for"]) { + chain[method] = () => chain; + } + return chain; + }, + }; + return { db, reads }; +} + +/** What the desk reads before the project list or the project itself. */ +const deskBasics = (): Answers => ({ + [via(judges)]: [{ name: "Grace Hopper" }], + [via(judgingJudges)]: [{ id: JUDGE_ID, displayName: "Grace Hopper" }], + [via(judgingChallenges)]: challenges, +}); + +/** + * One open flag from a hacker on FLAGGED_TEAM. The rows for the tables only an + * organiser's view reads are filled in too, so a desk that went and fetched + * them would have something to leak. + */ +const flagRows = ( + opts: { portal?: unknown[]; members?: unknown[] } = {}, +): Answers => ({ + [via(hackathonCheckInFlags, hackathonParticipants)]: [ + { + id: FLAG_ID, + participantId: PARTICIPANT_ID, + teamId: FLAGGED_TEAM, + userId: HACKER_USER_ID, + }, + ], + [via(judgingEntries, hackathonProjects)]: opts.portal ?? [ + { entryId: FLAGGED_ENTRY, teamId: FLAGGED_TEAM, submittedById: null }, + { entryId: CLEAN_ENTRY, teamId: OTHER_TEAM, submittedById: null }, + ], + [via(judgingEntryMembers, judgingEntries)]: opts.members ?? [], + [via(users)]: [{ id: HACKER_USER_ID, email: HACKER_EMAIL }], + [via(hackathonEvents)]: [ + { id: "event-a", name: TRIED_EVENT }, + { id: "event-b", name: CONFLICT_EVENT }, + ], +}); + +const memberCtx = (db: unknown) => + ({ + db, + session: { user: { id: "judge-user", name: "Grace Hopper" } }, + userId: "judge-user", + clientIp: "127.0.0.1", + req: { headers: { get: () => null } }, + }) as never; + +const guestAnswers = (): Answers => ({ + [via(judgingGuestSessions, judgingRoomLinks, judgingRooms, judgingJudges)]: [ + { + sessionId: "session-1", + hackathonId: HACK, + linkId: "link-1", + roomId: ROOM, + roomChallengeId: CHALLENGE, + judgeId: JUDGE_ID, + displayName: "Guest Judge", + }, + ], + [via(judgingChallenges)]: challenges, +}); + +const guestCtx = (db: unknown) => + ({ + db, + session: null, + userId: undefined, + clientIp: "127.0.0.2", + req: { + headers: { + get: (name: string) => + name.toLowerCase() === "cookie" + ? "query_judging_guest=guest-token" + : null, + }, + }, + }) as never; + +/** Words and ids that only organisers may see. */ +const organiserOnly = [ + "email", + "userId", + FLAG_ID, + PARTICIPANT_ID, + HACKER_USER_ID, + FLAGGED_TEAM, + HACKER_EMAIL, + HACKER_NAME, + TRIED_EVENT, + CONFLICT_EVENT, + "overlap", + "too_soon", + "attempts", +]; + +const expectNothingOrganiserOnly = (response: unknown) => { + const serialized = JSON.stringify(response); + for (const secret of organiserOnly) expect(serialized).not.toContain(secret); +}; + +/** The tables a desk read took data from, for the privacy checks. */ +const tablesRead = (reads: { from: string }[]) => + new Set(reads.map((read) => read.from)); + +const flagReads = (reads: T[]) => + reads.filter((read) => read.from === getTableName(hackathonCheckInFlags)); + +const judgesUnderTest = [ + { + kind: "member judge", + answers: deskBasics, + ctx: memberCtx, + }, + { + kind: "guest judge", + answers: guestAnswers, + ctx: guestCtx, + }, +] as const; + +describe.each(judgesUnderTest)( + "check-in flags at the desk for a $kind", + ({ answers, ctx }) => { + describe("desk.projects", () => { + const listAnswers = (flags: Answers): Answers => ({ + ...answers(), + [via(judgingEntries, judgingEntryChallenges)]: [ + { id: FLAGGED_ENTRY, title: "Tidewater", participantCount: 3 }, + { id: CLEAN_ENTRY, title: "Quiet Garden", participantCount: 2 }, + ], + ...flags, + }); + + it("marks the flagged team's project true and the other false", async () => { + const { db } = fakeDb(listAnswers(flagRows())); + + const result = await judgingDeskRouter + .createCaller(ctx(db)) + .projects({ hackathonId: HACK, inMyRoomOnly: false }); + + const flagged = Object.fromEntries( + result.projects.map((project) => [project.id, project.flagged]), + ); + expect(flagged).toEqual({ + [FLAGGED_ENTRY]: true, + [CLEAN_ENTRY]: false, + }); + }); + + it("marks a project flagged through a member row, as a Devpost entry is", async () => { + const { db } = fakeDb( + listAnswers( + flagRows({ + portal: [], + members: [{ entryId: CLEAN_ENTRY, userId: HACKER_USER_ID }], + }), + ), + ); + + const result = await judgingDeskRouter + .createCaller(ctx(db)) + .projects({ hackathonId: HACK, inMyRoomOnly: false }); + + expect( + result.projects.find((project) => project.id === CLEAN_ENTRY) + ?.flagged, + ).toBe(true); + expect( + result.projects.find((project) => project.id === FLAGGED_ENTRY) + ?.flagged, + ).toBe(false); + }); + + it("is false for every project when nobody is flagged", async () => { + const { db } = fakeDb(listAnswers({})); + + const result = await judgingDeskRouter + .createCaller(ctx(db)) + .projects({ hackathonId: HACK, inMyRoomOnly: false }); + + expect(result.projects).toHaveLength(2); + expect(result.projects.map((project) => project.flagged)).toEqual([ + false, + false, + ]); + }); + + it("adds a boolean to each project and nothing else about the flag", async () => { + const { db } = fakeDb(listAnswers(flagRows())); + + const result = await judgingDeskRouter + .createCaller(ctx(db)) + .projects({ hackathonId: HACK, inMyRoomOnly: false }); + + for (const project of result.projects) { + expect(typeof project.flagged).toBe("boolean"); + expect(Object.keys(project).sort()).toEqual([ + "assignedToYou", + "challenges", + "flagged", + "grading", + "hasDraft", + "id", + "myEvaluation", + "participantCount", + "presentation", + "reservedUntil", + "title", + ]); + } + expectNothingOrganiserOnly(result); + }); + + it("asks for flags once for the whole list, not once per project", async () => { + const { db, reads } = fakeDb(listAnswers(flagRows())); + + await judgingDeskRouter + .createCaller(ctx(db)) + .projects({ hackathonId: HACK, inMyRoomOnly: false }); + + expect(flagReads(reads)).toHaveLength(1); + }); + + it("never reads the people or events behind a flag", async () => { + const { db, reads } = fakeDb(listAnswers(flagRows())); + + await judgingDeskRouter + .createCaller(ctx(db)) + .projects({ hackathonId: HACK, inMyRoomOnly: false }); + + const read = tablesRead(reads); + expect(read.has(getTableName(users))).toBe(false); + expect(read.has(getTableName(hackathonEvents))).toBe(false); + // Only the flag's own ids are selected, never a name or an email. + for (const select of flagReads(reads)) { + expect(select.fields).toEqual([ + "id", + "participantId", + "teamId", + "userId", + ]); + } + }); + + it("does not look for flags when there are no projects", async () => { + const { db, reads } = fakeDb({ ...answers(), ...flagRows() }); + + const result = await judgingDeskRouter + .createCaller(ctx(db)) + .projects({ hackathonId: HACK, inMyRoomOnly: false }); + + expect(result.projects).toEqual([]); + expect(flagReads(reads)).toHaveLength(0); + }); + }); + + describe("desk.project", () => { + const sheetAnswers = ( + projectEntry: ReturnType, + flags: Answers, + ): Answers => ({ + ...answers(), + [via(judgingEntries, judgingEntryChallenges)]: [projectEntry], + [via(judgingEntryMembers)]: [{ name: "Ada Lovelace" }], + ...flags, + }); + + it("marks the flagged team's project true", async () => { + const { db } = fakeDb(sheetAnswers(flaggedEntry, flagRows())); + + const result = await judgingDeskRouter + .createCaller(ctx(db)) + .project({ hackathonId: HACK, entryId: FLAGGED_ENTRY }); + + expect(result.entry.flagged).toBe(true); + }); + + it("marks another team's project false", async () => { + const { db } = fakeDb(sheetAnswers(cleanEntry, flagRows())); + + const result = await judgingDeskRouter + .createCaller(ctx(db)) + .project({ hackathonId: HACK, entryId: CLEAN_ENTRY }); + + expect(result.entry.flagged).toBe(false); + }); + + it("is false when nobody is flagged", async () => { + const { db } = fakeDb(sheetAnswers(flaggedEntry, {})); + + const result = await judgingDeskRouter + .createCaller(ctx(db)) + .project({ hackathonId: HACK, entryId: FLAGGED_ENTRY }); + + expect(result.entry.flagged).toBe(false); + }); + + it("tells the judge nothing more than the boolean", async () => { + const { db, reads } = fakeDb(sheetAnswers(flaggedEntry, flagRows())); + + const result = await judgingDeskRouter + .createCaller(ctx(db)) + .project({ hackathonId: HACK, entryId: FLAGGED_ENTRY }); + + expect(result.entry.flagged).toBe(true); + expectNothingOrganiserOnly(result); + const read = tablesRead(reads); + expect(read.has(getTableName(users))).toBe(false); + expect(read.has(getTableName(hackathonEvents))).toBe(false); + expect(flagReads(reads)).toHaveLength(1); + }); + }); + }, +); diff --git a/packages/api/src/.internal-tests/check-in-flags-organizer.test.ts b/packages/api/src/.internal-tests/check-in-flags-organizer.test.ts new file mode 100644 index 00000000..eb1b60a3 --- /dev/null +++ b/packages/api/src/.internal-tests/check-in-flags-organizer.test.ts @@ -0,0 +1,783 @@ +/** + * Check-in flags in the organiser's judging console: the project list carries + * each project's flags and whether it was pulled from prizes, can be narrowed + * to flagged projects, and the header counts open flags. Two writes close the + * loop: dismissing a flag and pulling (or returning) a project from prizes. + * + * The schema is the real one; only the database handle is a stand-in, handed + * to the router through the context the way judge-edge.test.ts does. It keeps + * the rows of the tables a write touches and applies the conditions the router + * asks for, so a missing edition condition lets another edition's row through + * and fails the test, rather than passing on a canned answer. The flag + * service has its own tests; here it is a stub, which also lets these tests + * see exactly what the router asks it for. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { + auditLogs, + hackathonCheckInFlags, + hackathons, + judgingConfigs, + judgingEntries, +} from "@query/db"; +import { appRouter } from "../root"; +import { cache } from "../middleware/cache"; +import type * as CheckInFlags from "../services/check-in-flags"; + +const mockFlagsForEntries = vi.fn(); +const mockFlaggedIds = vi.fn(); +const mockOpenCount = vi.fn(); + +vi.mock("../services/check-in-flags", async (importActual) => ({ + ...(await importActual()), + checkInFlagsForEntries: (...args: unknown[]) => mockFlagsForEntries(...args), + flaggedEntryIds: (...args: unknown[]) => mockFlaggedIds(...args), + openCheckInFlagCount: (...args: unknown[]) => mockOpenCount(...args), +})); + +const HACK_A = "11111111-1111-4111-8111-111111111111"; +const HACK_B = "22222222-2222-4222-8222-222222222222"; +const ENTRY_1 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"; +const ENTRY_2 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2"; +const ENTRY_3 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3"; +const ENTRY_OTHER = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1"; +const FLAG_1 = "cccccccc-cccc-4ccc-8ccc-ccccccccccc1"; +const FLAG_OTHER = "dddddddd-dddd-4ddd-8ddd-ddddddddddd1"; +const NO_SUCH = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + +const dialect = new PgDialect(); +const render = (condition: unknown) => dialect.sqlToQuery(condition as never); + +type Row = Record; +type SelectCall = { + fields: Record | undefined; + table: unknown; + where: unknown; +}; + +const camel = (name: string) => + name.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()); + +const TERM = /"[a-z_]+"\."([a-z_]+)" = \$(\d+)/g; + +/** + * A row filter from a where made only of `column = value` terms joined by + * `and`. Anything else throws, so the fake never quietly ignores a condition + * it does not understand. + */ +function matcher(where: unknown) { + const { sql: text, params } = render(where); + const terms = [...text.matchAll(TERM)].map( + (match) => [camel(match[1]!), params[Number(match[2]) - 1]] as const, + ); + if (text.replace(TERM, "").replace(/[()\s]|and/g, "")) { + throw new Error(`The fake database cannot evaluate: ${text}`); + } + return (row: Row) => terms.every(([key, value]) => row[key] === value); +} + +// --------------------------------------------------------------------------- +// The stand-in database + +let adminRow: Row | undefined; +const store = new Map(); +const selects: SelectCall[] = []; +const updates: { table: unknown; values: Row }[] = []; +const inserts: { table: unknown; values: Row }[] = []; +// Answers a select outright; undefined hands it to the stored rows. +let route: ((call: SelectCall) => unknown[] | undefined) | null = null; + +const matching = (table: unknown, where: unknown) => { + const rows = store.get(table) ?? []; + return where ? rows.filter(matcher(where)) : [...rows]; +}; + +const fakeDb: any = { + query: { admins: { findFirst: async () => adminRow } }, + select: (fields?: Record) => { + const call: SelectCall = { fields, table: null, where: null }; + selects.push(call); + const chain: any = { + then: (onOk: any, onErr: any) => + Promise.resolve(route?.(call) ?? matching(call.table, call.where)).then( + onOk, + onErr, + ), + }; + chain.from = (table: unknown) => { + call.table = table; + return chain; + }; + chain.where = (where: unknown) => { + call.where = where; + return chain; + }; + for (const method of [ + "innerJoin", + "leftJoin", + "groupBy", + "orderBy", + "limit", + "offset", + "for", + ]) { + chain[method] = () => chain; + } + return chain; + }, + insert: (table: unknown) => ({ + values: (values: Row) => { + inserts.push({ table, values }); + return Object.assign(Promise.resolve(undefined), { + // The config row already exists, so a create-if-missing finds nothing + // to return and reads the stored one. + onConflictDoNothing: () => ({ returning: async () => [] }), + returning: async () => [values], + }); + }, + }), + update: (table: unknown) => ({ + set: (values: Row) => ({ + where: (where: unknown) => { + const hit = matching(table, where); + for (const row of hit) Object.assign(row, values); + updates.push({ table, values }); + return Object.assign(Promise.resolve(undefined), { + returning: async () => hit.map((row) => ({ ...row })), + }); + }, + }), + }), + delete: () => ({ where: async () => [] }), + transaction: async (callback: (tx: unknown) => unknown) => callback(fakeDb), +}; + +const callerAs = (userId: string | undefined) => + appRouter.createCaller({ + db: fakeDb, + session: userId ? { user: { id: userId } } : null, + userId, + cache, + clientIp: "127.0.0.1", + req: { headers: { get: () => null } }, + } as any); + +const admin = () => callerAs("admin_user"); + +const audits = () => + inserts.filter((write) => write.table === auditLogs).map((w) => w.values); + +const flagView = ( + over: Partial = {}, +): CheckInFlags.CheckInFlagView => ({ + id: FLAG_1, + participantName: "Ada Lovelace", + rule: "overlap", + eventName: "BNY Workshop", + conflictEventName: "Full Sail Fireside", + method: "code", + attempts: 3, + createdAt: new Date("2027-02-06T18:00:00Z"), + lastAttemptAt: new Date("2027-02-06T19:41:00Z"), + dismissed: false, + ...over, +}); + +const entryRow = (id: string, over: Row = {}) => ({ + id, + source: "portal", + sourceProjectId: null, + title: `Project ${id.slice(-1)}`, + submissionUrl: null, + description: "", + demoLinks: [], + videoUrl: null, + technologies: [], + universities: [], + prizeCategories: [], + participantCount: 2, + submittedAt: null, + deletedAt: null, + prizeIneligible: false, + createdAt: new Date("2027-02-06T12:00:00Z"), + ...over, +}); + +beforeEach(() => { + vi.clearAllMocks(); + cache.clear(); + selects.length = 0; + updates.length = 0; + inserts.length = 0; + route = null; + adminRow = { userId: "admin_user", isActive: true, role: "admin" }; + mockFlagsForEntries.mockReset().mockResolvedValue(new Map()); + mockFlaggedIds.mockReset().mockResolvedValue(new Set()); + mockOpenCount.mockReset().mockResolvedValue(0); + + store.clear(); + store.set(hackathons, [ + { id: HACK_A, name: "Hacklytics 2027", startDate: null, endDate: null }, + { id: HACK_B, name: "Hacklytics 2026", startDate: null, endDate: null }, + ]); + store.set(judgingConfigs, [ + { + hackathonId: HACK_A, + state: "draft", + openedAt: null, + closedAt: null, + showAllScores: false, + inventoryLockedAt: null, + groupsInitializedAt: null, + itineraryPublished: false, + emergencyMode: false, + claimsStartedAt: null, + }, + { + hackathonId: HACK_B, + state: "draft", + openedAt: null, + closedAt: null, + showAllScores: false, + inventoryLockedAt: null, + groupsInitializedAt: null, + itineraryPublished: false, + emergencyMode: false, + claimsStartedAt: null, + }, + ]); + store.set(hackathonCheckInFlags, [ + { + id: FLAG_1, + hackathonId: HACK_A, + dismissedAt: null, + dismissedById: null, + }, + { + id: FLAG_OTHER, + hackathonId: HACK_B, + dismissedAt: null, + dismissedById: null, + }, + ]); + store.set(judgingEntries, [ + { + id: ENTRY_1, + hackathonId: HACK_A, + prizeIneligibleAt: null, + prizeIneligibleById: null, + }, + { + id: ENTRY_OTHER, + hackathonId: HACK_B, + prizeIneligibleAt: null, + prizeIneligibleById: null, + }, + ]); +}); + +// --------------------------------------------------------------------------- +describe("judging.admin.listEntries", () => { + /** The page of projects the database returns, whatever filter it was given. */ + const wirePage = (rows: Row[], liveIds: string[] = rows.map((r) => r.id)) => { + route = (call) => { + if (call.table !== judgingEntries) return []; + if (call.fields && "value" in call.fields) + return [{ value: rows.length }]; + if (call.fields && "title" in call.fields) return rows; + return liveIds.map((id) => ({ id })); + }; + }; + + const pageQuery = () => + selects.find((call) => call.fields && "title" in call.fields)!; + const totalQuery = () => + selects.find((call) => call.fields && "value" in call.fields)!; + + it("puts each project's flags and its prize status on the project", async () => { + wirePage([entryRow(ENTRY_1, { prizeIneligible: true }), entryRow(ENTRY_2)]); + const flag = flagView(); + mockFlagsForEntries.mockResolvedValue(new Map([[ENTRY_1, [flag]]])); + + const result = await admin().judging.admin.listEntries({ + hackathonId: HACK_A, + }); + + expect(result.entries.map((e) => e.id)).toEqual([ENTRY_1, ENTRY_2]); + expect(result.entries[0]).toMatchObject({ + checkInFlags: [flag], + prizeIneligible: true, + }); + expect(result.entries[1]).toMatchObject({ + checkInFlags: [], + prizeIneligible: false, + }); + // Asked for this page's projects only, in this edition. + expect(mockFlagsForEntries).toHaveBeenCalledTimes(1); + expect(mockFlagsForEntries).toHaveBeenCalledWith(fakeDb, HACK_A, [ + ENTRY_1, + ENTRY_2, + ]); + }); + + it("reads prize status from the entry's prize_ineligible_at column", async () => { + wirePage([entryRow(ENTRY_1)]); + + await admin().judging.admin.listEntries({ hackathonId: HACK_A }); + + expect(render(pageQuery().fields!.prizeIneligible).sql).toBe( + '"judging_entry"."prize_ineligible_at" is not null', + ); + }); + + it("does not look up flags for an empty page", async () => { + wirePage([]); + + const result = await admin().judging.admin.listEntries({ + hackathonId: HACK_A, + }); + + expect(result.entries).toEqual([]); + expect(mockFlagsForEntries).not.toHaveBeenCalled(); + }); + + it("applies no flag condition unless asked", async () => { + wirePage([entryRow(ENTRY_1)]); + + await admin().judging.admin.listEntries({ hackathonId: HACK_A }); + await admin().judging.admin.listEntries({ + hackathonId: HACK_A, + flagged: false, + }); + + expect(mockFlaggedIds).not.toHaveBeenCalled(); + for (const call of selects.filter((c) => c.fields && "title" in c.fields)) { + expect(render(call.where).sql).not.toMatch(/ in \(/); + } + }); + + it("narrows the page and the total to projects with an open flag", async () => { + wirePage([entryRow(ENTRY_2)], [ENTRY_1, ENTRY_2, ENTRY_3]); + mockFlaggedIds.mockResolvedValue(new Set([ENTRY_2])); + + const result = await admin().judging.admin.listEntries({ + hackathonId: HACK_A, + flagged: true, + }); + + // Open flags are looked for among this edition's live projects. + expect(mockFlaggedIds).toHaveBeenCalledWith(fakeDb, HACK_A, [ + ENTRY_1, + ENTRY_2, + ENTRY_3, + ]); + const liveRead = selects.find( + (call) => + call.table === judgingEntries && + call.fields && + !("title" in call.fields) && + !("value" in call.fields), + )!; + const live = render(liveRead.where); + expect(live.sql).toContain('"judging_entry"."deleted_at" is null'); + expect(live.params).toContain(HACK_A); + + // The same condition sits in the query that pages and the one that counts, + // so the total is the flagged total and not the edition's. + for (const call of [pageQuery(), totalQuery()]) { + const { sql, params } = render(call.where); + expect(sql).toMatch(/"judging_entry"\."id" in \(\$\d+\)/); + expect(params).toContain(ENTRY_2); + expect(params).not.toContain(ENTRY_1); + expect(params).not.toContain(ENTRY_3); + } + expect(result.total).toBe(1); + }); + + it("matches nothing when no project has an open flag", async () => { + wirePage([], [ENTRY_1, ENTRY_2]); + + const result = await admin().judging.admin.listEntries({ + hackathonId: HACK_A, + flagged: true, + }); + + for (const call of [pageQuery(), totalQuery()]) { + expect(render(call.where).sql).toMatch(/ and false\)$/); + } + expect(result).toMatchObject({ total: 0, entries: [] }); + }); + + it("combines the flag filter with the others", async () => { + wirePage([], [ENTRY_1]); + mockFlaggedIds.mockResolvedValue(new Set([ENTRY_1])); + + await admin().judging.admin.listEntries({ + hackathonId: HACK_A, + flagged: true, + source: "portal", + status: "live", + }); + + const { sql, params } = render(pageQuery().where); + expect(sql).toContain('"judging_entry"."source" = $'); + expect(sql).toMatch(/"judging_entry"\."id" in \(/); + expect(params).toEqual(expect.arrayContaining([HACK_A, "portal", ENTRY_1])); + }); +}); + +// --------------------------------------------------------------------------- +describe("judging.admin.overview", () => { + const overview = () => + admin().judging.admin.overview({ hackathonId: HACK_A }); + + // Every other count reads from tables this test leaves empty. + beforeEach(() => { + route = (call) => + call.table === hackathons || call.table === judgingConfigs + ? undefined + : []; + }); + + it("counts the open check-in flags and nothing about them", async () => { + mockOpenCount.mockResolvedValue(4); + + const result = await overview(); + + expect(result.counts.checkInFlags).toEqual({ open: 4 }); + expect(mockOpenCount).toHaveBeenCalledWith(fakeDb, HACK_A); + }); + + it("reports zero when there are none", async () => { + const result = await overview(); + + expect(result.counts.checkInFlags).toEqual({ open: 0 }); + }); +}); + +// --------------------------------------------------------------------------- +describe("judging.admin.dismissCheckInFlag", () => { + const flagRow = (id: string) => + store.get(hackathonCheckInFlags)!.find((row) => row.id === id)!; + + it("dismisses a flag and records who", async () => { + const result = await admin().judging.admin.dismissCheckInFlag({ + hackathonId: HACK_A, + flagId: FLAG_1, + dismissed: true, + }); + + expect(result).toEqual({ dismissed: true }); + expect(flagRow(FLAG_1)).toMatchObject({ + dismissedAt: expect.any(Date), + dismissedById: "admin_user", + }); + expect(updates).toHaveLength(1); + expect(updates[0]!.table).toBe(hackathonCheckInFlags); + expect(Object.keys(updates[0]!.values).sort()).toEqual([ + "dismissedAt", + "dismissedById", + ]); + expect(audits()).toEqual([ + expect.objectContaining({ + userId: "admin_user", + action: "judging.dismissCheckInFlag", + resourceId: FLAG_1, + severity: "info", + metadata: { hackathonId: HACK_A }, + }), + ]); + }); + + it("opens a dismissed flag again", async () => { + Object.assign(flagRow(FLAG_1), { + dismissedAt: new Date(), + dismissedById: "someone", + }); + + const result = await admin().judging.admin.dismissCheckInFlag({ + hackathonId: HACK_A, + flagId: FLAG_1, + dismissed: false, + }); + + expect(result).toEqual({ dismissed: false }); + expect(flagRow(FLAG_1)).toMatchObject({ + dismissedAt: null, + dismissedById: null, + }); + expect(audits()).toEqual([ + expect.objectContaining({ + action: "judging.reopenCheckInFlag", + resourceId: FLAG_1, + }), + ]); + }); + + it("refuses a flag that belongs to another hackathon", async () => { + await expect( + admin().judging.admin.dismissCheckInFlag({ + hackathonId: HACK_A, + flagId: FLAG_OTHER, + dismissed: true, + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + + expect(flagRow(FLAG_OTHER).dismissedAt).toBeNull(); + expect(audits()).toEqual([]); + }); + + it("refuses a flag that does not exist", async () => { + await expect( + admin().judging.admin.dismissCheckInFlag({ + hackathonId: HACK_A, + flagId: NO_SUCH, + dismissed: true, + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + + expect(audits()).toEqual([]); + }); + + it("leaves the cached public results alone", async () => { + cache.set(`hackathon:${HACK_A}:results`, [{ id: "placing" }], 60); + + await admin().judging.admin.dismissCheckInFlag({ + hackathonId: HACK_A, + flagId: FLAG_1, + dismissed: true, + }); + + expect(cache.get(`hackathon:${HACK_A}:results`)).toEqual([ + { id: "placing" }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +describe("judging.admin.setPrizeEligibility", () => { + const entry = (id: string) => + store.get(judgingEntries)!.find((row) => row.id === id)!; + const resultsKey = (hackathonId: string) => + `hackathon:${hackathonId}:results`; + + it("pulls a project from prizes and clears the cached results at once", async () => { + cache.set(resultsKey(HACK_A), [{ id: "placing" }], 60); + cache.set(resultsKey(HACK_B), [{ id: "last year" }], 60); + + const result = await admin().judging.admin.setPrizeEligibility({ + hackathonId: HACK_A, + entryId: ENTRY_1, + eligible: false, + }); + + expect(result).toEqual({ changed: true }); + expect(entry(ENTRY_1)).toMatchObject({ + prizeIneligibleAt: expect.any(Date), + prizeIneligibleById: "admin_user", + }); + expect(updates).toHaveLength(1); + expect(updates[0]!.table).toBe(judgingEntries); + expect(cache.get(resultsKey(HACK_A))).toBeNull(); + // Only this edition's results. + expect(cache.get(resultsKey(HACK_B))).toEqual([{ id: "last year" }]); + expect(audits()).toEqual([ + expect.objectContaining({ + userId: "admin_user", + action: "judging.setPrizeEligibility", + resourceId: ENTRY_1, + severity: "warn", + metadata: { hackathonId: HACK_A, eligible: false }, + }), + ]); + }); + + it("puts a project back in the running and clears the cached results", async () => { + Object.assign(entry(ENTRY_1), { + prizeIneligibleAt: new Date(), + prizeIneligibleById: "someone", + }); + cache.set(resultsKey(HACK_A), [{ id: "placing" }], 60); + + const result = await admin().judging.admin.setPrizeEligibility({ + hackathonId: HACK_A, + entryId: ENTRY_1, + eligible: true, + }); + + expect(result).toEqual({ changed: true }); + expect(entry(ENTRY_1)).toMatchObject({ + prizeIneligibleAt: null, + prizeIneligibleById: null, + }); + expect(cache.get(resultsKey(HACK_A))).toBeNull(); + expect(audits()).toEqual([ + expect.objectContaining({ + action: "judging.setPrizeEligibility", + severity: "warn", + metadata: { hackathonId: HACK_A, eligible: true }, + }), + ]); + }); + + it("does nothing when the project is already in that state", async () => { + cache.set(resultsKey(HACK_A), [{ id: "placing" }], 60); + + const result = await admin().judging.admin.setPrizeEligibility({ + hackathonId: HACK_A, + entryId: ENTRY_1, + eligible: true, + }); + + expect(result).toEqual({ changed: false }); + expect(updates).toEqual([]); + expect(audits()).toEqual([]); + expect(cache.get(resultsKey(HACK_A))).toEqual([{ id: "placing" }]); + }); + + it("does not move the pull date when asked to pull again", async () => { + const pulledAt = new Date("2027-02-06T20:00:00Z"); + Object.assign(entry(ENTRY_1), { + prizeIneligibleAt: pulledAt, + prizeIneligibleById: "first_admin", + }); + + const result = await admin().judging.admin.setPrizeEligibility({ + hackathonId: HACK_A, + entryId: ENTRY_1, + eligible: false, + }); + + expect(result).toEqual({ changed: false }); + expect(entry(ENTRY_1)).toMatchObject({ + prizeIneligibleAt: pulledAt, + prizeIneligibleById: "first_admin", + }); + }); + + it("refuses a project that belongs to another hackathon", async () => { + cache.set(resultsKey(HACK_A), [{ id: "placing" }], 60); + cache.set(resultsKey(HACK_B), [{ id: "last year" }], 60); + + await expect( + admin().judging.admin.setPrizeEligibility({ + hackathonId: HACK_A, + entryId: ENTRY_OTHER, + eligible: false, + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + + expect(entry(ENTRY_OTHER).prizeIneligibleAt).toBeNull(); + expect(updates).toEqual([]); + expect(audits()).toEqual([]); + expect(cache.get(resultsKey(HACK_A))).toEqual([{ id: "placing" }]); + expect(cache.get(resultsKey(HACK_B))).toEqual([{ id: "last year" }]); + }); + + it("refuses a project that does not exist", async () => { + await expect( + admin().judging.admin.setPrizeEligibility({ + hackathonId: HACK_A, + entryId: NO_SUCH, + eligible: false, + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + + expect(updates).toEqual([]); + }); + + it("refuses an edition that does not exist", async () => { + await expect( + admin().judging.admin.setPrizeEligibility({ + hackathonId: NO_SUCH, + entryId: ENTRY_1, + eligible: false, + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + + expect(entry(ENTRY_1).prizeIneligibleAt).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +describe("who may use them", () => { + const writes = (caller: ReturnType) => [ + () => + caller.judging.admin.dismissCheckInFlag({ + hackathonId: HACK_A, + flagId: FLAG_1, + dismissed: true, + }), + () => + caller.judging.admin.setPrizeEligibility({ + hackathonId: HACK_A, + entryId: ENTRY_1, + eligible: false, + }), + ]; + const reads = (caller: ReturnType) => [ + () => caller.judging.admin.listEntries({ hackathonId: HACK_A }), + () => caller.judging.admin.overview({ hackathonId: HACK_A }), + ]; + + const untouched = () => { + expect(updates).toEqual([]); + expect( + store + .get(hackathonCheckInFlags)! + .every((row) => row.dismissedAt === null), + ).toBe(true); + expect( + store.get(judgingEntries)!.every((row) => row.prizeIneligibleAt === null), + ).toBe(true); + expect(mockFlagsForEntries).not.toHaveBeenCalled(); + expect(mockOpenCount).not.toHaveBeenCalled(); + }; + + it("refuses someone who is not an admin, reads and writes alike", async () => { + adminRow = undefined; + const caller = callerAs("hacker_user"); + + for (const run of [...reads(caller), ...writes(caller)]) { + await expect(run()).rejects.toMatchObject({ code: "FORBIDDEN" }); + } + untouched(); + }); + + it("refuses a volunteer, who only staffs the scanner", async () => { + adminRow = { userId: "vol_user", isActive: true, role: "volunteer" }; + const caller = callerAs("vol_user"); + + for (const run of [...reads(caller), ...writes(caller)]) { + await expect(run()).rejects.toMatchObject({ code: "FORBIDDEN" }); + } + untouched(); + }); + + it("refuses someone who is not signed in", async () => { + const caller = callerAs(undefined); + + for (const run of [...reads(caller), ...writes(caller)]) { + await expect(run()).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + } + untouched(); + }); + + it("lets a bug tester read but not write", async () => { + adminRow = { userId: "qa_user", isActive: true, role: "bug_tester" }; + const caller = callerAs("qa_user"); + route = (call) => + call.table === hackathons || call.table === judgingConfigs + ? undefined + : []; + + for (const run of reads(caller)) await run(); + for (const run of writes(caller)) { + await expect(run()).rejects.toMatchObject({ code: "FORBIDDEN" }); + } + + expect(updates).toEqual([]); + expect(audits()).toEqual([]); + }); +}); diff --git a/packages/api/src/.internal-tests/check-in-flags-results.test.ts b/packages/api/src/.internal-tests/check-in-flags-results.test.ts new file mode 100644 index 00000000..bd001ea2 --- /dev/null +++ b/packages/api/src/.internal-tests/check-in-flags-results.test.ts @@ -0,0 +1,1381 @@ +/** + * Check-in flags meeting results: a team an organiser pulled from prizes is not + * ranked by judging.results.compute, judging.results.list tells the organiser + * how many flags are open on each placed team, and the public hackathon.getResults + * drops a pulled team at once without ever carrying flag data. + * + * It also covers what calibration adds to those procedures: compute stores the + * judge-adjusted score with the raw mean beside it, the organiser's list returns + * both and a per-judge summary, and the public payload carries neither the raw + * score nor anything about a judge. + * + * It covers which evaluations count (every rating, whether or not a written + * response is missing, as with a draft submitted at the deadline), and the + * calibration the list keeps for a minute, which compute never reads. + * + * The schema is the real one; only the database handle is a stand-in, handed + * to the router through the context the way judge-edge.test.ts does. Its + * selects answer by the table they read from, and the entries query applies + * the filters it is given, so a filter taken out of the router changes the + * result instead of passing unnoticed. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { + hackathonCheckInFlags, + hackathonEvents, + hackathons, + judgingAppointments, + judgingChallenges, + judgingConfigs, + judgingEntries, + judgingEntryChallenges, + judgingEntryMembers, + judgingEvaluationDrafts, + judgingEvaluationRatings, + judgingEvaluationResponses, + judgingEvaluations, + judgingJudges, + judgingPlacements, + judgingRubricItems, +} from "@query/db"; +import { appRouter } from "../root"; +import { cache } from "../middleware/cache"; + +const HACK = "11111111-1111-4111-8111-111111111111"; +const ADMIN_ROW = { userId: "admin_user", isActive: true, role: "admin" }; + +const render = (condition: unknown) => + new PgDialect().sqlToQuery(condition as never); + +type Query = { + table: unknown; + wheres: unknown[]; + /** The columns the select asked for, to tell two reads of one table apart. */ + fields?: Record; +}; + +const mockSelect = vi.fn<(query: Query) => unknown[]>(); +const mockFindMany = vi.fn(); +const inserts: { table: unknown; values: any }[] = []; +const deletes: unknown[] = []; + +const settled = (value: T) => Object.assign(Promise.resolve(value), {}); + +const selectChain = (fields?: Record) => { + const query: Query = { table: null, wheres: [], fields }; + const chain: any = { + then: (onOk: any, onErr: any) => + Promise.resolve(mockSelect(query)).then(onOk, onErr), + }; + chain.from = (table: unknown) => { + query.table = table; + return chain; + }; + chain.where = (condition: unknown) => { + query.wheres.push(condition); + return chain; + }; + for (const method of [ + "innerJoin", + "leftJoin", + "groupBy", + "orderBy", + "limit", + "offset", + "for", + ]) { + chain[method] = () => chain; + } + return chain; +}; + +const fakeDb: any = { + query: { + admins: { findFirst: async () => ADMIN_ROW }, + hackathons: { + findFirst: async () => ({ id: HACK, status: "completed" }), + }, + hackathonResults: { findMany: async () => [] }, + judgingPlacements: { + findMany: (...args: unknown[]) => mockFindMany(...args), + }, + }, + select: (fields?: Record) => selectChain(fields), + transaction: async (callback: (tx: unknown) => unknown) => callback(fakeDb), + insert: (table: unknown) => ({ + values: (values: unknown) => { + inserts.push({ table, values }); + return Object.assign(Promise.resolve(undefined), { + returning: async () => [{ id: "row" }], + onConflictDoNothing: () => ({ returning: async () => [{ id: "row" }] }), + }); + }, + }), + delete: (table: unknown) => ({ + where: () => { + deletes.push(table); + return settled(undefined); + }, + }), +}; + +const adminCaller = () => + appRouter.createCaller({ + db: fakeDb, + session: { user: { id: "admin_user" } }, + userId: "admin_user", + cache, + clientIp: "127.0.0.1", + req: { headers: { get: () => null } }, + } as any); + +const anonCaller = () => + appRouter.createCaller({ + db: fakeDb, + session: null, + userId: undefined, + cache, + clientIp: "127.0.0.1", + req: { headers: { get: () => null } }, + } as any); + +const placementsInserted = () => + inserts + .filter((write) => write.table === judgingPlacements) + .flatMap((write) => write.values as any[]); + +beforeEach(() => { + vi.clearAllMocks(); + cache.clear(); + inserts.length = 0; + deletes.length = 0; + mockSelect.mockReset(); + mockFindMany.mockReset(); +}); + +// --------------------------------------------------------------------------- +describe("judging.results.compute", () => { + const entries = [ + { id: "entry-a", deletedAt: null, prizeIneligibleAt: null }, + // Pulled from prizes. Its judges scored it highest of the three. + { id: "entry-b", deletedAt: null, prizeIneligibleAt: new Date() }, + { id: "entry-c", deletedAt: null, prizeIneligibleAt: null }, + ]; + const evaluationRow = ( + id: string, + entryId: string, + judgeId: string, + minute: number, + ) => ({ + id, + entryId, + challengeId: "ch-1", + judgeId, + submittedAt: new Date(Date.UTC(2027, 1, 6, 10, minute)), + }); + // One judge marked all three. With the pulled team left out of the + // calibration she has two grades, a mean of 3.5 and the pool's own spread, so + // nothing about her scores moves; with it counted, they would. + const scored = [ + evaluationRow("eval-a", "entry-a", "judge-1", 0), + evaluationRow("eval-b", "entry-b", "judge-1", 1), + evaluationRow("eval-c", "entry-c", "judge-1", 2), + ]; + const ratings = [ + { evaluationId: "eval-a", value: 4 }, + { evaluationId: "eval-b", value: 5 }, + { evaluationId: "eval-c", value: 3 }, + ]; + + let entryWheres: unknown[] = []; + + const wire = (stored = entries) => { + entryWheres = []; + mockSelect.mockImplementation(({ table, wheres }) => { + if (table === hackathons) { + return [ + { id: HACK, name: "Hacklytics", startDate: null, endDate: null }, + ]; + } + // Nothing is due, so reconcileDueDrafts returns at once. + if (table === judgingEvaluationDrafts) return []; + if (table === judgingConfigs) return [{ hackathonId: HACK }]; + // Nothing is published yet. + if (table === judgingPlacements) return [{ n: 0 }]; + if (table === judgingEntries) { + entryWheres = wheres; + // What the database would hand back for the conditions asked for. + const asked = wheres.map((where) => render(where).sql).join(" "); + return stored.filter( + (entry) => + (!asked.includes('"deleted_at" is null') || !entry.deletedAt) && + (!asked.includes('"prize_ineligible_at" is null') || + !entry.prizeIneligibleAt), + ); + } + if (table === judgingEntryChallenges) return []; + if (table === judgingChallenges) return []; + if (table === judgingEvaluations) return scored; + if (table === judgingEvaluationRatings) return ratings; + // One rating item, which every evaluation here rated. + if (table === judgingRubricItems) return [{ n: 1 }]; + return []; + }); + }; + + it("leaves a team pulled from prizes out of the ranking", async () => { + wire(); + + const result = await adminCaller().judging.results.compute({ + hackathonId: HACK, + }); + + const placed = placementsInserted(); + expect(placed.map((row) => row.entryId)).not.toContain("entry-b"); + // The next team moves up: entry-a is 1st on 4.00 and entry-c 2nd on 3.00, + // although entry-b's 5.00 would have taken first place. + expect( + placed.map((row) => [row.entryId, row.scope, row.placement, row.score]), + ).toEqual([ + ["entry-a", "overall", 1, "4.00"], + ["entry-c", "overall", 2, "3.00"], + ]); + expect(placed.map((row) => row.rawScore)).toEqual(["4.00", "3.00"]); + expect(result.placements).toBe(2); + }); + + it("still counts the pulled team's evaluations as scored", async () => { + wire(); + + const result = await adminCaller().judging.results.compute({ + hackathonId: HACK, + }); + + // Judges keep scoring it; only the placing is withheld. + expect(result.evaluations).toBe(3); + }); + + it("asks for live, prize-eligible entries of the edition", async () => { + wire(); + + await adminCaller().judging.results.compute({ hackathonId: HACK }); + + const query = render(entryWheres[0]); + expect(query.params).toContain(HACK); + expect(query.sql).toContain('"deleted_at" is null'); + expect(query.sql).toContain('"prize_ineligible_at" is null'); + }); + + it("ranks everyone when nobody is pulled", async () => { + wire(entries.map((entry) => ({ ...entry, prizeIneligibleAt: null }))); + + await adminCaller().judging.results.compute({ hackathonId: HACK }); + + expect(placementsInserted().map((row) => row.entryId)).toEqual([ + "entry-b", + "entry-a", + "entry-c", + ]); + }); +}); + +describe("judging.results.compute with judge calibration", () => { + const entries = ["e1", "e2", "e3", "e4"].map((id) => ({ + id, + deletedAt: null, + prizeIneligibleAt: null, + })); + const evaluation = ( + id: string, + entryId: string, + judgeId: string, + minute: number, + ) => ({ + id, + entryId, + challengeId: "ch-1", + judgeId, + submittedAt: new Date(Date.UTC(2027, 1, 6, 10, minute)), + }); + + // A strict judge marked e1 and e2, a lenient one e3 and e4. Raw, e2 and e3 + // tie on 4.00 and the lenient judge's e4 runs away with first. + const wire = () => + mockSelect.mockImplementation(({ table }) => { + if (table === hackathons) { + return [ + { id: HACK, name: "Hacklytics", startDate: null, endDate: null }, + ]; + } + if (table === judgingEvaluationDrafts) return []; + if (table === judgingConfigs) return [{ hackathonId: HACK }]; + if (table === judgingPlacements) return [{ n: 0 }]; + if (table === judgingEntries) return entries; + if (table === judgingEvaluations) { + return [ + evaluation("v1", "e1", "strict", 0), + evaluation("v2", "e2", "strict", 1), + evaluation("v3", "e3", "lenient", 2), + evaluation("v4", "e4", "lenient", 3), + ]; + } + if (table === judgingEvaluationRatings) { + return [ + { evaluationId: "v1", value: 3 }, + { evaluationId: "v2", value: 4 }, + { evaluationId: "v3", value: 4 }, + { evaluationId: "v4", value: 5 }, + ]; + } + if (table === judgingRubricItems) return [{ n: 1 }]; + return []; + }); + + it("stores the judge-adjusted score with the raw mean beside it", async () => { + wire(); + + await adminCaller().judging.results.compute({ hackathonId: HACK }); + + // Pool mean 4. Two grades each gives an offset of 2/5 of the 0.5 gap: the + // strict judge is lifted 0.2, the lenient one lowered 0.2. Both spread + // their marks by the same 0.5, so neither is stretched. + expect( + placementsInserted().map((row) => [ + row.entryId, + row.placement, + row.score, + row.rawScore, + ]), + ).toEqual([ + ["e4", 1, "4.80", "5.00"], + ["e2", 2, "4.20", "4.00"], + ["e3", 3, "3.80", "4.00"], + ["e1", 4, "3.20", "3.00"], + ]); + }); +}); + +// --------------------------------------------------------------------------- +describe("judging.results.compute with grades submitted at the deadline", () => { + const entries = ["entry-a", "entry-b", "entry-c"].map((id) => ({ + id, + deletedAt: null, + prizeIneligibleAt: null, + })); + const evaluation = ( + id: string, + entryId: string, + minute: number, + isComplete: boolean, + ) => ({ + id, + entryId, + challengeId: "ch-1", + judgeId: "judge-1", + submittedAt: new Date(Date.UTC(2027, 1, 6, 10, minute)), + isComplete, + }); + // A rubric of two rating items. entry-a's only grade is an auto-submit: both + // ratings, a required response left empty, so isComplete is false. entry-b's + // stopped after one rating. entry-c's is whole. + const stored = [ + evaluation("eval-a", "entry-a", 0, false), + evaluation("eval-b", "entry-b", 1, false), + evaluation("eval-c", "entry-c", 2, true), + ]; + const ratings = [ + { evaluationId: "eval-a", value: 4 }, + { evaluationId: "eval-a", value: 4 }, + { evaluationId: "eval-b", value: 5 }, + { evaluationId: "eval-c", value: 3 }, + { evaluationId: "eval-c", value: 3 }, + ]; + let evaluationWheres: unknown[] = []; + let ratingWheres: unknown[] = []; + + const wire = () => { + evaluationWheres = []; + ratingWheres = []; + mockSelect.mockImplementation(({ table, wheres }) => { + if (table === hackathons) { + return [ + { id: HACK, name: "Hacklytics", startDate: null, endDate: null }, + ]; + } + if (table === judgingEvaluationDrafts) return []; + if (table === judgingConfigs) return [{ hackathonId: HACK }]; + if (table === judgingPlacements) return [{ n: 0 }]; + if (table === judgingEntries) return entries; + if (table === judgingRubricItems) return [{ n: 2 }]; + if (table === judgingEvaluations) { + evaluationWheres = wheres; + return stored; + } + if (table === judgingEvaluationRatings) { + ratingWheres = wheres; + return ratings; + } + return []; + }); + }; + + it("places a project whose only grade is such an auto-submit", async () => { + wire(); + + const result = await adminCaller().judging.results.compute({ + hackathonId: HACK, + }); + + const placed = placementsInserted(); + expect( + placed.map((row) => [row.entryId, row.scope, row.placement, row.score]), + ).toEqual([ + ["entry-a", "overall", 1, "4.00"], + ["entry-c", "overall", 2, "3.00"], + ]); + expect(placed.map((row) => row.evaluationCount)).toEqual([1, 1]); + expect(result.placements).toBe(2); + }); + + it("leaves a grade short of a rating out, and does not count it as ranked", async () => { + wire(); + + const result = await adminCaller().judging.results.compute({ + hackathonId: HACK, + }); + + expect(placementsInserted().map((row) => row.entryId)).not.toContain( + "entry-b", + ); + expect(result.evaluations).toBe(2); + }); + + it("does not filter evaluations or ratings on isComplete", async () => { + wire(); + + await adminCaller().judging.results.compute({ hackathonId: HACK }); + + expect(evaluationWheres.length).toBeGreaterThan(0); + expect(ratingWheres.length).toBeGreaterThan(0); + for (const where of [...evaluationWheres, ...ratingWheres]) { + expect(render(where).sql).not.toContain("is_complete"); + expect(render(where).params).toContain(HACK); + } + }); + + it("asks the rubric how many rating items an evaluation must have", async () => { + wire(); + + await adminCaller().judging.results.compute({ hackathonId: HACK }); + + const rubric = mockSelect.mock.calls.find( + ([query]) => query.table === judgingRubricItems, + )![0]; + expect(render(rubric.wheres[0]).params).toEqual([HACK, "rating"]); + }); +}); + +// --------------------------------------------------------------------------- +describe("judging.results.compute beside the list's calibration cache", () => { + const entries = ["e1", "e2", "e3", "e4"].map((id) => ({ + id, + deletedAt: null, + prizeIneligibleAt: null, + })); + const evaluation = ( + id: string, + entryId: string, + judgeId: string, + minute: number, + ) => ({ + id, + entryId, + challengeId: "ch-1", + judgeId, + submittedAt: new Date(Date.UTC(2027, 1, 6, 10, minute)), + }); + const stored = [ + evaluation("v1", "e1", "strict", 0), + evaluation("v2", "e2", "strict", 1), + evaluation("v3", "e3", "lenient", 2), + evaluation("v4", "e4", "lenient", 3), + ]; + let ratings = [ + { evaluationId: "v1", value: 3 }, + { evaluationId: "v2", value: 4 }, + { evaluationId: "v3", value: 4 }, + { evaluationId: "v4", value: 5 }, + ]; + const computedAt = new Date("2027-02-07T09:00:00Z"); + + beforeEach(() => { + ratings = [ + { evaluationId: "v1", value: 3 }, + { evaluationId: "v2", value: 4 }, + { evaluationId: "v3", value: 4 }, + { evaluationId: "v4", value: 5 }, + ]; + }); + + // The same edition answers both procedures. Placements come back as rows for + // the list and as a published count for compute, which asks for `n`. + const wire = () => + mockSelect.mockImplementation(({ table, fields }) => { + if (table === hackathons) { + return [ + { id: HACK, name: "Hacklytics", startDate: null, endDate: null }, + ]; + } + if (table === judgingPlacements) { + return fields && "n" in fields + ? [{ n: 0 }] + : [ + { + id: "p1", + scope: "overall", + scopeLabel: "Overall", + placement: 1, + score: "4.00", + evaluationCount: 1, + publishedAt: null, + computedAt, + entryId: "e1", + title: "Project e1", + deletedAt: null, + prizeIneligibleAt: null, + }, + ]; + } + if (table === judgingEvaluationDrafts) return []; + if (table === judgingConfigs) return [{ hackathonId: HACK }]; + if (table === judgingEntries) { + return fields && "latest" in fields + ? [{ n: 4, latest: new Date("2027-02-06T09:00:00Z") }] + : entries; + } + if (table === judgingEvaluations) { + if (fields && "latest" in fields) { + return [{ n: 4, latest: new Date("2027-02-06T11:00:00Z") }]; + } + return fields && "n" in fields ? [{ n: 0 }] : stored; + } + if (table === judgingRubricItems) return [{ n: 1 }]; + if (table === judgingEvaluationRatings) return ratings; + if (table === judgingJudges) { + return [ + { id: "strict", displayName: "Sam Strict", kind: "member" }, + { id: "lenient", displayName: "Lee Lenient", kind: "member" }, + ]; + } + if (table === judgingChallenges) { + return [{ id: "ch-1", label: "General" }]; + } + return []; + }); + + const ratingReads = () => + mockSelect.mock.calls.filter( + ([query]) => query.table === judgingEvaluationRatings, + ).length; + + it("ranks on the evaluations as they stand, whatever the list has cached", async () => { + wire(); + const getOrSet = vi.spyOn(cache, "getOrSet"); + + // The list fills the cache, and a second poll is answered from it. + await adminCaller().judging.results.list({ hackathonId: HACK }); + await adminCaller().judging.results.list({ hackathonId: HACK }); + expect(ratingReads()).toBe(1); + expect(getOrSet).toHaveBeenCalledTimes(2); + + // A rating changes under it, with nothing the cache key could see. + ratings = ratings.map((rating) => + rating.evaluationId === "v4" ? { ...rating, value: 1 } : rating, + ); + await adminCaller().judging.results.compute({ hackathonId: HACK }); + + // Compute read the ratings itself and did not touch the cache. + expect(ratingReads()).toBe(2); + expect(getOrSet).toHaveBeenCalledTimes(2); + // v4 is the lenient judge's lowest now, so e4 ranks last. + expect(placementsInserted().at(-1)?.entryId).toBe("e4"); + getOrSet.mockRestore(); + }); + + it("reads the ratings again on every compute", async () => { + wire(); + + await adminCaller().judging.results.compute({ hackathonId: HACK }); + await adminCaller().judging.results.compute({ hackathonId: HACK }); + + expect(ratingReads()).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +describe("judging.results.list", () => { + // What the calibration cache key is read from: the edition's evaluations and + // its ranked entries, each a count and a latest save. A test changes one + // between two lists to stand for a save, a reopen or a pulled team. + const stamps = { + evaluations: { n: 4, latest: new Date("2027-02-06T11:00:00Z") } as Record< + string, + unknown + >, + entries: { n: 6, latest: new Date("2027-02-06T09:00:00Z") } as Record< + string, + unknown + >, + }; + beforeEach(() => { + stamps.evaluations = { n: 4, latest: new Date("2027-02-06T11:00:00Z") }; + stamps.entries = { n: 6, latest: new Date("2027-02-06T09:00:00Z") }; + }); + + const COMPUTED_AT = new Date("2027-02-07T09:00:00Z"); + const placement = ( + id: string, + entryId: string, + over: Record = {}, + ) => ({ + id, + scope: "overall", + scopeLabel: "Overall", + placement: 1, + score: "4.00", + evaluationCount: 2, + publishedAt: null, + computedAt: COMPUTED_AT, + entryId, + title: `Project ${entryId}`, + deletedAt: null, + prizeIneligibleAt: null, + ...over, + }); + + const flagRow = (id: string, over: Record = {}) => ({ + id, + participantId: `part-${id}`, + eventId: "event-2", + conflictEventId: "event-1", + rule: "overlap", + method: "code", + attempts: 1, + createdAt: new Date("2027-02-06T18:00:00Z"), + lastAttemptAt: new Date("2027-02-06T18:00:00Z"), + dismissedAt: null, + teamId: "team-1", + userId: `user-${id}`, + firstName: "Secret", + lastName: `Hacker${id}`, + accountName: null, + email: `${id}@example.com`, + ...over, + }); + + const wire = (opts: { + placements: unknown[]; + flags?: unknown[]; + portal?: unknown[]; + /** Live entries, as the calibration summary reads them. */ + live?: unknown[]; + /** Complete evaluations, as the calibration summary reads them. */ + evaluations?: unknown[]; + ratings?: unknown[]; + /** Rating items on the rubric. The fixtures rate one each unless said. */ + ratingItems?: number; + judges?: unknown[]; + challenges?: unknown[]; + }) => { + mockSelect.mockImplementation(({ table, fields }) => { + if (table === judgingPlacements) return opts.placements; + // The cache key reads a count and a latest time, the stale count reads + // a count alone, and the calibration summary reads many columns. + if (table === judgingEvaluations) { + if (fields && "latest" in fields) return [stamps.evaluations]; + return fields && "n" in fields ? [{ n: 0 }] : (opts.evaluations ?? []); + } + if (table === judgingRubricItems) return [{ n: opts.ratingItems ?? 1 }]; + if (table === judgingEvaluationRatings) return opts.ratings ?? []; + if (table === judgingJudges) return opts.judges ?? []; + if (table === judgingChallenges) return opts.challenges ?? []; + if (table === hackathonCheckInFlags) return opts.flags ?? []; + if (table === hackathonEvents) { + return [ + { id: "event-1", name: "Opening Workshop" }, + { id: "event-2", name: "Sponsor Talk" }, + ]; + } + if (table === judgingEntries) { + if (fields && "latest" in fields) return [stamps.entries]; + return opts.portal ?? opts.live ?? []; + } + if (table === judgingEntryMembers) return []; + return []; + }); + }; + + const rowsOf = (data: { scopes: { placements: { entryId: string }[] }[] }) => + data.scopes.flatMap((scope) => scope.placements); + + it("counts the open flags on each placed team and skips dismissed ones", async () => { + wire({ + placements: [ + placement("p1", "entry-a"), + placement("p2", "entry-b", { placement: 2 }), + ], + // Team one has two open flags and a cleared one; entry-b has none. + flags: [ + flagRow("f1"), + flagRow("f2", { participantId: "part-f1b" }), + flagRow("f3", { dismissedAt: new Date("2027-02-06T20:00:00Z") }), + ], + portal: [{ entryId: "entry-a", teamId: "team-1", submittedById: null }], + }); + + const data = await adminCaller().judging.results.list({ + hackathonId: HACK, + }); + + const byEntry = Object.fromEntries( + rowsOf(data).map((row) => [row.entryId, row]), + ) as Record; + expect(byEntry["entry-a"].openCheckInFlags).toBe(2); + expect(byEntry["entry-b"].openCheckInFlags).toBe(0); + }); + + it("says which placed teams are pulled from prizes", async () => { + wire({ + placements: [ + placement("p1", "entry-a"), + placement("p2", "entry-b", { + placement: 2, + prizeIneligibleAt: new Date("2027-02-07T10:00:00Z"), + }), + ], + }); + + const data = await adminCaller().judging.results.list({ + hackathonId: HACK, + }); + + const byEntry = Object.fromEntries( + rowsOf(data).map((row) => [row.entryId, row]), + ) as Record; + expect(byEntry["entry-a"].prizeIneligible).toBe(false); + expect(byEntry["entry-b"].prizeIneligible).toBe(true); + expect(byEntry["entry-a"].openCheckInFlags).toBe(0); + }); + + it("carries a count and a flag, never who or why", async () => { + wire({ + placements: [placement("p1", "entry-a")], + flags: [flagRow("f1")], + portal: [{ entryId: "entry-a", teamId: "team-1", submittedById: null }], + }); + + const data = await adminCaller().judging.results.list({ + hackathonId: HACK, + }); + + const text = JSON.stringify(data); + expect(text).not.toContain("Secret"); + expect(text).not.toContain("example.com"); + expect(text).not.toContain("Opening Workshop"); + expect(text).not.toContain("Sponsor Talk"); + expect(Object.keys(rowsOf(data)[0]!).sort()).toEqual([ + "entryId", + "evaluationCount", + "id", + "openCheckInFlags", + "placement", + "prizeIneligible", + "rawScore", + "score", + "title", + "withdrawn", + ]); + }); + + it("returns the raw score beside the adjusted one, and null on older rows", async () => { + wire({ + placements: [ + placement("p1", "entry-a", { score: "4.20", rawScore: "3.90" }), + placement("p2", "entry-b", { placement: 2, rawScore: null }), + ], + }); + + const data = await adminCaller().judging.results.list({ + hackathonId: HACK, + }); + + const byEntry = Object.fromEntries( + rowsOf(data).map((row) => [row.entryId, row]), + ) as Record; + expect(byEntry["entry-a"].score).toBe(4.2); + expect(byEntry["entry-a"].rawScore).toBe(3.9); + expect(byEntry["entry-b"].rawScore).toBeNull(); + }); + + describe("judge calibration summary", () => { + const graded = ( + id: string, + entryId: string, + judgeId: string, + challengeId: string, + minute: number, + ) => ({ + id, + entryId, + challengeId, + judgeId, + submittedAt: new Date(Date.UTC(2027, 1, 6, 10, minute)), + }); + const challenges = [ + { id: "ch-sponsor", label: "Sponsor Prize" }, + { id: "ch-general", label: "General" }, + ]; + const judges = [ + { id: "strict", displayName: "Sam Strict", kind: "member" }, + { id: "lenient", displayName: "Lee Lenient", kind: "guest" }, + { id: "sponsor-guest", displayName: "Gus Guest", kind: "guest" }, + ]; + // Six table grades by two judges, and two by the sponsor's own guest. + const table = [ + ["g1", "strict", 3], + ["g2", "strict", 3], + ["g3", "strict", 4], + ["g4", "lenient", 4], + ["g5", "lenient", 5], + ["g6", "lenient", 5], + ] as const; + const evaluations = [ + ...table.map(([id, judgeId], i) => + graded(id, `entry-${i}`, judgeId, "ch-general", i), + ), + graded("s1", "entry-0", "sponsor-guest", "ch-sponsor", 10), + graded("s2", "entry-1", "sponsor-guest", "ch-sponsor", 11), + ]; + const ratings = [ + ...table.map(([id, , value]) => ({ evaluationId: id, value })), + { evaluationId: "s1", value: 2 }, + { evaluationId: "s2", value: 2 }, + ]; + const live = Array.from({ length: 6 }, (_, i) => ({ id: `entry-${i}` })); + + it("is computed on read, per challenge, one row for each judge", async () => { + wire({ + placements: [placement("p1", "entry-0")], + evaluations, + ratings, + judges, + challenges, + live, + }); + + const data = await adminCaller().judging.results.list({ + hackathonId: HACK, + }); + + expect(data.calibration.map((c) => c.challenge)).toEqual([ + "Sponsor Prize", + "General", + ]); + const [sponsor, general] = data.calibration; + // The sponsor's guest is compared with no one at the tables. + expect(sponsor!.judges).toHaveLength(1); + expect(sponsor!.judges[0]).toMatchObject({ + judgeName: "Gus Guest", + kind: "guest", + grades: 2, + offset: 0, + spread: 1, + drift: 0, + }); + expect(general!.judges.map((j) => [j.judgeName, j.grades])).toEqual([ + ["Lee Lenient", 3], + ["Sam Strict", 3], + ]); + const [lee, sam] = general!.judges; + expect(lee!.offset).toBeGreaterThan(0); + expect(sam!.offset).toBeLessThan(0); + // Rounded to two places. + for (const judge of general!.judges) { + for (const value of [judge.offset, judge.spread, judge.drift]) { + expect(value).toBe(Math.round(value * 100) / 100); + } + } + }); + + it("leaves a team pulled from prizes out of the summary", async () => { + wire({ + placements: [placement("p1", "entry-0")], + evaluations, + ratings, + judges, + challenges, + // Only the first four teams are live and prize-eligible. + live: live.slice(0, 4), + }); + + const data = await adminCaller().judging.results.list({ + hackathonId: HACK, + }); + + const general = data.calibration.find((c) => c.challenge === "General"); + expect(general!.judges.map((j) => j.grades)).toEqual([1, 3]); + }); + + it("is empty before anything is computed", async () => { + wire({ placements: [], evaluations, ratings, judges, challenges, live }); + + const data = await adminCaller().judging.results.list({ + hackathonId: HACK, + }); + + expect(data.calibration).toEqual([]); + }); + + it("counts a grade submitted at the deadline with every rating, and not one short of a rating", async () => { + // Two rating items. g1 is an auto-submit: all its ratings, a response + // left empty, so the evaluation is incomplete. g3 stopped after one. + const two = [ + { ...evaluations[0]!, isComplete: false }, + evaluations[1]!, + evaluations[2]!, + ]; + const rated = [ + { evaluationId: "g1", value: 3 }, + { evaluationId: "g1", value: 3 }, + { evaluationId: "g2", value: 3 }, + { evaluationId: "g2", value: 3 }, + { evaluationId: "g3", value: 4 }, + ]; + wire({ + placements: [placement("p1", "entry-0")], + evaluations: two, + ratings: rated, + ratingItems: 2, + judges, + challenges, + live, + }); + + const data = await adminCaller().judging.results.list({ + hackathonId: HACK, + }); + + const general = data.calibration.find((c) => c.challenge === "General"); + expect(general!.judges.map((j) => [j.judgeName, j.grades])).toEqual([ + ["Sam Strict", 2], + ]); + }); + + describe("cache", () => { + const list = () => + adminCaller().judging.results.list({ hackathonId: HACK }); + // The heavy read: every rating of the edition. + const calibrations = () => + mockSelect.mock.calls.filter( + ([query]) => query.table === judgingEvaluationRatings, + ).length; + const setUp = () => + wire({ + placements: [placement("p1", "entry-0")], + evaluations, + ratings, + judges, + challenges, + live, + }); + + it("calibrates once for polls that find nothing changed", async () => { + setUp(); + + const first = await list(); + const second = await list(); + const third = await list(); + + expect(calibrations()).toBe(1); + expect(second.calibration).toEqual(first.calibration); + expect(third.calibration).toEqual(first.calibration); + expect(first.calibration.length).toBeGreaterThan(0); + }); + + it("calibrates again when an evaluation is added or deleted", async () => { + setUp(); + await list(); + + stamps.evaluations = { ...stamps.evaluations, n: 5 }; + await list(); + expect(calibrations()).toBe(2); + + // Two grades reopened, the newest among them: fewer, and an earlier + // latest save. + stamps.evaluations = { + n: 3, + latest: new Date("2027-02-06T10:30:00Z"), + }; + await list(); + expect(calibrations()).toBe(3); + }); + + it("calibrates again when an evaluation is edited", async () => { + setUp(); + await list(); + + // Same count, saved later. + stamps.evaluations = { + ...stamps.evaluations, + latest: new Date("2027-02-06T11:05:00Z"), + }; + await list(); + + expect(calibrations()).toBe(2); + }); + + it("calibrates again when a team is pulled from prizes or put back", async () => { + setUp(); + await list(); + + stamps.entries = { n: 5, latest: new Date("2027-02-07T08:00:00Z") }; + await list(); + expect(calibrations()).toBe(2); + + // Back in, a moment later: the count is as it was, the time is not. + stamps.entries = { n: 6, latest: new Date("2027-02-07T08:01:00Z") }; + await list(); + expect(calibrations()).toBe(3); + }); + + it("keys the result by edition and by what it was computed from", async () => { + setUp(); + const getOrSet = vi.spyOn(cache, "getOrSet"); + + await list(); + + expect(getOrSet).toHaveBeenCalledTimes(1); + const [key, , ttl] = getOrSet.mock.calls[0]!; + const latest = (stamps.evaluations.latest as Date).getTime(); + const entries = (stamps.entries.latest as Date).getTime(); + expect(key).toBe( + `judging:${HACK}:calibration:4:${latest}:6:${entries}`, + ); + expect(ttl).toBe(60); + getOrSet.mockRestore(); + }); + + it("does not share a result between editions", async () => { + setUp(); + await list(); + + await adminCaller().judging.results.list({ + hackathonId: "99999999-9999-4999-8999-999999999999", + }); + + expect(calibrations()).toBe(2); + }); + + it("keeps the result for a minute and no longer", async () => { + setUp(); + const getOrSet = vi.spyOn(cache, "getOrSet"); + await list(); + const key = getOrSet.mock.calls[0]![0]; + getOrSet.mockRestore(); + + // Only the clock the cache reads is moved: the router's own rate + // limiter keeps real time. + const clock = vi.spyOn(Date, "now"); + const now = Date.now(); + try { + clock.mockReturnValue(now + 59_000); + expect(cache.has(key)).toBe(true); + clock.mockReturnValue(now + 61_000); + expect(cache.has(key)).toBe(false); + } finally { + clock.mockRestore(); + } + }); + }); + }); + + it("reads no flags when nothing is placed", async () => { + wire({ placements: [] }); + + const data = await adminCaller().judging.results.list({ + hackathonId: HACK, + }); + + expect(data.scopes).toEqual([]); + const tables = mockSelect.mock.calls.map(([query]) => query.table); + expect(tables).not.toContain(hackathonCheckInFlags); + }); +}); + +// --------------------------------------------------------------------------- +describe("the organiser's evaluation views with a grade short of a response", () => { + const APPOINTMENT = "33333333-3333-4333-8333-333333333333"; + const at = new Date("2027-02-06T11:00:00Z"); + // A rubric of two rating items. Judge A's grade was submitted at the + // deadline with both ratings and a response left empty. B's is whole. C's + // stopped after one rating. + const evaluations = [ + { + id: "eval-a", + entryId: "entry-1", + title: "Project 1", + challengeId: "ch-1", + challenge: "General", + judgeId: "judge-a", + judgeName: "Ada Auto", + kind: "member", + appointmentId: APPOINTMENT, + isComplete: false, + revision: 1, + submittedAt: at, + autoSubmittedAt: at, + updatedAt: at, + }, + { + id: "eval-b", + entryId: "entry-1", + title: "Project 1", + challengeId: "ch-1", + challenge: "General", + judgeId: "judge-b", + judgeName: "Bea Whole", + kind: "member", + appointmentId: APPOINTMENT, + isComplete: true, + revision: 1, + submittedAt: at, + autoSubmittedAt: null, + updatedAt: at, + }, + { + id: "eval-c", + entryId: "entry-1", + title: "Project 1", + challengeId: "ch-1", + challenge: "General", + judgeId: "judge-c", + judgeName: "Cy Short", + kind: "member", + appointmentId: APPOINTMENT, + isComplete: false, + revision: 1, + submittedAt: at, + autoSubmittedAt: at, + updatedAt: at, + }, + ]; + const rating = ( + evaluationId: string, + item: "one" | "two", + value: number, + ) => ({ + evaluationId, + itemId: `item-${item}`, + label: item === "one" ? "Impact" : "Craft", + value, + }); + const ratings = [ + rating("eval-a", "one", 5), + rating("eval-a", "two", 3), + rating("eval-b", "one", 4), + rating("eval-b", "two", 4), + rating("eval-c", "one", 2), + ]; + + const wire = () => + mockSelect.mockImplementation(({ table, fields }) => { + if (table === judgingEvaluationDrafts) return []; + if (table === judgingRubricItems) return [{ n: 2 }]; + if (table === judgingEvaluationRatings) return ratings; + if (table === judgingEvaluationResponses) return []; + if (table === judgingAppointments) { + return [ + { + id: APPOINTMENT, + entryId: "entry-1", + challengeId: "ch-1", + startsAt: at, + endsAt: at, + title: "Project 1", + challenge: "General", + room: "Table 1", + building: null, + }, + ]; + } + if (table === judgingEvaluations) { + // The "elsewhere" check reads the id alone; the rest read the judge. + return fields && "judgeName" in fields ? evaluations : []; + } + return []; + }); + + describe("appointmentResults", () => { + const results = () => + adminCaller().judging.results.appointmentResults({ + hackathonId: HACK, + appointmentId: APPOINTMENT, + }); + + it("counts a grade with every rating, whether or not it is complete", async () => { + wire(); + + const data = await results(); + + const byName = Object.fromEntries( + data.judges.map((judge) => [judge.judgeName, judge]), + ); + expect(byName["Ada Auto"]).toMatchObject({ + status: "incomplete", + autoSubmitted: true, + counted: true, + score: 4, + }); + expect(byName["Ada Auto"]!.ratings.map((r) => r.value)).toEqual([5, 3]); + expect(byName["Bea Whole"]).toMatchObject({ + status: "complete", + counted: true, + score: 4, + }); + }); + + it("leaves a grade short of a rating out of the score and the average", async () => { + wire(); + + const data = await results(); + + const short = data.judges.find((judge) => judge.judgeName === "Cy Short"); + expect(short).toMatchObject({ counted: false, score: null, ratings: [] }); + expect(data.countedCount).toBe(2); + expect(data.average).toBe(4); + }); + }); + + describe("evaluationAudit", () => { + it("says which evaluations the placings count", async () => { + wire(); + + const rows = await adminCaller().judging.results.evaluationAudit({ + hackathonId: HACK, + }); + + expect(rows.map((row) => [row.id, row.isComplete, row.counted])).toEqual([ + ["eval-a", false, true], + ["eval-b", true, true], + ["eval-c", false, false], + ]); + }); + }); +}); + +// --------------------------------------------------------------------------- +describe("hackathon.getResults", () => { + const placementRow = (id: string, over: Record = {}) => ({ + id, + scope: "overall", + scopeLabel: "Overall", + placement: 1, + score: "4.25", + // Stored beside the score; it must never leave in the public payload. + rawScore: "3.75", + entry: { + id: `entry-${id}`, + title: `Project ${id}`, + submissionUrl: null, + demoLinks: [], + deletedAt: null, + prizeIneligibleAt: null, + sourceProject: null, + ...over, + }, + }); + + const wire = (rows: unknown[]) => { + mockFindMany.mockImplementation(async () => rows); + }; + + it("hides a team pulled from prizes after the placings went out", async () => { + wire([ + placementRow("a"), + placementRow("b", { + prizeIneligibleAt: new Date("2027-02-07T10:00:00Z"), + }), + placementRow("c", { deletedAt: new Date("2027-02-07T10:00:00Z") }), + ]); + + const results = await anonCaller().hackathon.getResults({ + hackathonId: HACK, + }); + + expect(results.map((row) => row.projectName)).toEqual(["Project a"]); + }); + + it("reads the column it filters on", async () => { + wire([]); + + await anonCaller().hackathon.getResults({ hackathonId: HACK }); + + const args = mockFindMany.mock.calls[0]![0]; + expect(args.with.entry.columns).toMatchObject({ + deletedAt: true, + prizeIneligibleAt: true, + }); + }); + + it("carries no flag data, and reads none", async () => { + wire([ + placementRow("a"), + placementRow("b", { + prizeIneligibleAt: new Date("2027-02-07T10:00:00Z"), + }), + ]); + + const results = await anonCaller().hackathon.getResults({ + hackathonId: HACK, + }); + + expect(Object.keys(results[0]!).sort()).toEqual([ + "demoUrl", + "githubUrl", + "id", + "placement", + "projectName", + "scope", + "scopeLabel", + "score", + "submissionUrl", + "teamName", + ]); + const text = JSON.stringify(results).toLowerCase(); + expect(text).not.toContain("flag"); + expect(text).not.toContain("ineligible"); + expect(text).not.toContain("3.75"); + expect(text).not.toContain("calibrat"); + // The cached payload is built from placements alone: no select, so no + // flag table, ran for it. + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it("serves a pulled team's absence from the cache too", async () => { + wire([ + placementRow("a"), + placementRow("b", { + prizeIneligibleAt: new Date("2027-02-07T10:00:00Z"), + }), + ]); + + await anonCaller().hackathon.getResults({ hackathonId: HACK }); + const cached = await anonCaller().hackathon.getResults({ + hackathonId: HACK, + }); + + expect(mockFindMany).toHaveBeenCalledTimes(1); + expect(cached.map((row) => row.projectName)).toEqual(["Project a"]); + }); +}); diff --git a/packages/api/src/.internal-tests/evaluation-counts.test.ts b/packages/api/src/.internal-tests/evaluation-counts.test.ts new file mode 100644 index 00000000..44773b28 --- /dev/null +++ b/packages/api/src/.internal-tests/evaluation-counts.test.ts @@ -0,0 +1,363 @@ +/** + * Counts of evaluations that organisers and hackers read agree with placings: + * an evaluation counts once it holds a rating for every rating item, whether + * or not a written response was left empty, which is what an auto-submit at + * the deadline often leaves. The organiser's overview and project list, the + * hacker's feedback and the status the itinerary reports all read it that way, + * and none reads the judge's own "submitted whole" flag. + * + * The schema is the real one; only the database handle is a stand-in, handed + * to the router through the context the way check-in-flags-organizer.test.ts + * does. It answers each select by the table it reads from, so what these tests + * look at is the SQL each router asks the database to apply (the rule is + * checked against scoring.hasEveryRating in services/judging/scoring-sql.test.ts) + * and what the router makes of the rows it is given. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { + hackathonParticipants, + hackathons, + judgingAppointments, + judgingChallenges, + judgingConfigs, + judgingEntries, + judgingEntryChallenges, + judgingEntryMembers, + judgingEvaluationResponses, + judgingEvaluations, + judgingPresence, + judgingRooms, +} from "@query/db"; +import { appRouter } from "../root"; +import { cache } from "../middleware/cache"; +import type * as Store from "../services/judging/evaluation-store"; + +// Nothing is due on any read here, so there is nothing to reconcile. +vi.mock("../services/judging/evaluation-store", async (importActual) => ({ + ...(await importActual()), + reconcileDueDrafts: async () => undefined, +})); + +const HACK = "11111111-1111-4111-8111-111111111111"; +const ENTRY = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"; +const GENERAL = "cccccccc-cccc-4ccc-8ccc-ccccccccccc1"; +const ROOM = "dddddddd-dddd-4ddd-8ddd-ddddddddddd1"; +const SLOT = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1"; +const EVAL_1 = "ffffffff-ffff-4fff-8fff-fffffffffff1"; +const EVAL_2 = "ffffffff-ffff-4fff-8fff-fffffffffff2"; + +const dialect = new PgDialect(); +const render = (condition: unknown) => dialect.sqlToQuery(condition as never); +/** The SQL with its spacing and parameter numbers evened out. */ +const flat = (condition: unknown) => + render(condition) + .sql.replace(/\s+/g, " ") + .replace(/\( /g, "(") + .replace(/ \)/g, ")") + .replace(/\$\d+/g, "$?"); + +// scoring.hasEveryRating, as SQL: the evaluation's ratings are at least as +// many as the rubric's rating items. +const HOLDS_EVERY_RATING = + '(select count(*) from "judging_evaluation_rating" as r where r."evaluation_id" = "judging_evaluation"."id") >= (select count(*) from "judging_rubric_item" as i where i."hackathon_id" = $? and i."kind" = \'rating\')'; + +type Row = Record; +type SelectCall = { + fields: Record | undefined; + table: unknown; + where: unknown; +}; + +let adminRow: Row | undefined; +const selects: SelectCall[] = []; +// The rows each table answers with, whatever condition it was asked for. +const rows = new Map(); + +const fakeDb: any = { + query: { admins: { findFirst: async () => adminRow } }, + select: (fields?: Record) => { + const call: SelectCall = { fields, table: null, where: null }; + selects.push(call); + const chain: any = { + then: (onOk: any, onErr: any) => + Promise.resolve(rows.get(call.table) ?? []).then(onOk, onErr), + }; + chain.from = (table: unknown) => { + call.table = table; + return chain; + }; + chain.where = (where: unknown) => { + call.where = where; + return chain; + }; + for (const method of [ + "innerJoin", + "leftJoin", + "groupBy", + "orderBy", + "limit", + "offset", + "for", + ]) { + chain[method] = () => chain; + } + return chain; + }, + insert: () => ({ + values: () => + Object.assign(Promise.resolve(undefined), { + onConflictDoNothing: () => ({ returning: async () => [] }), + returning: async () => [{ id: "row" }], + }), + }), + delete: () => ({ where: async () => [] }), + transaction: async (callback: (tx: unknown) => unknown) => callback(fakeDb), +}; + +const callerAs = (userId: string) => + appRouter.createCaller({ + db: fakeDb, + session: { user: { id: userId } }, + userId, + cache, + clientIp: "127.0.0.1", + req: { headers: { get: () => null } }, + } as any); +const admin = () => callerAs("admin_user"); +const hacker = () => callerAs("hacker_user"); + +const callsOn = (table: unknown) => + selects.filter((call) => call.table === table); + +const at = (time: string) => new Date(`2027-02-06T${time}:00Z`); + +const config = (over: Row = {}) => ({ + hackathonId: HACK, + state: "open", + openedAt: null, + closedAt: null, + showAllScores: false, + inventoryLockedAt: null, + groupsInitializedAt: null, + itineraryPublished: true, + emergencyMode: false, + claimsStartedAt: null, + ...over, +}); + +beforeEach(() => { + cache.clear(); + selects.length = 0; + rows.clear(); + adminRow = { userId: "admin_user", isActive: true, role: "admin" }; + rows.set(hackathons, [{ id: HACK, name: "Hacklytics 2027" }]); + rows.set(judgingConfigs, [config()]); +}); + +// --------------------------------------------------------------------------- +describe("judging.admin.overview", () => { + it("counts the evaluations that hold every rating, not the ones submitted whole", async () => { + rows.set(judgingEvaluations, [{ total: 5, complete: 3 }]); + + const { counts } = await admin().judging.admin.overview({ + hackathonId: HACK, + }); + + expect(counts.evaluations).toMatchObject({ total: 5, complete: 3 }); + const aggregate = callsOn(judgingEvaluations).find( + (call) => call.fields && "complete" in call.fields, + )!; + const text = flat(aggregate.fields!.complete); + expect(text).toContain(`filter (where ${HOLDS_EVERY_RATING})`); + expect(text).not.toContain("is_complete"); + // For this edition's rubric. + expect(render(aggregate.fields!.complete).params).toEqual([HACK]); + }); +}); + +// --------------------------------------------------------------------------- +describe("judging.admin.listEntries", () => { + it("counts each project's evaluations that hold every rating", async () => { + rows.set(judgingEntries, [ + { + id: ENTRY, + title: "Tidewater", + value: 1, + deletedAt: null, + prizeIneligible: false, + }, + ]); + rows.set(judgingEvaluations, [{ entryId: ENTRY, value: 2 }]); + + const page = await admin().judging.admin.listEntries({ hackathonId: HACK }); + + expect(page.entries[0]).toMatchObject({ + id: ENTRY, + completeEvaluations: 2, + }); + const [counted] = callsOn(judgingEvaluations); + const text = flat(counted!.where); + expect(text).toContain(HOLDS_EVERY_RATING); + expect(text).not.toContain("is_complete"); + expect(render(counted!.where).params).toEqual([ENTRY, HACK]); + }); +}); + +// --------------------------------------------------------------------------- +describe("hackathon participant reads", () => { + const ratedResponse = ( + evaluationId: string, + label: string, + value: string, + ) => ({ + evaluationId, + label, + displayOrder: 1, + value, + }); + + beforeEach(() => { + rows.set(hackathonParticipants, [{ status: "checked_in" }]); + rows.set(judgingEntryMembers, [ + { + memberId: "m1", + entryId: ENTRY, + title: "Tidewater", + participantCount: 2, + deletedAt: null, + }, + ]); + rows.set(judgingChallenges, [ + { + id: GENERAL, + parentId: null, + mode: "scheduled", + label: "General", + displayOrder: 0, + isGeneral: true, + isGroup: false, + }, + ]); + }); + + describe("judging.hacker.feedback", () => { + it("asks only for member evaluations that hold every rating", async () => { + rows.set(judgingEvaluations, []); + + await hacker().judging.hacker.feedback({ hackathonId: HACK }); + + const [read] = callsOn(judgingEvaluations); + const text = flat(read!.where); + expect(text).toContain(HOLDS_EVERY_RATING); + expect(text).not.toContain("is_complete"); + // Of this project, in this edition, by a member judge. + expect(render(read!.where).params).toEqual( + expect.arrayContaining([HACK, ENTRY, "member"]), + ); + }); + + it("shows the written responses that exist and leaves out the ones left empty", async () => { + // The first auto-submitted at the deadline with every rating and one + // response; the second has none written at all. + rows.set(judgingEvaluations, [ + { id: EVAL_1, challengeId: GENERAL, submittedAt: at("15:00") }, + { id: EVAL_2, challengeId: GENERAL, submittedAt: at("15:10") }, + ]); + rows.set(judgingEvaluationResponses, [ + ratedResponse(EVAL_1, "Strengths", " Clear demo "), + { ...ratedResponse(EVAL_1, "Next steps", ""), displayOrder: 2 }, + ]); + + const feedback = await hacker().judging.hacker.feedback({ + hackathonId: HACK, + }); + + expect(feedback).toEqual({ + available: true, + items: [ + { + challenge: "General", + responses: [{ label: "Strengths", value: "Clear demo" }], + }, + ], + }); + }); + + it("asks for public responses only", async () => { + rows.set(judgingEvaluations, [ + { id: EVAL_1, challengeId: GENERAL, submittedAt: at("15:00") }, + ]); + + await hacker().judging.hacker.feedback({ hackathonId: HACK }); + + const [read] = callsOn(judgingEvaluationResponses); + expect(flat(read!.where)).toContain( + '"judging_evaluation_response"."is_public" = $?', + ); + expect(render(read!.where).params).toContain(true); + }); + }); + + describe("judging.hacker.itinerary", () => { + beforeEach(() => { + rows.set(judgingEntries, [{ id: ENTRY, title: "Tidewater" }]); + rows.set(judgingEntryChallenges, [{ challengeId: GENERAL }]); + rows.set(judgingRooms, [ + { + id: ROOM, + challengeId: GENERAL, + name: "Table 1", + kind: "table", + building: null, + displayOrder: 0, + archivedAt: null, + }, + ]); + rows.set(judgingAppointments, [ + { + id: SLOT, + challengeId: GENERAL, + roomId: ROOM, + startsAt: at("10:00"), + endsAt: at("10:10"), + }, + ]); + rows.set(judgingPresence, []); + }); + + const evaluation = (ratingsComplete: boolean) => ({ + judgeId: "judge-1", + challengeId: GENERAL, + appointmentId: SLOT, + ratingsComplete, + }); + const statusOfSlot = async () => { + const itinerary = await hacker().judging.hacker.itinerary({ + hackathonId: HACK, + }); + return itinerary.appointments.map((a) => a.status); + }; + + it("reads whether an evaluation holds every rating, not whether it was submitted whole", async () => { + rows.set(judgingEvaluations, [evaluation(true)]); + + await statusOfSlot(); + + const [read] = callsOn(judgingEvaluations); + expect(flat(read!.fields!.ratingsComplete)).toBe(HOLDS_EVERY_RATING); + expect(read!.fields).not.toHaveProperty("isComplete"); + expect(render(read!.fields!.ratingsComplete).params).toEqual([HACK]); + }); + + it("calls a presentation judged once its evaluation holds every rating", async () => { + rows.set(judgingEvaluations, [evaluation(true)]); + await expect(statusOfSlot()).resolves.toEqual(["complete"]); + }); + + it("calls it presented while an evaluation is short of a rating", async () => { + rows.set(judgingEvaluations, [evaluation(false)]); + await expect(statusOfSlot()).resolves.toEqual(["incomplete"]); + }); + }); +}); diff --git a/packages/api/src/.internal-tests/graded-once.test.ts b/packages/api/src/.internal-tests/graded-once.test.ts new file mode 100644 index 00000000..3fb75423 --- /dev/null +++ b/packages/api/src/.internal-tests/graded-once.test.ts @@ -0,0 +1,1983 @@ +/** + * Each project is graded once per challenge, by one judge, at the judge desk: + * the refusals on a draft save and a submit, what the project list and sheet + * report, the next project a judge is sent to (its queue window and skips), + * opening a project to hold it, and who may grade which challenge. + * + * The router runs for real against a stand-in database that answers each select + * by the tables it reads and keeps every statement in order, which is how the + * tests see the grading lock being taken before a write. + */ +import { describe, it, expect } from "vitest"; +import { getTableName } from "drizzle-orm"; +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { + hackathons, + judgingAppointments, + judgingBuildings, + judgingChallenges, + judgingConfigs, + judgingDeliberationEntries, + judgingDeliberationSections, + judgingEntries, + judgingEntryChallenges, + judgingEntryMembers, + judgingEvaluationDrafts, + judgingEvaluations, + judgingGuestSessions, + judgingJudges, + judges, + judgingPresence, + judgingRoomLinks, + judgingRooms, + judgingRubricItems, + judgingSchedules, +} from "@query/db"; +import { judgingDeskRouter } from "../routers/judging/desk"; +import { GRADING_HOLD_MS } from "../services/judging/grading-hold"; +import { QUEUE_WINDOW, queueOrder } from "../services/judging/queue"; + +const HACK = "6f1d2c3b-4a5e-4f60-8a7b-9c0d1e2f3a4b"; +const GENERAL = "0c4a8e10-2b3d-4c5e-9f60-7a8b9c0d1e2f"; +const SPONSOR = "0c4a8e11-2b3d-4c5e-9f60-7a8b9c0d1e2f"; +const ME = "1d5b9f21-3c4e-4d6f-8a70-8b9c0d1e2f3a"; +const OTHER = "1d5b9f22-3c4e-4d6f-8a70-8b9c0d1e2f3a"; +const ROOM = "2e6ca032-4d5f-4e70-9b81-9c0d1e2f3a4b"; +const RATING = "3f7db143-5e60-4f81-8c92-0d1e2f3a4b5c"; +const SECTION = "5a9fd365-7082-41a3-8eb4-2f3a4b5c6d01"; + +const E_OPEN = "40e8c254-6f71-4092-9da3-1e2f3a4b5c01"; +const E_MINE = "40e8c254-6f71-4092-9da3-1e2f3a4b5c02"; +const E_GRADED = "40e8c254-6f71-4092-9da3-1e2f3a4b5c03"; +const E_HELD = "40e8c254-6f71-4092-9da3-1e2f3a4b5c04"; +const E_STALE = "40e8c254-6f71-4092-9da3-1e2f3a4b5c05"; + +const challenge = ( + id: string, + label: string, + mode: "scheduled" | "unscheduled" | "remote", +) => ({ + id, + parentId: null, + mode, + label, + isGeneral: id === GENERAL, + isGroup: false, +}); +const general = challenge(GENERAL, "General", "unscheduled"); +const sponsor = challenge(SPONSOR, "Best Use of AI", "scheduled"); + +const entry = (id: string, title: string) => ({ + id, + title, + description: "", + submissionUrl: null, + demoLinks: [], + videoUrl: null, + technologies: [], + participantCount: 2, +}); + +const name = (table: unknown) => getTableName(table as never); +const via = (...tables: unknown[]) => tables.map(name).join(">"); + +type Rows = unknown[] | ((call: number) => unknown[]); +type Answers = Record; +type Event = { + kind: "select" | "execute" | "insert" | "update" | "delete"; + table?: string; + where?: SQL; + values?: Record; + /** What a select asked for. */ + fields?: Record; +}; + +/** The nth read of a table gets the nth list. */ +const byCall = + (...lists: unknown[][]) => + (call: number) => + lists[call] ?? []; + +function fakeDb(answers: Answers) { + const events: Event[] = []; + const reads = new Map(); + const rowsFor = (key: string) => { + const call = reads.get(key) ?? 0; + reads.set(key, call + 1); + const answer = answers[key] ?? []; + return typeof answer === "function" ? answer(call) : answer; + }; + + const select = (fields?: Record) => { + const joined: unknown[] = []; + let from: unknown; + let where: SQL | undefined; + const chain: Record = { + from: (table: unknown) => { + from = table; + return chain; + }, + where: (clause: SQL) => { + where = clause; + return chain; + }, + then: ( + ok: (rows: unknown[]) => unknown, + fail: (error: unknown) => unknown, + ) => { + const key = via(from, ...joined); + events.push({ kind: "select", table: key, where, fields }); + return Promise.resolve(rowsFor(key)).then(ok, fail); + }, + }; + for (const join of ["innerJoin", "leftJoin"]) { + chain[join] = (table: unknown) => { + joined.push(table); + return chain; + }; + } + for (const method of ["orderBy", "limit", "for"]) { + chain[method] = () => chain; + } + return chain; + }; + + const db: Record = { + select, + execute: () => { + events.push({ kind: "execute" }); + return Promise.resolve(); + }, + insert: (table: unknown) => ({ + values: (values: Record) => { + events.push({ kind: "insert", table: via(table), values }); + const done = Promise.resolve([{ id: "new" }]); + return Object.assign(done, { returning: () => done }); + }, + }), + update: (table: unknown) => ({ + set: (values: Record) => ({ + where: (where: SQL) => { + events.push({ kind: "update", table: via(table), where, values }); + return Promise.resolve(); + }, + }), + }), + delete: (table: unknown) => ({ + where: (where: SQL) => { + events.push({ kind: "delete", table: via(table), where }); + return Promise.resolve(); + }, + }), + transaction: (run: (tx: unknown) => Promise) => run(db), + }; + return { db, events }; +} + +const rating = { + id: RATING, + kind: "rating", + label: "Impact", + description: "", + required: true, + guestVisibility: null, +}; + +/** What the desk reads for a member judge at an edition with one challenge. */ +const memberWorld = (over: Answers = {}): Answers => ({ + [via(judges)]: [{ name: "Grace Hopper" }], + [via(judgingJudges)]: [{ id: ME, displayName: "Grace Hopper" }], + [via(judgingChallenges)]: [general], + [via(judgingConfigs)]: [ + { state: "open", showAllScores: false, closedAt: null }, + ], + [via(judgingRubricItems)]: [rating], + [via(judgingEntries, judgingEntryChallenges)]: [entry(E_OPEN, "Tidewater")], + ...over, +}); + +/** A guest judge whose room serves `roomChallengeId`. */ +const guestWorld = (over: Answers = {}): Answers => ({ + [via(judgingGuestSessions, judgingRoomLinks, judgingRooms, judgingJudges)]: [ + { + sessionId: "session-1", + hackathonId: HACK, + linkId: "link-1", + roomId: ROOM, + roomChallengeId: SPONSOR, + judgeId: ME, + displayName: "Guest Judge", + }, + ], + [via(judgingGuestSessions, judgingRoomLinks, judgingRooms)]: [{ id: "s" }], + [via(judgingChallenges)]: [general, sponsor], + [via(judgingConfigs)]: [ + { state: "open", showAllScores: false, closedAt: null }, + ], + [via(judgingRubricItems)]: [rating], + [via(judgingEntries, judgingEntryChallenges)]: [entry(E_OPEN, "Tidewater")], + ...over, +}); + +// A new user for each context: the desk's flood guard counts calls per user, +// and this file makes more than one user's worth. +let callers = 0; +const memberCtx = (db: unknown) => { + const userId = `judge-user-${(callers += 1)}`; + return { + db, + session: { user: { id: userId, name: "Grace Hopper" } }, + userId, + clientIp: "127.0.0.1", + req: { headers: { get: () => null } }, + } as never; +}; + +const guestCtx = (db: unknown) => + ({ + db, + session: null, + userId: undefined, + clientIp: "127.0.0.2", + req: { + headers: { + get: (header: string) => + header.toLowerCase() === "cookie" + ? "query_judging_guest=guest-token" + : null, + }, + }, + }) as never; + +const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60_000); +const minutesAhead = (minutes: number) => + new Date(Date.now() + minutes * 60_000); + +const evaluations = name(judgingEvaluations); +const drafts = name(judgingEvaluationDrafts); + +const kinds = (events: Event[]) => events.map((e) => e.kind); +const wrote = (events: Event[], table: unknown) => + events.some((e) => e.kind === "insert" && e.table === name(table)); +const firstRead = (events: Event[], table: string) => + events.findIndex((e) => e.kind === "select" && e.table === table); + +const gradedElsewhere = + "Another judge has already graded this project for General. Each project is graded once."; +const beingGraded = + "Another judge is grading this project right now. Pick another one."; + +const sponsorRefusal = + "Sponsor judges grade Best Use of AI. Sit in its room to grade it."; +const queueRefusal = + "Grade the projects your queue offers so every judge sees a random mix. Use Grade next project."; + +const save = { + hackathonId: HACK, + entryId: E_OPEN, + challengeId: GENERAL, + ratings: { [RATING]: 4 }, + responses: {}, +}; + +describe("saveDraft: a project is graded once", () => { + const run = (answers: Answers) => { + const fake = fakeDb(answers); + return { + ...fake, + save: () => + judgingDeskRouter + .createCaller(memberCtx(fake.db)) + .saveDraft({ ...save, revision: 0 }), + }; + }; + + it("saves a draft of a project nobody has, after taking the grading lock", async () => { + const { save: go, events } = run(memberWorld()); + + await expect(go()).resolves.toMatchObject({ revision: 1 }); + + expect(wrote(events, judgingEvaluationDrafts)).toBe(true); + expect(kinds(events).indexOf("execute")).toBeGreaterThan(-1); + expect(kinds(events).indexOf("execute")).toBeLessThan( + kinds(events).indexOf("insert"), + ); + // The judge's own draft is locked first, so the locks cannot deadlock + // with a deadline submission. + expect(firstRead(events, drafts)).toBeLessThan( + kinds(events).indexOf("execute"), + ); + }); + + it("refuses a project another judge already graded", async () => { + const { save: go, events } = run( + memberWorld({ [evaluations]: byCall([], [{ judgeId: OTHER }]) }), + ); + + await expect(go()).rejects.toMatchObject({ + code: "CONFLICT", + message: gradedElsewhere, + }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(false); + }); + + it("refuses a project another judge is grading right now", async () => { + const { save: go, events } = run( + memberWorld({ + [drafts]: byCall( + [], + [{ judgeId: OTHER, updatedAt: minutesAgo(1), deadlineAt: null }], + ), + }), + ); + + await expect(go()).rejects.toMatchObject({ + code: "CONFLICT", + message: beingGraded, + }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(false); + }); + + it("refuses while another judge's presentation draft is before its deadline", async () => { + const { save: go } = run( + memberWorld({ + [drafts]: byCall( + [], + [ + { + judgeId: OTHER, + updatedAt: minutesAgo(60), + deadlineAt: minutesAhead(3), + }, + ], + ), + }), + ); + + await expect(go()).rejects.toMatchObject({ message: beingGraded }); + }); + + it("takes over a draft the other judge left alone past the hold", async () => { + const { save: go, events } = run( + memberWorld({ + [drafts]: byCall( + [], + [ + { + judgeId: OTHER, + updatedAt: new Date(Date.now() - GRADING_HOLD_MS - 60_000), + deadlineAt: null, + }, + ], + ), + }), + ); + + await expect(go()).resolves.toMatchObject({ revision: 1 }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(true); + }); + + it("leaves a judge editing their own evaluation alone", async () => { + // Old duplicates: another judge graded it too. The judge's own is theirs. + const { save: go, events } = run( + memberWorld({ + [evaluations]: byCall([{ appointmentId: null }], [{ judgeId: OTHER }]), + }), + ); + + await expect(go()).resolves.toMatchObject({ revision: 1 }); + expect(kinds(events)).not.toContain("execute"); + expect(wrote(events, judgingEvaluationDrafts)).toBe(true); + }); +}); + +describe("submit: a project is graded once", () => { + const run = (answers: Answers) => { + const fake = fakeDb(answers); + return { + ...fake, + submit: () => + judgingDeskRouter + .createCaller(memberCtx(fake.db)) + .submit({ ...save, expectedRevision: 0 }), + }; + }; + + it("submits a project nobody has graded, after taking the grading lock", async () => { + const { submit, events } = run(memberWorld()); + + await expect(submit()).resolves.toMatchObject({ isComplete: true }); + + expect(wrote(events, judgingEvaluations)).toBe(true); + expect(kinds(events).indexOf("execute")).toBeGreaterThan(-1); + expect(kinds(events).indexOf("execute")).toBeLessThan( + kinds(events).indexOf("insert"), + ); + // The judge's own draft and evaluation are locked first. + expect(firstRead(events, drafts)).toBeLessThan( + kinds(events).indexOf("execute"), + ); + expect(firstRead(events, evaluations)).toBeLessThan( + kinds(events).indexOf("execute"), + ); + }); + + it("refuses a project another judge already graded", async () => { + const { submit, events } = run( + memberWorld({ [evaluations]: byCall([], [{ judgeId: OTHER }]) }), + ); + + await expect(submit()).rejects.toMatchObject({ + code: "CONFLICT", + message: gradedElsewhere, + }); + expect(wrote(events, judgingEvaluations)).toBe(false); + }); + + it("refuses a project another judge is grading right now", async () => { + const { submit, events } = run( + memberWorld({ + [drafts]: byCall( + [], + [{ judgeId: OTHER, updatedAt: minutesAgo(2), deadlineAt: null }], + ), + }), + ); + + await expect(submit()).rejects.toMatchObject({ message: beingGraded }); + expect(wrote(events, judgingEvaluations)).toBe(false); + }); + + it("lets a judge update their own evaluation", async () => { + const { db, events } = fakeDb( + memberWorld({ + [evaluations]: byCall( + [ + { + id: "mine", + revision: 1, + appointmentId: null, + autoSubmittedAt: null, + }, + ], + [{ judgeId: OTHER }], + ), + }), + ); + + await expect( + judgingDeskRouter + .createCaller(memberCtx(db)) + .submit({ ...save, expectedRevision: 1 }), + ).resolves.toMatchObject({ isComplete: true }); + expect(kinds(events)).not.toContain("execute"); + }); +}); + +describe("desk.projects: each project's grading state", () => { + const list = async (answers: Answers, ctx = memberCtx) => { + const { db } = fakeDb(answers); + return judgingDeskRouter.createCaller(ctx(db)).projects({ + hackathonId: HACK, + inMyRoomOnly: false, + hideJudged: false, + }); + }; + + const crowded = () => + memberWorld({ + [via(judgingEntries, judgingEntryChallenges)]: [ + entry(E_GRADED, "Alpha"), + entry(E_HELD, "Bravo"), + entry(E_MINE, "Charlie"), + entry(E_STALE, "Delta"), + entry(E_OPEN, "Echo"), + ], + [evaluations]: [ + { entryId: E_MINE, judgeId: ME, revision: 1, counted: true }, + { entryId: E_GRADED, judgeId: OTHER, revision: 1, counted: true }, + ], + [drafts]: [ + { + entryId: E_HELD, + judgeId: OTHER, + updatedAt: minutesAgo(1), + deadlineAt: null, + }, + { + entryId: E_STALE, + judgeId: OTHER, + updatedAt: minutesAgo(60), + deadlineAt: null, + }, + ], + }); + + it("reports mine, graded, in progress and open", async () => { + const result = await list(crowded()); + + expect( + Object.fromEntries(result.projects.map((p) => [p.id, p.grading])), + ).toEqual({ + [E_MINE]: "mine", + [E_GRADED]: "graded", + [E_HELD]: "in_progress", + [E_STALE]: "open", + [E_OPEN]: "open", + }); + }); + + it("lists open projects first, then those being graded, then graded ones", async () => { + const result = await list(crowded()); + + expect(result.projects.map((p) => p.grading)).toEqual([ + "mine", + "open", + "open", + "in_progress", + "graded", + ]); + }); + + it("keeps a judge's own draft and evaluation to themselves", async () => { + const result = await list(crowded()); + + const held = result.projects.find((p) => p.id === E_HELD); + expect(held?.hasDraft).toBe(false); + expect(held?.myEvaluation).toBeNull(); + const graded = result.projects.find((p) => p.id === E_GRADED); + expect(graded?.myEvaluation).toBeNull(); + expect(result.judgedCount).toBe(1); + }); + + it("does not call a project the judge only opened a saved draft", async () => { + const mine = (entryId: string, revision: number) => ({ + entryId, + judgeId: ME, + updatedAt: minutesAgo(1), + deadlineAt: null, + revision, + }); + const result = await list( + memberWorld({ + [via(judgingEntries, judgingEntryChallenges)]: [ + entry(E_OPEN, "Alpha"), + entry(E_HELD, "Bravo"), + ], + [drafts]: [mine(E_OPEN, 0), mine(E_HELD, 2)], + }), + ); + + const hasDraft = Object.fromEntries( + result.projects.map((p) => [p.id, p.hasDraft]), + ); + expect(hasDraft).toEqual({ [E_OPEN]: false, [E_HELD]: true }); + expect(result.projects.map((p) => p.grading)).toEqual(["mine", "mine"]); + }); + + it("never says which other judge", async () => { + const result = await list(crowded()); + + expect(JSON.stringify(result)).not.toContain(OTHER); + }); + + it("tells a guest the same, for their own room", async () => { + const result = await list( + guestWorld({ + [evaluations]: [ + { entryId: E_OPEN, judgeId: OTHER, revision: 1, counted: true }, + ], + }), + guestCtx, + ); + + expect(result.challenge.id).toBe(SPONSOR); + expect(result.projects.map((p) => p.grading)).toEqual(["graded"]); + expect(JSON.stringify(result)).not.toContain(OTHER); + }); + + it("calls a project judged once its evaluation counts, as placings do", async () => { + const answers = memberWorld({ + [via(judgingEntries, judgingEntryChallenges)]: [ + entry(E_MINE, "Alpha"), + entry(E_OPEN, "Bravo"), + ], + // Alpha holds every rating, a written response left empty when time + // ran out. Bravo lacks a rating. + [evaluations]: [ + { entryId: E_MINE, judgeId: ME, revision: 1, counted: true }, + { entryId: E_OPEN, judgeId: ME, revision: 1, counted: false }, + ], + }); + const { db, events } = fakeDb(answers); + const shown = await judgingDeskRouter.createCaller(memberCtx(db)).projects({ + hackathonId: HACK, + inMyRoomOnly: false, + hideJudged: false, + }); + + expect(shown.judgedCount).toBe(1); + expect( + Object.fromEntries( + shown.projects.map((p) => [p.id, p.myEvaluation?.counted]), + ), + ).toEqual({ [E_MINE]: true, [E_OPEN]: false }); + // The database applies scoring.hasEveryRating's rule, not the judge's + // "answered everything" flag. + const read = events.find( + (e) => e.table === evaluations && e.fields && "counted" in e.fields, + ); + expect(read?.fields).not.toHaveProperty("isComplete"); + expect( + new PgDialect().sqlToQuery(read?.fields?.counted as SQL).sql, + ).toContain('"judging_evaluation_rating"'); + + const hidden = await judgingDeskRouter + .createCaller(memberCtx(fakeDb(answers).db)) + .projects({ hackathonId: HACK, inMyRoomOnly: false, hideJudged: true }); + expect(hidden.projects.map((p) => p.id)).toEqual([E_OPEN]); + }); +}); + +describe("desk.deliberation.add: what can go on a shortlist", () => { + it("takes a project once the judge's evaluation counts, as placings do", async () => { + const { db, events } = fakeDb( + memberWorld({ [via(judgingDeliberationSections)]: [{ id: SECTION }] }), + ); + + await expect( + judgingDeskRouter.createCaller(memberCtx(db)).deliberation.add({ + hackathonId: HACK, + sectionId: SECTION, + entryId: E_OPEN, + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + const read = events.find( + (e) => e.table === via(judgingEvaluations, judgingEntries), + ); + const clause = new PgDialect().sqlToQuery(read?.where as SQL).sql; + expect(clause).toContain('"judging_evaluation_rating"'); + expect(clause).not.toContain("is_complete"); + expect(wrote(events, judgingDeliberationEntries)).toBe(false); + }); +}); + +describe("desk.project: whether the judge may write", () => { + const open = async ( + entryId: string, + over: Answers, + ctx = memberCtx, + challengeId: string | undefined = undefined, + ) => { + const { db } = fakeDb( + memberWorld({ + [via(judgingEntries, judgingEntryChallenges)]: [ + entry(entryId, "Tidewater"), + ], + [via(judgingEntryMembers)]: [{ name: "Ada Lovelace" }], + ...over, + }), + ); + return judgingDeskRouter + .createCaller(ctx(db)) + .project({ hackathonId: HACK, entryId, challengeId }); + }; + + it("is open to write on a project nobody has", async () => { + const sheet = await open(E_OPEN, {}); + + expect(sheet.grading).toBe("open"); + expect(sheet.access).toMatchObject({ + canEdit: true, + reason: null, + gradedElsewhere: false, + }); + }); + + it("is read-only on a project another judge graded", async () => { + const sheet = await open(E_GRADED, { + [evaluations]: byCall([], [{ judgeId: OTHER }]), + }); + + expect(sheet.grading).toBe("graded"); + expect(sheet.access).toMatchObject({ + canEdit: false, + reason: gradedElsewhere, + gradedElsewhere: true, + needsRoom: false, + ended: false, + opensAt: null, + deadlineAt: null, + }); + // Still there to read. + expect(sheet.entry.title).toBe("Tidewater"); + expect(sheet.rubric).toHaveLength(1); + expect(sheet.evaluation).toBeNull(); + expect(JSON.stringify(sheet)).not.toContain(OTHER); + }); + + it("is read-only on a project another judge is grading", async () => { + const sheet = await open(E_HELD, { + [drafts]: byCall( + [], + [{ judgeId: OTHER, updatedAt: minutesAgo(1), deadlineAt: null }], + ), + }); + + expect(sheet.grading).toBe("in_progress"); + expect(sheet.access).toMatchObject({ + canEdit: false, + reason: beingGraded, + gradedElsewhere: true, + needsRoom: false, + }); + expect(sheet.draft).toBeNull(); + expect(JSON.stringify(sheet)).not.toContain(OTHER); + }); + + it("stays the judge's own project when another judge also graded it", async () => { + const sheet = await open(E_MINE, { + [evaluations]: byCall( + [ + { + id: "mine", + revision: 1, + isComplete: true, + appointmentId: null, + submittedAt: minutesAgo(30), + autoSubmittedAt: null, + updatedAt: minutesAgo(30), + }, + ], + [{ judgeId: OTHER }, { judgeId: ME }], + ), + }); + + expect(sheet.grading).toBe("mine"); + expect(sheet.access.canEdit).toBe(true); + }); + + it("does not hide that judging is closed behind the grading", async () => { + const sheet = await open(E_GRADED, { + [via(judgingConfigs)]: [ + { state: "closed", showAllScores: false, closedAt: new Date() }, + ], + [evaluations]: byCall([], [{ judgeId: OTHER }]), + }); + + expect(sheet.access.canEdit).toBe(false); + expect(sheet.access.reason).toBe( + "Judging is closed. Your submitted evaluations are read-only.", + ); + }); +}); + +describe("desk.nextProject", () => { + const next = async ( + answers: Answers, + input: { challengeId?: string } = {}, + ctx = memberCtx, + ) => { + const fake = fakeDb(answers); + const result = await judgingDeskRouter + .createCaller(ctx(fake.db)) + .nextProject({ hackathonId: HACK, ...input }); + return { result, ...fake }; + }; + + const pool = [E_OPEN, E_MINE, E_GRADED, E_HELD, E_STALE]; + const crowded = (over: Answers = {}) => + memberWorld({ + [via(judgingEntries, judgingEntryChallenges)]: pool.map((id) => ({ + id, + })), + [evaluations]: [ + { entryId: E_MINE, judgeId: ME }, + { entryId: E_GRADED, judgeId: OTHER }, + ], + [drafts]: [ + { + entryId: E_HELD, + judgeId: OTHER, + updatedAt: minutesAgo(1), + deadlineAt: null, + }, + { + entryId: E_STALE, + judgeId: OTHER, + updatedAt: minutesAgo(60), + deadlineAt: null, + }, + ], + ...over, + }); + + it("sends the judge to the first open project in their own order", async () => { + const { result } = await next(crowded()); + + const open = [E_OPEN, E_STALE]; + expect(result.entryId).toBe(queueOrder(ME, GENERAL, open)[0]); + expect(result.challenge).toMatchObject({ id: GENERAL, label: "General" }); + expect(result.needsRoom).toBe(false); + }); + + it("sends the same judge to the same project each time", async () => { + const first = await next(crowded()); + const again = await next(crowded()); + + expect(again.result.entryId).toBe(first.result.entryId); + }); + + it("is nothing when every project is graded or held", async () => { + const { result } = await next( + crowded({ + [via(judgingEntries, judgingEntryChallenges)]: [ + { id: E_MINE }, + { id: E_GRADED }, + { id: E_HELD }, + ], + }), + ); + + expect(result.entryId).toBeNull(); + expect(result.needsRoom).toBe(false); + }); + + it("is nothing when the challenge has no projects", async () => { + const { result } = await next( + memberWorld({ [via(judgingEntries, judgingEntryChallenges)]: [] }), + ); + + expect(result.entryId).toBeNull(); + }); + + it("leaves out withdrawn projects, and still grades prize-ineligible ones", async () => { + const { events } = await next(crowded()); + + const read = events.find( + (e) => e.table === via(judgingEntries, judgingEntryChallenges), + ); + const clause = new PgDialect().sqlToQuery(read?.where as SQL).sql; + expect(clause).toContain('"deleted_at" is null'); + expect(clause).not.toContain("prize_ineligible"); + }); + + describe("at a scheduled challenge", () => { + const seat = (challengeId: string) => [ + { roomId: ROOM, roomName: "Table 4", challengeId }, + ]; + const scheduled = (over: Answers = {}) => + memberWorld({ + [via(judgingChallenges)]: [general, sponsor], + [via(judgingSchedules)]: [{ id: "schedule" }], + [via(judgingPresence, judgingRooms)]: seat(SPONSOR), + ...over, + }); + const slot = (entryId: string, startsAt: Date, deadlineAt: Date) => ({ + id: `slot-${entryId}`, + entryId, + startsAt, + deadlineAt, + }); + + it("sends the judge to a presentation running in their room", async () => { + const { result, events } = await next( + scheduled({ + [via(judgingEntries, judgingEntryChallenges)]: [ + { id: E_OPEN }, + { id: E_STALE }, + { id: E_HELD }, + ], + [via(judgingAppointments)]: [ + slot(E_OPEN, minutesAgo(30), minutesAgo(20)), + slot(E_STALE, minutesAgo(5), minutesAhead(5)), + slot(E_HELD, minutesAhead(20), minutesAhead(30)), + ], + }), + { challengeId: SPONSOR }, + ); + + // Over, running, and not started: only the running one is on now. + expect(result.entryId).toBe(E_STALE); + const read = events.find((e) => e.table === name(judgingAppointments)); + const clause = new PgDialect().sqlToQuery(read?.where as SQL).sql; + expect(clause).toContain('"room_id" = $'); + }); + + it("is nothing when no presentation in the room is running", async () => { + const { result } = await next( + scheduled({ + [via(judgingEntries, judgingEntryChallenges)]: [{ id: E_OPEN }], + [via(judgingAppointments)]: [ + slot(E_OPEN, minutesAhead(10), minutesAhead(20)), + ], + }), + { challengeId: SPONSOR }, + ); + + expect(result.entryId).toBeNull(); + expect(result.needsRoom).toBe(false); + }); + + it("skips a running presentation another judge already graded", async () => { + const { result } = await next( + scheduled({ + [via(judgingEntries, judgingEntryChallenges)]: [ + { id: E_OPEN }, + { id: E_STALE }, + ], + [via(judgingAppointments)]: [ + slot(E_OPEN, minutesAgo(5), minutesAhead(5)), + slot(E_STALE, minutesAgo(5), minutesAhead(5)), + ], + [evaluations]: [{ entryId: E_OPEN, judgeId: OTHER }], + }), + { challengeId: SPONSOR }, + ); + + expect(result.entryId).toBe(E_STALE); + }); + + // A table judge can only reach a scheduled challenge that is General's: + // a sponsor's is refused before the room matters (see "who may grade + // which challenge"). + const timedGeneral = { ...general, mode: "scheduled" as const }; + + it("asks a judge seated elsewhere to join the room first", async () => { + const { result, events } = await next( + scheduled({ [via(judgingChallenges)]: [timedGeneral, sponsor] }), + { challengeId: GENERAL }, + ); + + expect(result.entryId).toBeNull(); + expect(result.needsRoom).toBe(true); + expect( + events.some( + (e) => e.table === via(judgingEntries, judgingEntryChallenges), + ), + ).toBe(false); + }); + + it("asks a judge with no seat to join the room first", async () => { + const { result } = await next( + scheduled({ + [via(judgingChallenges)]: [timedGeneral, sponsor], + [via(judgingPresence, judgingRooms)]: [], + }), + { challengeId: GENERAL }, + ); + + expect(result.needsRoom).toBe(true); + }); + }); +}); + +describe("who may grade which challenge", () => { + it("keeps a sponsor's guest judge on their room's challenge when saving", async () => { + const { db, events } = fakeDb(guestWorld()); + + await expect( + judgingDeskRouter + .createCaller(guestCtx(db)) + .saveDraft({ ...save, challengeId: GENERAL, revision: 0 }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(false); + expect(kinds(events)).not.toContain("execute"); + }); + + it("keeps a sponsor's guest judge on their room's challenge when submitting", async () => { + const { db, events } = fakeDb(guestWorld()); + + await expect( + judgingDeskRouter + .createCaller(guestCtx(db)) + .submit({ ...save, challengeId: GENERAL, expectedRevision: 0 }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(wrote(events, judgingEvaluations)).toBe(false); + }); + + it("keeps a guest from opening or queueing another challenge", async () => { + const { db } = fakeDb(guestWorld()); + const caller = judgingDeskRouter.createCaller(guestCtx(db)); + + await expect( + caller.project({ + hackathonId: HACK, + entryId: E_OPEN, + challengeId: GENERAL, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect( + caller.nextProject({ hackathonId: HACK, challengeId: GENERAL }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect( + caller.projects({ hackathonId: HACK, challengeId: GENERAL }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("queues a guest in their room's challenge without being told which", async () => { + const { db } = fakeDb(guestWorld()); + + const result = await judgingDeskRouter + .createCaller(guestCtx(db)) + .nextProject({ hackathonId: HACK }); + + expect(result.challenge.id).toBe(SPONSOR); + expect(result.entryId).toBe(E_OPEN); + }); + + it("lets a guest grade their own room's challenge", async () => { + const { db, events } = fakeDb(guestWorld()); + + await expect( + judgingDeskRouter + .createCaller(guestCtx(db)) + .saveDraft({ ...save, challengeId: SPONSOR, revision: 0 }), + ).resolves.toMatchObject({ revision: 1 }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(true); + }); + + describe("a table judge and a scheduled sponsor challenge", () => { + const sheet = (seatedAt: string) => + memberWorld({ + [via(judgingChallenges)]: [general, sponsor], + [via(judgingSchedules)]: [{ id: "schedule" }], + [via(judgingAppointments)]: [ + { + id: "slot", + roomId: ROOM, + startsAt: minutesAgo(5), + deadlineAt: minutesAhead(5), + endsAt: minutesAhead(10), + }, + ], + [via(judgingPresence, judgingRooms)]: [ + { roomId: ROOM, roomName: "Table 4", challengeId: seatedAt }, + ], + }); + + it("cannot grade it from a table that judges another challenge", async () => { + const { db, events } = fakeDb(sheet(GENERAL)); + + await expect( + judgingDeskRouter + .createCaller(memberCtx(db)) + .saveDraft({ ...save, challengeId: SPONSOR, revision: 0 }), + ).rejects.toMatchObject({ code: "FORBIDDEN", message: sponsorRefusal }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(false); + + await expect( + judgingDeskRouter + .createCaller(memberCtx(fakeDb(sheet(GENERAL)).db)) + .submit({ ...save, challengeId: SPONSOR, expectedRevision: 0 }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("still needs the room for a scheduled General", async () => { + const world = memberWorld({ + ...sheet(SPONSOR), + [via(judgingChallenges)]: [{ ...general, mode: "scheduled" }, sponsor], + }); + const { db, events } = fakeDb(world); + + await expect( + judgingDeskRouter + .createCaller(memberCtx(db)) + .saveDraft({ ...save, challengeId: GENERAL, revision: 0 }), + ).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + message: + "Join the room or table that judges this challenge to evaluate this project.", + }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(false); + }); + + it("can grade it when seated at the room that judges it", async () => { + const { db, events } = fakeDb(sheet(SPONSOR)); + + await expect( + judgingDeskRouter + .createCaller(memberCtx(db)) + .saveDraft({ ...save, challengeId: SPONSOR, revision: 0 }), + ).resolves.toMatchObject({ revision: 1 }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(true); + }); + }); +}); + +/** More projects than the queue window, with ids the desk accepts as input. */ +const eid = (n: number) => + `40e8c254-6f71-4092-9da3-1e2f3a4b5c${String(n).padStart(2, "0")}`; +const POOL = Array.from({ length: 8 }, (_, i) => eid(i + 10)); + +const entriesKey = via(judgingEntries, judgingEntryChallenges); +const presenceKey = via(judgingPresence, judgingRooms); +const seatAt = (challengeId: string) => [ + { roomId: ROOM, roomName: "Table 4", challengeId }, +]; + +describe("sponsor challenges belong to sponsor judges", () => { + const MLH = "0c4a8e12-2b3d-4c5e-9f60-7a8b9c0d1e2f"; + const MONGO = "0c4a8e13-2b3d-4c5e-9f60-7a8b9c0d1e2f"; + const mlh = { + ...challenge(MLH, "MLH Challenges", "unscheduled"), + isGroup: true, + }; + const mongo = { + ...challenge(MONGO, "Best Use of MongoDB", "unscheduled"), + parentId: MLH, + }; + + const world = (seatedAt: string | null, over: Answers = {}) => + memberWorld({ + [via(judgingChallenges)]: [general, sponsor, mlh, mongo], + [presenceKey]: seatedAt ? seatAt(seatedAt) : [], + ...over, + }); + + it("refuses a table judge who is not seated in the sponsor's room, on every call that picks or writes", async () => { + for (const seatedAt of [null, GENERAL, MLH]) { + const { db, events } = fakeDb(world(seatedAt)); + const caller = judgingDeskRouter.createCaller(memberCtx(db)); + const refused = { code: "FORBIDDEN", message: sponsorRefusal }; + + await expect( + caller.projects({ hackathonId: HACK, challengeId: SPONSOR }), + ).rejects.toMatchObject(refused); + await expect( + caller.project({ + hackathonId: HACK, + entryId: E_OPEN, + challengeId: SPONSOR, + }), + ).rejects.toMatchObject(refused); + await expect( + caller.nextProject({ hackathonId: HACK, challengeId: SPONSOR }), + ).rejects.toMatchObject(refused); + await expect( + caller.saveDraft({ ...save, challengeId: SPONSOR, revision: 0 }), + ).rejects.toMatchObject(refused); + await expect( + caller.submit({ ...save, challengeId: SPONSOR, expectedRevision: 0 }), + ).rejects.toMatchObject(refused); + await expect( + caller.startGrading({ + hackathonId: HACK, + entryId: E_OPEN, + challengeId: SPONSOR, + }), + ).rejects.toMatchObject(refused); + + expect(kinds(events)).not.toContain("execute"); + expect(kinds(events)).not.toContain("insert"); + } + }); + + it("names the group when the judge asked for a prize inside it", async () => { + const { db } = fakeDb(world(GENERAL)); + + await expect( + judgingDeskRouter + .createCaller(memberCtx(db)) + .projects({ hackathonId: HACK, challengeId: MONGO }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: + "Sponsor judges grade MLH Challenges. Sit in its room to grade it.", + }); + }); + + it("lets a table judge in once they are seated in the room that serves it", async () => { + const { db, events } = fakeDb(world(SPONSOR)); + const caller = judgingDeskRouter.createCaller(memberCtx(db)); + + const listed = await caller.projects({ + hackathonId: HACK, + challengeId: SPONSOR, + inMyRoomOnly: false, + }); + expect(listed.challenge.id).toBe(SPONSOR); + await expect( + caller.nextProject({ hackathonId: HACK, challengeId: SPONSOR }), + ).resolves.toMatchObject({ entryId: E_OPEN }); + await expect( + caller.saveDraft({ ...save, challengeId: SPONSOR, revision: 0 }), + ).resolves.toMatchObject({ revision: 1 }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(true); + }); + + it("lets a table judge grade a prize inside a group from a seat at the group's room", async () => { + const { db } = fakeDb(world(MLH)); + + const listed = await judgingDeskRouter + .createCaller(memberCtx(db)) + .projects({ hackathonId: HACK, challengeId: MONGO }); + expect(listed.challenge.id).toBe(MLH); + }); + + it("keeps General open to a table judge wherever they sit", async () => { + for (const seatedAt of [null, GENERAL, SPONSOR]) { + const { db } = fakeDb(world(seatedAt)); + const listed = await judgingDeskRouter + .createCaller(memberCtx(db)) + .projects({ hackathonId: HACK, challengeId: GENERAL }); + expect(listed.challenge.id).toBe(GENERAL); + } + }); + + it("defaults an unseated table judge to General", async () => { + const { db } = fakeDb(world(null)); + + const listed = await judgingDeskRouter + .createCaller(memberCtx(db)) + .projects({ hackathonId: HACK }); + + expect(listed.challenge.id).toBe(GENERAL); + }); + + describe("the challenges the desk offers", () => { + const offered = async (answers: Answers, ctx = memberCtx) => { + const { db } = fakeDb({ + [via(hackathons)]: [{ id: HACK, name: "Hacklytics" }], + ...answers, + }); + const context = await judgingDeskRouter + .createCaller(ctx(db)) + .context({ hackathonId: HACK }); + return context.challenges.map((c) => c.id); + }; + + it("is General alone for a table judge with no seat or another's", async () => { + expect(await offered(world(null))).toEqual([GENERAL]); + expect(await offered(world(GENERAL))).toEqual([GENERAL]); + }); + + it("adds the sponsor's challenge, and what is below it, from its room", async () => { + expect(await offered(world(SPONSOR))).toEqual([GENERAL, SPONSOR]); + expect(await offered(world(MLH))).toEqual([GENERAL, MLH, MONGO]); + }); + + it("is still only the room's challenge for a guest", async () => { + expect(await offered(guestWorld(), guestCtx)).toEqual([SPONSOR]); + }); + }); +}); + +describe("a judge's first claim is limited to their queue window", () => { + const order = queueOrder(ME, GENERAL, POOL); + const world = (over: Answers = {}) => + memberWorld({ + [entriesKey]: POOL.map((id) => entry(id, "Project")), + ...over, + }); + const firstSave = (entryId: string) => ({ ...save, entryId, revision: 0 }); + const desk = (answers: Answers) => { + const fake = fakeDb(answers); + return { + ...fake, + caller: judgingDeskRouter.createCaller(memberCtx(fake.db)), + }; + }; + + it("saves a draft of any of the first projects the queue offers", async () => { + for (const entryId of order.slice(0, QUEUE_WINDOW)) { + const { caller, events } = desk(world()); + + await expect(caller.saveDraft(firstSave(entryId))).resolves.toMatchObject( + { revision: 1 }, + ); + expect(wrote(events, judgingEvaluationDrafts)).toBe(true); + } + }); + + it("refuses a first save of a project further down, after the grading lock and before any write", async () => { + const { caller, events } = desk(world()); + + await expect( + caller.saveDraft(firstSave(order[QUEUE_WINDOW])), + ).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + message: queueRefusal, + }); + expect(kinds(events)).toContain("execute"); + expect(wrote(events, judgingEvaluationDrafts)).toBe(false); + }); + + it("refuses a first submission of a project further down", async () => { + const { caller, events } = desk(world()); + + await expect( + caller.submit({ ...firstSave(order[QUEUE_WINDOW]), expectedRevision: 0 }), + ).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + message: queueRefusal, + }); + expect(wrote(events, judgingEvaluations)).toBe(false); + }); + + it("lets a judge submit a project in the window without ever saving a draft", async () => { + const { caller } = desk(world()); + + await expect( + caller.submit({ ...firstSave(order[0]), expectedRevision: 0 }), + ).resolves.toMatchObject({ isComplete: true }); + }); + + it("slides the window past projects that are graded or held", async () => { + const held = (entryId: string, minutes: number) => ({ + entryId, + judgeId: OTHER, + updatedAt: minutesAgo(minutes), + deadlineAt: null, + }); + const { caller } = desk( + world({ + [evaluations]: byCall([], [], [{ entryId: order[0], judgeId: OTHER }]), + [drafts]: byCall([], [], [held(order[1], 1), held(order[2], 2)]), + }), + ); + + await expect( + caller.saveDraft(firstSave(order[QUEUE_WINDOW])), + ).resolves.toMatchObject({ revision: 1 }); + }); + + it("leaves a project the judge already holds alone, however far down", async () => { + const { caller } = desk( + world({ + [drafts]: byCall([{ id: "mine", revision: 0, deadlineAt: null }]), + }), + ); + + await expect(caller.saveDraft(firstSave(order[7]))).resolves.toMatchObject({ + revision: 1, + }); + }); + + it("leaves a project the judge already graded alone, however far down", async () => { + const { caller, events } = desk( + world({ [evaluations]: byCall([{ appointmentId: null }]) }), + ); + + await expect(caller.saveDraft(firstSave(order[7]))).resolves.toMatchObject({ + revision: 1, + }); + expect(kinds(events)).not.toContain("execute"); + }); + + it("asks nothing of a scheduled challenge: the schedule decides", async () => { + const { caller, events } = desk( + memberWorld({ + [via(judgingChallenges)]: [general, sponsor], + [via(judgingSchedules)]: [{ id: "schedule" }], + [via(judgingAppointments)]: [ + { + id: "slot", + roomId: ROOM, + startsAt: minutesAgo(5), + deadlineAt: minutesAhead(5), + endsAt: minutesAhead(10), + }, + ], + [presenceKey]: seatAt(SPONSOR), + [entriesKey]: POOL.map((id) => entry(id, "Project")), + }), + ); + + await expect( + caller.saveDraft({ + ...firstSave(queueOrder(ME, SPONSOR, POOL)[7]), + challengeId: SPONSOR, + }), + ).resolves.toMatchObject({ revision: 1 }); + // Only the check that the project is entered read the projects. + expect(events.filter((e) => e.table === entriesKey)).toHaveLength(1); + }); +}); + +describe("desk.nextProject: the queue window and skips", () => { + const order = queueOrder(ME, GENERAL, POOL); + const next = async ( + input: { challengeId?: string; skip?: string[] } = {}, + over: Answers = {}, + ) => { + const { db } = fakeDb( + memberWorld({ [entriesKey]: POOL.map((id) => ({ id })), ...over }), + ); + return judgingDeskRouter + .createCaller(memberCtx(db)) + .nextProject({ hackathonId: HACK, ...input }); + }; + + it("offers the first project of the judge's queue", async () => { + const result = await next(); + + expect(result.entryId).toBe(order[0]); + expect(result.allSkipped).toBe(false); + }); + + it("passes over skipped projects, one at a time, within the window", async () => { + expect((await next({ skip: [order[0]] })).entryId).toBe(order[1]); + expect((await next({ skip: [order[0], order[1]] })).entryId).toBe(order[2]); + }); + + it("says everything is skipped once the whole window is, and never reaches past it", async () => { + const result = await next({ skip: order.slice(0, QUEUE_WINDOW) }); + + expect(result.entryId).toBeNull(); + expect(result.allSkipped).toBe(true); + expect(result.needsRoom).toBe(false); + }); + + it("ignores a skip outside the window", async () => { + expect((await next({ skip: [order[6]] })).entryId).toBe(order[0]); + }); + + it("does not call a challenge with nothing open skipped", async () => { + const held = POOL.map((entryId) => ({ + entryId, + judgeId: OTHER, + updatedAt: minutesAgo(1), + deadlineAt: null, + })); + + const result = await next({ skip: [order[0]] }, { [drafts]: held }); + + expect(result.entryId).toBeNull(); + expect(result.allSkipped).toBe(false); + }); + + it("slides the window as other judges grade projects", async () => { + const result = await next( + { skip: order.slice(0, QUEUE_WINDOW - 1) }, + { [evaluations]: [{ entryId: order[0], judgeId: OTHER }] }, + ); + + // order[0] is graded, so the window is order[1..3]; skipping order[0] + // and order[1] leaves order[2]. + expect(result.entryId).toBe(order[2]); + const all = await next( + { skip: order.slice(1, QUEUE_WINDOW + 1) }, + { [evaluations]: [{ entryId: order[0], judgeId: OTHER }] }, + ); + expect(all.entryId).toBeNull(); + expect(all.allSkipped).toBe(true); + }); + + it("keeps a project the judge opened and skipped inside the window", async () => { + // Opening a project holds it for the judge (an empty draft); skipping it + // must not let them reach past the window. + const opened = order.slice(0, QUEUE_WINDOW).map((entryId) => ({ + entryId, + judgeId: ME, + updatedAt: minutesAgo(1), + deadlineAt: null, + revision: 0, + })); + + expect((await next({}, { [drafts]: opened.slice(0, 1) })).entryId).toBe( + order[0], + ); + expect( + (await next({ skip: [order[0]] }, { [drafts]: opened.slice(0, 1) })) + .entryId, + ).toBe(order[1]); + const all = await next( + { skip: order.slice(0, QUEUE_WINDOW) }, + { [drafts]: opened }, + ); + expect(all.entryId).toBeNull(); + expect(all.allSkipped).toBe(true); + }); + + it("takes at most 20 skips, and only project ids", async () => { + const many = Array.from({ length: 21 }, (_, i) => eid(i + 30)); + await expect(next({ skip: many })).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + await expect(next({ skip: ["not-an-id"] })).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + await expect(next({ skip: many.slice(0, 20) })).resolves.toMatchObject({ + entryId: order[0], + }); + }); + + it("offers every running presentation at a scheduled challenge, not just a window", async () => { + const ids = POOL.slice(0, 5); + const sponsorOrder = queueOrder(ME, SPONSOR, ids); + const running = ids.map((entryId) => ({ + id: `slot-${entryId}`, + entryId, + startsAt: minutesAgo(5), + deadlineAt: minutesAhead(5), + })); + const result = await next( + { challengeId: SPONSOR, skip: sponsorOrder.slice(0, 4) }, + { + [via(judgingChallenges)]: [general, sponsor], + [via(judgingSchedules)]: [{ id: "schedule" }], + [presenceKey]: seatAt(SPONSOR), + [entriesKey]: ids.map((id) => ({ id })), + [via(judgingAppointments)]: running, + }, + ); + + expect(result.entryId).toBe(sponsorOrder[4]); + }); +}); + +describe("desk.projects: open projects in the judge's queue order", () => { + const titled = [ + ["Alpha", eid(40)], + ["Bravo", eid(41)], + ["Charlie", eid(42)], + ["Delta", eid(43)], + ["Echo", eid(44)], + ["Foxtrot", eid(45)], + ["Golf", eid(46)], + ["Hotel", eid(47)], + ].map(([title, id]) => entry(id, title)); + // Alpha is the judge's own, Bravo is held by another judge, Charlie was + // graded by another judge, and the rest are open. + const [MINE, HELD, GRADED, ...OPEN] = titled.map((e) => e.id); + + const list = async ( + over: Answers = {}, + input: { sort?: "scheduled" | "title"; challengeId?: string } = {}, + ) => { + const { db } = fakeDb( + memberWorld({ + [entriesKey]: titled, + [drafts]: [ + { + entryId: MINE, + judgeId: ME, + updatedAt: minutesAgo(1), + deadlineAt: null, + }, + { + entryId: HELD, + judgeId: OTHER, + updatedAt: minutesAgo(1), + deadlineAt: null, + }, + ], + [evaluations]: [ + { entryId: GRADED, judgeId: OTHER, revision: 1, counted: true }, + ], + ...over, + }), + ); + return judgingDeskRouter.createCaller(memberCtx(db)).projects({ + hackathonId: HACK, + inMyRoomOnly: false, + hideJudged: false, + ...input, + }); + }; + + it("lists the judge's own first, then open projects in their queue order", async () => { + const result = await list(); + const queue = queueOrder(ME, GENERAL, OPEN); + // The test only means something if the queue is not the title order. + expect(queue).not.toEqual(OPEN); + + expect(result.projects.map((p) => p.id)).toEqual([ + MINE, + ...queue, + HELD, + GRADED, + ]); + expect(result.projects.map((p) => p.grading)).toEqual([ + "mine", + ...queue.map(() => "open"), + "in_progress", + "graded", + ]); + }); + + it("puts first the open project that Grade next project goes to", async () => { + const result = await list(); + const { db } = fakeDb( + memberWorld({ + [entriesKey]: titled.map((e) => ({ id: e.id })), + [drafts]: [ + { + entryId: MINE, + judgeId: ME, + updatedAt: minutesAgo(1), + deadlineAt: null, + }, + { + entryId: HELD, + judgeId: OTHER, + updatedAt: minutesAgo(1), + deadlineAt: null, + }, + ], + [evaluations]: [{ entryId: GRADED, judgeId: OTHER }], + }), + ); + const next = await judgingDeskRouter + .createCaller(memberCtx(db)) + .nextProject({ hackathonId: HACK }); + + expect(result.projects.find((p) => p.grading === "open")?.id).toBe( + next.entryId, + ); + }); + + it("keeps title order when the judge asks for it", async () => { + const result = await list({}, { sort: "title" }); + + expect( + result.projects.filter((p) => p.grading === "open").map((p) => p.title), + ).toEqual(["Delta", "Echo", "Foxtrot", "Golf", "Hotel"]); + }); + + it("keeps presentation order at a scheduled challenge", async () => { + const slot = (entryId: string, minutes: number) => ({ + id: `slot-${entryId}`, + entryId, + roomId: ROOM, + roomName: "Table 4", + buildingName: null, + startsAt: minutesAhead(minutes), + deadlineAt: minutesAhead(minutes + 10), + endsAt: minutesAhead(minutes + 12), + }); + const [delta, echo, foxtrot] = OPEN; + const result = await list( + { + [via(judgingChallenges)]: [general, sponsor], + [via(judgingSchedules)]: [{ id: "schedule" }], + [presenceKey]: seatAt(SPONSOR), + [via(judgingAppointments, judgingRooms, judgingBuildings)]: [ + slot(foxtrot, 10), + slot(echo, 20), + slot(delta, 30), + ], + }, + { challengeId: SPONSOR }, + ); + + expect(result.projects.map((p) => p.title)).toEqual([ + "Foxtrot", + "Echo", + "Delta", + "Alpha", + "Golf", + "Hotel", + "Bravo", + "Charlie", + ]); + }); +}); + +describe("desk.startGrading: the hold starts when a project is opened", () => { + const start = (entryId = E_OPEN, challengeId = GENERAL) => ({ + hackathonId: HACK, + entryId, + challengeId, + }); + const desk = (answers: Answers, ctx = memberCtx) => { + const fake = fakeDb(answers); + return { + ...fake, + caller: judgingDeskRouter.createCaller(ctx(fake.db)), + }; + }; + const insertedDraft = (events: Event[]) => + events.find((e) => e.kind === "insert" && e.table === drafts)?.values; + + it("creates an empty draft after taking the grading lock", async () => { + const { caller, events } = desk(memberWorld()); + + const result = await caller.startGrading(start()); + + expect(result).toEqual({ held: true, revision: 0, deadlineAt: null }); + expect(insertedDraft(events)).toMatchObject({ + hackathonId: HACK, + judgeId: ME, + entryId: E_OPEN, + challengeId: GENERAL, + ratings: {}, + responses: {}, + revision: 0, + appointmentId: null, + deadlineAt: null, + }); + expect(kinds(events).indexOf("execute")).toBeGreaterThan(-1); + expect(kinds(events).indexOf("execute")).toBeLessThan( + kinds(events).indexOf("insert"), + ); + }); + + it("picks the challenge a table judge sits at when none is named", async () => { + const { caller, events } = desk( + memberWorld({ + [via(judgingChallenges)]: [general, sponsor], + [presenceKey]: seatAt(SPONSOR), + }), + ); + + await caller.startGrading({ hackathonId: HACK, entryId: E_OPEN }); + + expect(insertedDraft(events)).toMatchObject({ challengeId: SPONSOR }); + }); + + it("touches a draft the judge already has, keeping its answers and revision", async () => { + const { caller, events } = desk( + memberWorld({ + [drafts]: byCall([{ id: "mine", revision: 3, deadlineAt: null }]), + }), + ); + + const result = await caller.startGrading(start()); + + expect(result).toEqual({ held: true, revision: 3, deadlineAt: null }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(false); + const touched = events.find( + (e) => e.kind === "update" && e.table === drafts, + ); + expect(touched?.values).toHaveProperty("updatedAt"); + expect(touched?.values).not.toHaveProperty("revision"); + expect(touched?.values).not.toHaveProperty("ratings"); + expect(touched?.values).not.toHaveProperty("responses"); + // A draft they hold needs no queue check. + expect(events.filter((e) => e.table === entriesKey)).toHaveLength(1); + }); + + it("does not make the editor's first save stale", async () => { + // The sheet opened with no draft and sends revision 0; the empty draft + // holds revision 0, so that save goes through as an update of it. + const { caller, events } = desk( + memberWorld({ + [drafts]: byCall([{ id: "new", revision: 0, deadlineAt: null }]), + }), + ); + + await expect( + caller.saveDraft({ ...save, revision: 0 }), + ).resolves.toMatchObject({ revision: 1 }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(false); + expect(events.some((e) => e.kind === "update" && e.table === drafts)).toBe( + true, + ); + }); + + it("refuses a project another judge already graded", async () => { + const { caller, events } = desk( + memberWorld({ [evaluations]: byCall([], [{ judgeId: OTHER }]) }), + ); + + await expect(caller.startGrading(start())).rejects.toMatchObject({ + code: "CONFLICT", + message: gradedElsewhere, + }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(false); + }); + + it("refuses a project another judge is grading right now", async () => { + const { caller, events } = desk( + memberWorld({ + [drafts]: byCall( + [], + [{ judgeId: OTHER, updatedAt: minutesAgo(1), deadlineAt: null }], + ), + }), + ); + + await expect(caller.startGrading(start())).rejects.toMatchObject({ + code: "CONFLICT", + message: beingGraded, + }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(false); + }); + + it("refuses a project outside the judge's queue window", async () => { + const order = queueOrder(ME, GENERAL, POOL); + const { caller, events } = desk( + memberWorld({ [entriesKey]: POOL.map((id) => entry(id, "Project")) }), + ); + + await expect( + caller.startGrading(start(order[QUEUE_WINDOW])), + ).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + message: queueRefusal, + }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(false); + + await expect( + desk( + memberWorld({ [entriesKey]: POOL.map((id) => entry(id, "Project")) }), + ).caller.startGrading(start(order[0])), + ).resolves.toMatchObject({ held: true }); + }); + + it("leaves the judge's own graded project alone", async () => { + const { caller, events } = desk( + memberWorld({ [evaluations]: byCall([{ id: "mine" }]) }), + ); + + await expect(caller.startGrading(start())).resolves.toMatchObject({ + held: false, + }); + expect(kinds(events)).not.toContain("execute"); + expect(kinds(events)).not.toContain("insert"); + expect(kinds(events)).not.toContain("update"); + }); + + it("is refused while judging is not open", async () => { + const { caller, events } = desk( + memberWorld({ + [via(judgingConfigs)]: [ + { state: "draft", showAllScores: false, closedAt: null }, + ], + }), + ); + + await expect(caller.startGrading(start())).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(false); + }); + + it("holds a guest's own room's project the same way", async () => { + const { caller, events } = desk(guestWorld(), guestCtx); + + await caller.startGrading({ hackathonId: HACK, entryId: E_OPEN }); + + expect(insertedDraft(events)).toMatchObject({ + challengeId: SPONSOR, + ratings: {}, + revision: 0, + }); + }); + + describe("for a scheduled presentation", () => { + const presenting = (startsAt: Date, deadlineAt: Date) => + memberWorld({ + [via(judgingChallenges)]: [general, sponsor], + [via(judgingSchedules)]: [{ id: "schedule" }], + [via(judgingAppointments)]: [ + { + id: "slot", + roomId: ROOM, + startsAt, + deadlineAt, + endsAt: minutesAhead(10), + }, + ], + [presenceKey]: seatAt(SPONSOR), + }); + + it("stamps the draft with the presentation and its deadline, as a save does", async () => { + const deadline = minutesAhead(5); + const { caller, events } = desk(presenting(minutesAgo(5), deadline)); + + const result = await caller.startGrading(start(E_OPEN, SPONSOR)); + + expect(result.deadlineAt).toEqual(deadline); + expect(insertedDraft(events)).toMatchObject({ + challengeId: SPONSOR, + appointmentId: "slot", + deadlineAt: deadline, + revision: 0, + }); + }); + + it("stamps the slot's deadline once it has just passed, inside the write grace", async () => { + const passed = new Date(Date.now() - 5_000); + // Within the grace a write still gets, the slot still gives its + // deadline, so the draft is submitted for the judge when it is due. + const { caller, events } = desk({ + ...presenting(minutesAgo(10), passed), + [drafts]: byCall([ + { id: "mine", revision: 2, deadlineAt: minutesAhead(2) }, + ]), + }); + + const result = await caller.startGrading(start(E_OPEN, SPONSOR)); + + expect(result).toEqual({ held: true, revision: 2, deadlineAt: passed }); + const touched = events.find( + (e) => e.kind === "update" && e.table === drafts, + ); + expect(touched?.values).toMatchObject({ deadlineAt: passed }); + }); + + it("does not start before the presentation does", async () => { + const { caller, events } = desk( + presenting(minutesAhead(10), minutesAhead(20)), + ); + + await expect( + caller.startGrading(start(E_OPEN, SPONSOR)), + ).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + message: + "This project has not presented yet. You can judge it once its presentation starts.", + }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(false); + }); + }); + + describe("a second judge after the first opens a project", () => { + const secondJudge = (held: Event["values"]) => + memberWorld({ + [via(judgingJudges)]: [{ id: OTHER, displayName: "Alan Turing" }], + [via(judgingEntryMembers)]: [{ name: "Ada Lovelace" }], + [drafts]: [ + { + entryId: held?.entryId, + judgeId: held?.judgeId, + updatedAt: held?.updatedAt, + deadlineAt: held?.deadlineAt, + }, + ], + }); + + it("sees the project in progress, read-only, and cannot take it", async () => { + const first = desk(memberWorld()); + await first.caller.startGrading(start()); + const held = insertedDraft(first.events); + expect(held).toBeDefined(); + + const { db } = fakeDb(secondJudge(held)); + const caller = judgingDeskRouter.createCaller(memberCtx(db)); + + const listed = await caller.projects({ + hackathonId: HACK, + inMyRoomOnly: false, + }); + expect(listed.projects.map((p) => p.grading)).toEqual(["in_progress"]); + + const sheet = await caller.project({ + hackathonId: HACK, + entryId: E_OPEN, + challengeId: GENERAL, + }); + expect(sheet.grading).toBe("in_progress"); + expect(sheet.access).toMatchObject({ + canEdit: false, + reason: beingGraded, + gradedElsewhere: true, + }); + + const { db: db2, events } = fakeDb(secondJudge(held)); + const second = judgingDeskRouter.createCaller(memberCtx(db2)); + await expect(second.startGrading(start())).rejects.toMatchObject({ + code: "CONFLICT", + message: beingGraded, + }); + await expect( + second.saveDraft({ ...save, revision: 0 }), + ).rejects.toMatchObject({ code: "CONFLICT", message: beingGraded }); + expect(wrote(events, judgingEvaluationDrafts)).toBe(false); + }); + }); +}); diff --git a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts index e4fefad7..5612dc42 100644 --- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts +++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts @@ -1,7 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { appRouter } from "../root"; +import { errorFormatter } from "../trpc"; import { cache } from "../middleware/cache"; -import { db, hackathons, hackathonParticipants } from "@query/db"; +import { + db, + hackathons, + hackathonParticipants, + hackathonEventAttendees, + hackathonCheckInFlags, +} from "@query/db"; // Fully mock the DB at the file level. vi.mock factories are hoisted and // file-scoped, so this mirrors the shape used by hackathon-flow.test.ts. @@ -14,6 +21,11 @@ const mockDelete = vi.fn(); const mockCount = vi.fn(); /** maxParticipants as the capacity check reads it; undefined = uncapped. */ const mockMaxParticipants = vi.fn<() => number | undefined>(() => undefined); +/** + * A participant's earlier check-ins as recordEventAttendance reads them: + * attendance joined to its event. Empty unless a test says otherwise. + */ +const mockEarlierAttendance = vi.fn<() => unknown[]>(() => []); // Acceptance emails are sent through a dynamic import; intercept it so we can // see exactly who a mass-approval actually mailed. const mockSendAcceptanceEmail = vi.fn(); @@ -106,7 +118,7 @@ vi.mock("@query/db", () => { select: vi.fn().mockImplementation(() => ({ from: (tbl: any) => { const count = mockCount(tbl?._t) ?? 0; - return thenable([ + const node = thenable([ { count, n: count, @@ -114,6 +126,10 @@ vi.mock("@query/db", () => { tbl?._t === "hackathons" ? mockMaxParticipants() : undefined, }, ]); + if (tbl?._t === "hackathonEventAttendees") { + node.innerJoin = () => thenable(mockEarlierAttendance()); + } + return node; }, })), }, @@ -151,6 +167,7 @@ vi.mock("@query/db", () => { userId: "user_id", registrationStatus: "registration_status", checkedInAt: "checked_in_at", + attendanceConfirmedAt: "attendance_confirmed_at", }, hackathonTeams: { _t: "hackathonTeams", @@ -180,6 +197,16 @@ vi.mock("@query/db", () => { participantId: "participant_id", checkedInAt: "checked_in_at", }, + hackathonCheckInFlags: { + _t: "hackathonCheckInFlags", + id: "id", + hackathonId: "hackathon_id", + participantId: "participant_id", + eventId: "event_id", + conflictEventId: "conflict_event_id", + attempts: "attempts", + lastAttemptAt: "last_attempt_at", + }, members: { _t: "members", id: "id", @@ -286,6 +313,7 @@ describe("Hackathon admin management edge cases", () => { cache.clear(); mockCount.mockReturnValue(0); mockMaxParticipants.mockImplementation(() => undefined); + mockEarlierAttendance.mockReturnValue([]); mockFindMany.mockReturnValue([]); mockInsert.mockReturnValue([{}]); mockUpdate.mockReturnValue([{}]); @@ -498,12 +526,12 @@ describe("Hackathon admin management edge cases", () => { expect(mockUpdate).not.toHaveBeenCalled(); }); - it("keeps super-admin queries closed to a bug tester", async () => { + it("keeps the staff account lookup closed to a bug tester", async () => { const caller = testerCaller(); await expect( caller.admin.findUserByEmail({ email: "someone@gatech.edu" }), - ).rejects.toThrow(/Super admin access required/); + ).rejects.toThrow(/Admin access required/); }); }); @@ -535,6 +563,28 @@ describe("Hackathon admin management edge cases", () => { await expect( caller.team.createTeam({ hackathonId: HACK_A, name: "Team" } as never), ).rejects.toThrow(refused); + await expect( + caller.hackathon.confirmAttendance({ hackathonId: HACK_A }), + ).rejects.toThrow(refused); + }); + + // A banned hacker can keep a registration, so the code check-in is a way + // of taking part that has to stop at the gate, before any lookup. + it("refuses a banned hacker's event code check-in", async () => { + const caller = participant(true); + + await expect( + caller.points.me.checkIn({ hackathonId: HACK_A, code: "ABC234" }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: expect.stringMatching(/can't take part in Hacklytics/), + }); + + expect(mockFindFirst).not.toHaveBeenCalledWith( + "hackathonParticipants", + expect.anything(), + ); + expect(mockInsert).not.toHaveBeenCalled(); }); it("lets everyone else through the ban gate", async () => { @@ -1115,6 +1165,539 @@ describe("Hackathon admin management edge cases", () => { }), ).rejects.toThrow(/already checked into/); }); + + it("records a pass scan as a scan", async () => { + const caller = scanCtx(); + + await caller.hackathon.scanParticipantPass({ + hackathonId: HACK_A, + eventId: EVENT_A, + participantId: PART_A1, + }); + + const attendance = mockInsert.mock.calls.find( + (c) => c[1][0] === hackathonEventAttendees, + ); + expect(attendance?.[2][0]).toMatchObject({ method: "scan" }); + }); + + // The desk for a hacker with no phone. It is the scan's check-in, made by + // hand: same attendance row, same promotion, but recorded as manual and + // audited, since no pass was read to vouch for the person. + describe("Desk admit for a hacker without a phone", () => { + const admitInput = { + hackathonId: HACK_A, + eventId: EVENT_A, + participantId: PART_A1, + }; + + const accepted = { + id: PART_A1, + hackathonId: HACK_A, + registrationStatus: "approved", + user: { name: "Ada Lovelace", email: "ada@example.com" }, + }; + const lunch = { + id: EVENT_A, + hackathonId: HACK_A, + name: "Lunch", + startTime: new Date(Date.now() - HOUR), + endTime: new Date(Date.now() + HOUR), + }; + + it("checks an approved hacker in by id, as a manual check-in", async () => { + const caller = adminCaller({ + hackathonParticipants: accepted, + hackathonEvents: lunch, + hackathonEventAttendees: undefined, + }); + + const res = await caller.hackathon.admit(admitInput); + + expect(res).toMatchObject({ + checkedIn: true, + name: "Ada Lovelace", + eventName: "Lunch", + }); + const attendance = mockInsert.mock.calls.find( + (c) => c[1][0] === hackathonEventAttendees, + ); + expect(attendance?.[2][0]).toMatchObject({ + participantId: PART_A1, + method: "manual", + checkedInById: ADMIN_USER, + }); + const rosterWrite = mockUpdate.mock.calls.find( + (c) => c[1][0] === hackathonParticipants, + ); + expect(rosterWrite?.[2][0]).toMatchObject({ + registrationStatus: "checked_in", + }); + expect(rosterWrite?.[2][0].checkedInAt).toBeInstanceOf(Date); + }); + + it("leaves a trail of who admitted whom", async () => { + const caller = adminCaller({ + hackathonParticipants: accepted, + hackathonEvents: lunch, + hackathonEventAttendees: undefined, + }); + + await caller.hackathon.admit(admitInput); + + const values = mockInsert.mock.calls.map((c) => c[2]?.[0]); + expect(values).toContainEqual( + expect.objectContaining({ + userId: ADMIN_USER, + action: "hackathon.admit", + resourceId: PART_A1, + }), + ); + }); + + it("is open to a volunteer, who works the desk", async () => { + const caller = adminCaller( + { + hackathonParticipants: accepted, + hackathonEvents: lunch, + hackathonEventAttendees: undefined, + }, + "volunteer", + ); + + await expect(caller.hackathon.admit(admitInput)).resolves.toMatchObject({ + checkedIn: true, + }); + }); + + it("refuses somebody who has not been accepted", async () => { + const caller = adminCaller({ + hackathonParticipants: { ...accepted, registrationStatus: "pending" }, + }); + + await expect(caller.hackathon.admit(admitInput)).rejects.toThrow( + /not accepted/, + ); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("refuses an event that has already ended", async () => { + const caller = adminCaller({ + hackathonParticipants: accepted, + hackathonEvents: { ...lunch, endTime: new Date(Date.now() - HOUR) }, + }); + + await expect(caller.hackathon.admit(admitInput)).rejects.toThrow( + /already ended/, + ); + }); + + it("will not admit the same person into the event twice", async () => { + const caller = adminCaller({ + hackathonParticipants: accepted, + hackathonEvents: lunch, + hackathonEventAttendees: { id: "already" }, + }); + + await expect(caller.hackathon.admit(admitInput)).rejects.toThrow( + /already checked into/, + ); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("refuses a bug tester", async () => { + const caller = adminCaller({}, "bug_tester"); + + await expect(caller.hackathon.admit(admitInput)).rejects.toThrow( + /read-only/, + ); + }); + }); + }); + + // ===================================================================== + // Organisers' rules for bad actors: nobody is in two events that run at the + // same time, or in two within 30 minutes. They live in recordEventAttendance, + // so every way of writing attendance is held to them. + describe("Event check-in rules", () => { + const MINUTE = 60 * 1000; + + const person = { + id: PART_A1, + hackathonId: HACK_A, + registrationStatus: "checked_in", + firstName: "Ada", + lastName: "Lovelace", + user: { name: "Ada Lovelace", email: "ada@example.com" }, + }; + const fireside = { + id: EVENT_A, + hackathonId: HACK_A, + name: "Full Sail Fireside", + type: "sponsor_session", + points: 10, + selfCheckIn: true, + startTime: new Date(Date.now() - HOUR), + endTime: new Date(Date.now() + HOUR), + }; + + // A check-in for another event, as the attendance read returns it. + const earlier = (event: Record, minutesAgo: number) => ({ + event: { + id: "99999999-9999-4999-8999-999999999999", + name: "BNY Workshop", + type: "workshop", + startTime: new Date(Date.now() - 5 * HOUR), + endTime: new Date(Date.now() - 4 * HOUR), + ...event, + }, + checkedInAt: new Date(Date.now() - minutesAgo * MINUTE), + }); + + // The hacker's own session, past the ban gate, with the event live. + const hackerCaller = () => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "users") return { email: "ada@example.com" }; + if (table === "hackathonParticipants") return person; + if (table === "hackathonEvents") return fireside; + return undefined; + }); + return appRouter.createCaller(createMockCtx("rules-hacker")); + }; + + const attendanceWrites = () => + mockInsert.mock.calls.filter((c) => c[1][0] === hackathonEventAttendees); + + // Each refused attempt leaves a flag for organisers; the values are the + // third element of the mock's recorded call. + const flagWrites = () => + mockInsert.mock.calls.filter((c) => c[1][0] === hackathonCheckInFlags); + + // The event the fixtures' earlier check-ins are for. + const BNY = "99999999-9999-4999-8999-999999999999"; + + it("refuses a code check-in into an event that overlaps one already attended", async () => { + const caller = hackerCaller(); + mockEarlierAttendance.mockReturnValue([ + earlier( + { + startTime: new Date(Date.now() - 30 * MINUTE), + endTime: new Date(Date.now() + 30 * MINUTE), + }, + 20, + ), + ]); + + await expect( + caller.points.me.checkIn({ hackathonId: HACK_A, code: "ABC234" }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: + "You're already checked into BNY Workshop, which runs at the same time as Full Sail Fireside. You can check into one event at a time.", + }); + expect(attendanceWrites()).toHaveLength(0); + }); + + it("lets a code check-in through when nothing overlaps and the last was long ago", async () => { + const caller = hackerCaller(); + mockEarlierAttendance.mockReturnValue([earlier({}, 120)]); + + await expect( + caller.points.me.checkIn({ hackathonId: HACK_A, code: "ABC234" }), + ).resolves.toMatchObject({ eventName: "Full Sail Fireside" }); + expect(attendanceWrites()).toHaveLength(1); + }); + + it("refuses a pass scan within 30 minutes of the last check-in", async () => { + const caller = adminCaller({ + hackathonParticipants: { ...person, registrationStatus: "approved" }, + hackathonEvents: fireside, + hackathonEventAttendees: undefined, + }); + mockEarlierAttendance.mockReturnValue([earlier({}, 10)]); + + await expect( + caller.hackathon.scanParticipantPass({ + hackathonId: HACK_A, + eventId: EVENT_A, + participantId: PART_A1, + }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: + "Ada Lovelace checked into BNY Workshop 10 minutes ago. Event check-ins have to be at least 30 minutes apart. If that was a mistake, remove them from BNY Workshop first.", + }); + // The whole scan rolls back: no attendance, and no arrival either. + expect(attendanceWrites()).toHaveLength(0); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("refuses a manual check-in into an event that overlaps one already attended", async () => { + const caller = adminCaller({ + hackathonParticipants: person, + hackathonEvents: fireside, + }); + mockEarlierAttendance.mockReturnValue([ + earlier( + { + startTime: new Date(Date.now() - 30 * MINUTE), + endTime: new Date(Date.now() + 30 * MINUTE), + }, + 45, + ), + ]); + + await expect( + caller.hackathon.manualEventCheckIn({ + hackathonId: HACK_A, + eventId: EVENT_A, + participantId: PART_A1, + }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: + "Ada Lovelace is already checked into BNY Workshop, which runs at the same time as Full Sail Fireside. If that was a mistake, remove them from BNY Workshop first.", + }); + expect(attendanceWrites()).toHaveLength(0); + }); + + describe("flags for organisers", () => { + const overlapping = () => + earlier( + { + startTime: new Date(Date.now() - 30 * MINUTE), + endTime: new Date(Date.now() + 30 * MINUTE), + }, + 20, + ); + + it("records a refused code check-in against the hacker", async () => { + const caller = hackerCaller(); + mockEarlierAttendance.mockReturnValue([overlapping()]); + + await expect( + caller.points.me.checkIn({ hackathonId: HACK_A, code: "ABC234" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(flagWrites()).toHaveLength(1); + expect(flagWrites()[0]![2][0]).toEqual({ + hackathonId: HACK_A, + participantId: PART_A1, + eventId: EVENT_A, + conflictEventId: BNY, + rule: "overlap", + method: "code", + attemptedById: "rules-hacker", + attempts: 1, + createdAt: expect.any(Date), + lastAttemptAt: expect.any(Date), + }); + }); + + it("records a refused pass scan, with the staff member who scanned", async () => { + const caller = adminCaller({ + hackathonParticipants: { ...person, registrationStatus: "approved" }, + hackathonEvents: fireside, + hackathonEventAttendees: undefined, + }); + mockEarlierAttendance.mockReturnValue([earlier({}, 10)]); + + await expect( + caller.hackathon.scanParticipantPass({ + hackathonId: HACK_A, + eventId: EVENT_A, + participantId: PART_A1, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(flagWrites()).toHaveLength(1); + expect(flagWrites()[0]![2][0]).toMatchObject({ + hackathonId: HACK_A, + participantId: PART_A1, + eventId: EVENT_A, + conflictEventId: BNY, + rule: "too_soon", + method: "scan", + attemptedById: ADMIN_USER, + }); + }); + + it("records a refused manual check-in", async () => { + const caller = adminCaller({ + hackathonParticipants: person, + hackathonEvents: fireside, + }); + mockEarlierAttendance.mockReturnValue([overlapping()]); + + await expect( + caller.hackathon.manualEventCheckIn({ + hackathonId: HACK_A, + eventId: EVENT_A, + participantId: PART_A1, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(flagWrites()).toHaveLength(1); + expect(flagWrites()[0]![2][0]).toMatchObject({ + participantId: PART_A1, + rule: "overlap", + method: "manual", + attemptedById: ADMIN_USER, + }); + }); + + it("records nothing for a check-in that goes through", async () => { + mockEarlierAttendance.mockReturnValue([earlier({}, 120)]); + + await hackerCaller().points.me.checkIn({ + hackathonId: HACK_A, + code: "ABC234", + }); + await adminCaller({ + hackathonParticipants: person, + hackathonEvents: fireside, + }).hackathon.manualEventCheckIn({ + hackathonId: HACK_A, + eventId: EVENT_A, + participantId: PART_A1, + }); + + expect(attendanceWrites()).toHaveLength(2); + expect(flagWrites()).toHaveLength(0); + }); + + it("records nothing for a duplicate scan", async () => { + const caller = adminCaller({ + hackathonParticipants: { ...person, registrationStatus: "approved" }, + hackathonEvents: fireside, + hackathonEventAttendees: { id: "already" }, + }); + + await expect( + caller.hackathon.scanParticipantPass({ + hackathonId: HACK_A, + eventId: EVENT_A, + participantId: PART_A1, + }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + + expect(flagWrites()).toHaveLength(0); + }); + + it("gives the hacker the same refusal when the flag cannot be written", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const caller = hackerCaller(); + mockEarlierAttendance.mockReturnValue([overlapping()]); + mockInsert.mockImplementation((_op, [table]) => { + if (table === hackathonCheckInFlags) throw new Error("db is down"); + return [{}]; + }); + + await expect( + caller.points.me.checkIn({ hackathonId: HACK_A, code: "ABC234" }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: + "You're already checked into BNY Workshop, which runs at the same time as Full Sail Fireside. You can check into one event at a time.", + }); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it("keeps the flag out of what the hacker is sent", async () => { + const caller = hackerCaller(); + mockEarlierAttendance.mockReturnValue([overlapping()]); + + const error: any = await caller.points.me + .checkIn({ hackathonId: HACK_A, code: "ABC234" }) + .catch((e: unknown) => e); + + // The record rides on the error server side only; the formatter is what + // builds the response. + const response = JSON.stringify( + errorFormatter({ + shape: { + message: error.message, + code: -32003, + data: { + code: "FORBIDDEN", + httpStatus: 403, + path: "points.me.checkIn", + }, + }, + error, + }), + ); + expect(response).not.toContain(PART_A1); + expect(response).not.toContain(BNY); + expect(response).not.toContain("CheckInRuleBreak"); + expect(response).not.toContain("conflictEventId"); + }); + }); + + it("does not hold a meal to the rules", async () => { + const caller = adminCaller({ + hackathonParticipants: person, + hackathonEvents: { ...fireside, name: "Dinner", type: "meal" }, + }); + mockEarlierAttendance.mockReturnValue([ + earlier( + { + startTime: new Date(Date.now() - 30 * MINUTE), + endTime: new Date(Date.now() + 30 * MINUTE), + }, + 5, + ), + ]); + + await expect( + caller.hackathon.manualEventCheckIn({ + hackathonId: HACK_A, + eventId: EVENT_A, + participantId: PART_A1, + }), + ).resolves.toMatchObject({ eventName: "Dinner" }); + expect(attendanceWrites()).toHaveLength(1); + }); + }); + + // ===================================================================== + describe("Chasing unconfirmed seats", () => { + const whereOf = () => + JSON.stringify(mockFindMany.mock.calls[0]?.[1]?.where); + + it("narrows the roster to accepted hackers who have not said they are coming", async () => { + const caller = adminCaller(); + + await caller.hackathon.adminGetAttendees({ + hackathonId: HACK_A, + unconfirmedOnly: true, + }); + + expect(whereOf()).toContain("attendance_confirmed_at"); + expect(whereOf()).toContain("approved"); + }); + + it("does not filter on confirmation unless asked", async () => { + const caller = adminCaller(); + + await caller.hackathon.adminGetAttendees({ hackathonId: HACK_A }); + + expect(whereOf()).not.toContain("attendance_confirmed_at"); + }); + + it("applies the same filter to the export, so the file matches the list", async () => { + const caller = adminCaller(); + + await caller.hackathon.exportAttendees({ + hackathonId: HACK_A, + unconfirmedOnly: true, + }); + + expect(whereOf()).toContain("attendance_confirmed_at"); + }); }); // ===================================================================== @@ -1170,6 +1753,24 @@ describe("Hackathon admin management edge cases", () => { expect(written).not.toHaveProperty("theme"); }); + // The edit form sends null for a blank deadline or cap. Before these were + // nullable, blanking either field silently kept the old value. + it("clears the registration deadline and the cap when sent null", async () => { + const caller = adminCaller({ hackathons: liveHackathon() }); + mockUpdate.mockReturnValue([{ id: HACK_A }]); + + await caller.hackathon.update({ + id: HACK_A, + registrationDeadline: null, + maxParticipants: null, + }); + + expect(mockUpdate.mock.calls.at(-1)?.[2]?.[0]).toMatchObject({ + registrationDeadline: null, + maxParticipants: null, + }); + }); + it("stores the tracks it was given", async () => { const caller = adminCaller({ hackathons: liveHackathon() }); mockUpdate.mockReturnValue([{ id: HACK_A }]); @@ -1413,21 +2014,191 @@ describe("Hackathon admin management edge cases", () => { }), ).rejects.toThrow(/last super admin/i); }); + + // The check-in desk is staffed by volunteers, and a plain admin can get + // one a scanner login without waiting on a super admin. Every other role + // is still a super admin's to give. + describe("Staffing the check-in desk", () => { + const VOLUNTEER_USER = "volunteer_user"; + const TARGET_ROW = "77777777-7777-4777-8777-777777777772"; + + /** + * The caller's own admins row first, then the row being changed — or + * nobody, for a first grant. The gate reads the caller before anything. + */ + const asRole = (role: string, target?: Record | null) => { + let reads = 0; + mockFindFirst.mockImplementation((table: string) => { + if (table === "users") return { id: VOLUNTEER_USER }; + if (table !== "admins") return undefined; + reads += 1; + if (reads === 1) return adminRow(role); + if (target === null) return undefined; + return { + id: TARGET_ROW, + userId: VOLUNTEER_USER, + role: "volunteer", + isActive: true, + ...target, + }; + }); + return appRouter.createCaller(createMockCtx(ADMIN_USER)); + }; + + const written = () => mockInsert.mock.calls.map((c) => c[2]?.[0]); + + it("lets a plain admin grant the volunteer role", async () => { + const caller = asRole("admin", null); + mockInsert.mockReturnValue([{ id: TARGET_ROW, role: "volunteer" }]); + + await expect( + caller.admin.create({ userId: VOLUNTEER_USER, role: "volunteer" }), + ).resolves.toMatchObject({ role: "volunteer" }); + expect(written()).toContainEqual( + expect.objectContaining({ userId: VOLUNTEER_USER, role: "volunteer" }), + ); + }); + + it("records who granted the role", async () => { + const caller = asRole("admin", null); + mockInsert.mockReturnValue([{ id: TARGET_ROW, role: "volunteer" }]); + + await caller.admin.create({ userId: VOLUNTEER_USER, role: "volunteer" }); + + expect(written()).toContainEqual( + expect.objectContaining({ + userId: ADMIN_USER, + action: "admin.create", + resourceId: VOLUNTEER_USER, + }), + ); + }); + + it.each(["admin", "moderator", "super_admin", "bug_tester"] as const)( + "refuses a plain admin granting %s", + async (role) => { + const caller = asRole("admin", null); + + await expect( + caller.admin.create({ userId: VOLUNTEER_USER, role }), + ).rejects.toThrow(/Super admin access required/); + expect(mockInsert).not.toHaveBeenCalled(); + }, + ); + + it("still lets a super admin grant any role", async () => { + const caller = asRole("super_admin", null); + mockInsert.mockReturnValue([{ id: TARGET_ROW, role: "admin" }]); + + await expect( + caller.admin.create({ userId: VOLUNTEER_USER, role: "admin" }), + ).resolves.toBeDefined(); + }); + + it("refuses a plain admin handing a volunteer permissions", async () => { + const caller = asRole("admin", null); + + await expect( + caller.admin.create({ + userId: VOLUNTEER_USER, + role: "volunteer", + permissions: ["everything"], + }), + ).rejects.toThrow(/Super admin access required/); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it("refuses a bug tester granting anything", async () => { + const caller = asRole("bug_tester", null); + + await expect( + caller.admin.create({ userId: VOLUNTEER_USER, role: "volunteer" }), + ).rejects.toThrow(/read-only/); + }); + + it("lets a plain admin revoke a volunteer", async () => { + const caller = asRole("admin"); + mockUpdate.mockReturnValue([ + { id: TARGET_ROW, role: "volunteer", isActive: false }, + ]); + + await expect( + caller.admin.update({ adminId: TARGET_ROW, isActive: false }), + ).resolves.toMatchObject({ isActive: false }); + expect(written()).toContainEqual( + expect.objectContaining({ + userId: ADMIN_USER, + action: "admin.update", + resourceId: VOLUNTEER_USER, + }), + ); + }); + + it("lets a plain admin delete a volunteer", async () => { + const caller = asRole("admin"); + + await expect( + caller.admin.remove({ adminId: TARGET_ROW }), + ).resolves.toEqual({ success: true }); + expect(mockDelete).toHaveBeenCalled(); + expect(written()).toContainEqual( + expect.objectContaining({ action: "admin.remove" }), + ); + }); + + it("refuses a plain admin promoting a volunteer", async () => { + const caller = asRole("admin"); + + await expect( + caller.admin.update({ adminId: TARGET_ROW, role: "admin" }), + ).rejects.toThrow(/Super admin access required/); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + // Granting "volunteer" over a row that holds another role is a demotion. + // It must not be a way past the rule for revoking one. + it.each(["admin", "moderator", "super_admin"])( + "refuses a plain admin demoting, deactivating or deleting a %s", + async (held) => { + const caller = asRole("admin", { role: held }); + + await expect( + caller.admin.update({ adminId: TARGET_ROW, role: "volunteer" }), + ).rejects.toThrow(/Super admin access required/); + await expect( + caller.admin.update({ adminId: TARGET_ROW, isActive: false }), + ).rejects.toThrow(/Super admin access required/); + await expect( + caller.admin.remove({ adminId: TARGET_ROW }), + ).rejects.toThrow(/Super admin access required/); + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockDelete).not.toHaveBeenCalled(); + }, + ); + + it("lets a plain admin look up who they are about to add", async () => { + const caller = asRole("admin"); + + await expect( + caller.admin.findUserByEmail({ email: "someone@gatech.edu" }), + ).resolves.toBeDefined(); + }); + }); }); // ===================================================================== describe("Day-of dashboard", () => { - // BUG: analyticsOverview counts eventCheckIns (the club check-in table), - // not hackathonEventAttendees, which is what scanParticipantPass writes. - it("counts today's hackathon badge scans", async () => { + it("counts today's hackathon badge scans apart from club door check-ins", async () => { const caller = adminCaller({}, "admin"); mockCount.mockImplementation((t: string) => - t === "hackathonEventAttendees" ? 7 : 0, + t === "hackathonEventAttendees" ? 7 : t === "eventCheckIns" ? 2 : 0, ); const res = await caller.admin.analyticsOverview(); - expect(res.checkinsToday).toBe(7); + expect(res.badgeScansToday).toBe(7); + expect(res.doorCheckinsToday).toBe(2); + expect(res.checkinsToday).toBe(9); }); }); }); diff --git a/packages/api/src/.internal-tests/hackathon-flow.test.ts b/packages/api/src/.internal-tests/hackathon-flow.test.ts index 6a920b27..37c4844e 100644 --- a/packages/api/src/.internal-tests/hackathon-flow.test.ts +++ b/packages/api/src/.internal-tests/hackathon-flow.test.ts @@ -189,7 +189,6 @@ const HACK_B = "22222222-2222-4222-8222-222222222222"; const PARTICIPANT = "33333333-3333-4333-8333-333333333333"; const EVENT_A = "44444444-4444-4444-8444-444444444444"; const EVENT_B = "55555555-5555-4555-8555-555555555555"; -const PROJECT = "66666666-6666-4666-8666-666666666666"; const DAY = 24 * 60 * 60 * 1000; @@ -244,6 +243,8 @@ describe("Hackathon end-to-end flow", () => { country: "United States", whyAttend: "I want to build with data.", agreeToCodeOfConduct: true as const, + mlhCodeOfConduct: true as const, + mlhDataSharing: true as const, }); // --------------------------------------------------------------------- @@ -492,107 +493,6 @@ describe("Hackathon end-to-end flow", () => { }); }); - // --------------------------------------------------------------------- - describe("4. Judges grade projects", () => { - const judgeCaller = () => { - mockFindFirst.mockImplementation((table) => { - if (table === "judgingProjects") - return { id: PROJECT, hackathonId: HACK_A }; - if (table === "judges") - return { id: "judge_1", userId: "judge_user", hackathonId: HACK_A }; - // The project was routed to this judge, which is the ordinary case - // these tests are about; scoring something never assigned to you is - // covered separately in judge-edge. - if (table === "judgeQueue") - return { - id: "queue_1", - judgeId: "judge_1", - projectId: PROJECT, - isCompleted: false, - // Tapped in a minute ago: inside the scoring window. - startedAt: new Date(Date.now() - 60_000), - arrivedAt: new Date(Date.now() - 60_000), - }; - return undefined; - }); - return appRouter.createCaller(createMockCtx("judge_user")); - }; - - const scores = { - projectId: PROJECT, - scoreCreativity: 8, - scoreImpact: 7, - scoreScope: 6, - scoreClarity: 9, - scoreSoundness: 5, - }; - - it("records a vote and sums the five criteria", async () => { - const caller = judgeCaller(); - mockInsert.mockReturnValue([{ id: "vote_1", score: 35 }]); - - const res = await caller.judge.submitVote(scores); - - expect(res).toMatchObject({ score: 35 }); - const inserted = mockInsert.mock.calls[0]?.[2]?.[0]; - expect(inserted.score).toBe(35); - expect(inserted.judgeId).toBe("judge_1"); - }); - - it("refuses a vote from a user who is not a judge for that hackathon", async () => { - mockFindFirst.mockImplementation((table) => - table === "judgingProjects" - ? { id: PROJECT, hackathonId: HACK_A } - : undefined, - ); - const caller = appRouter.createCaller(createMockCtx("random_user")); - - await expect(caller.judge.submitVote(scores)).rejects.toThrow( - /Judge access required/, - ); - }); - - it.each([0, 11])("rejects an out-of-range score of %i", async (bad) => { - const caller = judgeCaller(); - - await expect( - caller.judge.submitVote({ ...scores, scoreImpact: bad }), - ).rejects.toThrow(); - }); - - it("only lets an admin toggle judging on", async () => { - const caller = adminCaller(); - mockWhereRows.mockReturnValueOnce([{ n: 12 }]); - mockUpdate.mockReturnValue([{ judgingActive: true }]); - - const res = await caller.judge.toggleJudging({ - hackathonId: HACK_A, - active: true, - }); - expect(res).toMatchObject({ success: true, judgingActive: true }); - }); - - // With nothing queued every judge opened straight onto "All done, 0 of 0". - it("refuses to open judging before any judge has a table", async () => { - const caller = adminCaller(); - mockWhereRows.mockReturnValueOnce([{ n: 0 }]); - - await expect( - caller.judge.toggleJudging({ hackathonId: HACK_A, active: true }), - ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); - expect(mockUpdate).not.toHaveBeenCalled(); - }); - - it("refuses to let a non-admin toggle judging", async () => { - mockFindFirst.mockImplementation(() => undefined); - const caller = appRouter.createCaller(createMockCtx("random_user")); - - await expect( - caller.judge.toggleJudging({ hackathonId: HACK_A, active: true }), - ).rejects.toThrow(/Admin access required/); - }); - }); - // --------------------------------------------------------------------- describe("5. Team window", () => { const HOUR = 60 * 60 * 1000; @@ -881,214 +781,6 @@ describe("Hackathon end-to-end flow", () => { }); }); - // --------------------------------------------------------------------- - describe("8. Judge queue", () => { - const QUEUE_A = "99999999-9999-4999-8999-999999999999"; - - /** - * `isJudge` resolves the hackathon from the input. When the input carries a - * projectId it looks up judgingProjects; when it only carries a queueId it - * burns the first judgeQueue lookup. `middlewareHitsQueue` accounts for it. - */ - const queueCaller = (opts: { - queueItem?: Record | undefined; - next?: Record | undefined; - middlewareHitsQueue?: boolean; - }) => { - const skip = opts.middlewareHitsQueue ? 1 : 0; - let queueLookups = 0; - mockFindFirst.mockImplementation((table) => { - if (table === "judgingProjects") - return { id: PROJECT, hackathonId: HACK_A }; - if (table === "judges") - return { id: "judge_1", userId: "judge_user", hackathonId: HACK_A }; - if (table === "hackathons") return { id: HACK_A }; - if (table === "judgeQueue") { - queueLookups += 1; - // judge_queue.project_id is NOT NULL, so a slot always names the - // project it covers. Defaulted here (overridable per test) because - // completeAndNext checks the slot against the project being scored. - if (skip && queueLookups === 1) - return { id: QUEUE_A, hackathonId: HACK_A, projectId: PROJECT }; - if (queueLookups === skip + 1) - return opts.queueItem && { projectId: PROJECT, ...opts.queueItem }; - return opts.next; - } - if (table === "judgeAssignments") return undefined; - return undefined; - }); - return appRouter.createCaller(createMockCtx("judge_user")); - }; - - const vote = { - queueId: QUEUE_A, - projectId: PROJECT, - scoreCreativity: 5, - scoreImpact: 5, - scoreScope: 5, - scoreClarity: 5, - scoreSoundness: 5, - }; - - /** At the table, tapped in a minute ago. */ - const arrived = () => ({ - isCompleted: false, - startedAt: new Date(Date.now() - 60_000), - arrivedAt: new Date(Date.now() - 60_000), - }); - - /** - * The reads dispatch makes through select, in order: assignments and - * projects for the pool, this judge's visits, then every vote and every - * open visit when it has a table to pick. Which table it picks is covered - * against real Postgres in routers/judge/dispatch.db.test.ts. An empty - * pool stops after the third read; queueing more would leak into the - * next test, since clearAllMocks does not drain once-values. - */ - const poolOf = (projects: unknown[]) => { - mockWhereRows - .mockReturnValueOnce([]) - .mockReturnValueOnce(projects) - .mockReturnValueOnce([]); - if (projects.length > 0) - mockWhereRows.mockReturnValueOnce([]).mockReturnValueOnce([]); - }; - - it("completes the current project and hands back the next one", async () => { - const caller = queueCaller({ - queueItem: { id: QUEUE_A, judgeId: "judge_1", hackathonId: HACK_A, ...arrived() }, - }); - poolOf([ - { - id: "project_b", - tableNumber: 2, - tracks: null, - challenges: null, - isCreateX: false, - }, - ]); - mockInsert.mockReturnValue([{ id: "queue_b" }]); - - const res = await caller.judge.completeAndNext(vote); - expect(res).toMatchObject({ - done: false, - nextQueueId: "queue_b", - timedOut: false, - }); - }); - - it("reports done when nothing is left in the queue", async () => { - const caller = queueCaller({ - queueItem: { id: QUEUE_A, judgeId: "judge_1", hackathonId: HACK_A, ...arrived() }, - }); - poolOf([]); - - const res = await caller.judge.completeAndNext(vote); - expect(res).toMatchObject({ done: true, nextProject: null }); - }); - - it("errors when skipping a queue item that does not exist", async () => { - const caller = queueCaller({ - middlewareHitsQueue: true, - queueItem: undefined, - next: undefined, - }); - - await expect( - caller.judge.skipProject({ queueId: QUEUE_A }), - ).rejects.toThrow(/Queue item not found/); - }); - - it("force-skips an overtime project without recording a vote", async () => { - const caller = queueCaller({ - middlewareHitsQueue: true, - queueItem: { - id: QUEUE_A, - judgeId: "judge_1", - hackathonId: HACK_A, - ...arrived(), - }, - }); - poolOf([]); - mockInsert.mockClear(); - - await caller.judge.forceSkipOvertime({ queueId: QUEUE_A }); - - // No vote row should be written for a forced skip. - expect(mockInsert).not.toHaveBeenCalled(); - }); - - it("errors when force-skipping a queue item that does not exist", async () => { - const caller = queueCaller({ - middlewareHitsQueue: true, - queueItem: undefined, - }); - - await expect( - caller.judge.forceSkipOvertime({ queueId: QUEUE_A }), - ).rejects.toThrow(/Queue item not found/); - }); - }); - - // --------------------------------------------------------------------- - describe("9. Rankings", () => { - it("ranks projects and normalizes across judges", async () => { - const caller = adminCaller(); - mockFindMany.mockImplementation((table) => { - if (table !== "judgingProjects") return []; - return [ - { - id: "p_low", - name: "Low", - hackathonId: HACK_A, - votes: [ - { judgeId: "j1", score: 20, judge: { user: { name: "J1" } } }, - { judgeId: "j2", score: 22, judge: { user: { name: "J2" } } }, - ], - }, - { - id: "p_high", - name: "High", - hackathonId: HACK_A, - votes: [ - { judgeId: "j1", score: 45, judge: { user: { name: "J1" } } }, - { judgeId: "j2", score: 48, judge: { user: { name: "J2" } } }, - ], - }, - ]; - }); - - const res = await caller.judge.getRankings({ hackathonId: HACK_A }); - - expect(res.rankings.length).toBe(2); - // The better-scored project must outrank the weaker one. - const ids = res.rankings.map((r: any) => r.project.id); - expect(ids[0]).toBe("p_high"); - expect(res.globalAvg).toBeGreaterThan(0); - }); - - it("returns an empty ranking when no projects exist", async () => { - const caller = adminCaller(); - mockFindMany.mockReturnValue([]); - - const res = await caller.judge.getRankings({ hackathonId: HACK_A }); - expect(res).toMatchObject({ - rankings: [], - globalAvg: 0, - hasTies: false, - }); - }); - - it("requires admin rights to read rankings", async () => { - mockFindFirst.mockImplementation(() => undefined); - const caller = appRouter.createCaller(createMockCtx("random_user")); - - await expect( - caller.judge.getRankings({ hackathonId: HACK_A }), - ).rejects.toThrow(/Admin access required/); - }); - }); - // --------------------------------------------------------------------- describe("9. Portal links resolve", () => { const bloom = () => diff --git a/packages/api/src/.internal-tests/hackathon-interest.test.ts b/packages/api/src/.internal-tests/hackathon-interest.test.ts index 3c43c765..3206ebb6 100644 --- a/packages/api/src/.internal-tests/hackathon-interest.test.ts +++ b/packages/api/src/.internal-tests/hackathon-interest.test.ts @@ -269,6 +269,39 @@ describe("Hackathon interest list", () => { const res = await callerFor().hackathon.getUpcoming(); expect(res?.registrationOpen).toBe(true); }); + + /** + * The page has to resolve after the event too, so a registered hacker lands + * on something that points at their results instead of "Nothing announced + * yet". A finished edition only stands in when none is ahead of it. + */ + it("falls back to the finished edition once nothing is ahead of it", async () => { + // The first lookup (editions ahead) finds nothing; the second does. + let editionLookups = 0; + mockFindFirst.mockImplementation((tableName: string) => { + if (tableName !== "hackathons") return undefined; + editionLookups += 1; + return editionLookups === 1 + ? undefined + : announced({ status: "completed" }); + }); + + const res = await callerFor().hackathon.getUpcoming(); + expect(res?.name).toBe("Example Hackathon"); + expect(res?.status).toBe("completed"); + expect(res?.registrationOpen).toBe(false); + }); + + it("prefers an edition that is ahead over a finished one", async () => { + lookups({ hackathon: announced({ status: "open" }) }); + + const res = await callerFor().hackathon.getUpcoming(); + expect(res?.status).toBe("open"); + // Answered by the first lookup; the finished-edition fallback never ran. + expect( + mockFindFirst.mock.calls.filter(([name]) => name === "hackathons"), + ).toHaveLength(1); + }); }); describe("5. Telling the list registration opened", () => { diff --git a/packages/api/src/.internal-tests/judge-edge.test.ts b/packages/api/src/.internal-tests/judge-edge.test.ts index 421db11e..2b0fb396 100644 --- a/packages/api/src/.internal-tests/judge-edge.test.ts +++ b/packages/api/src/.internal-tests/judge-edge.test.ts @@ -1,18 +1,12 @@ /** - * Judge-side edge cases: queue ownership, vote/queue consistency, the judging - * window and the scoring cutoff, and admin cleanup paths. Which table the - * shared pool hands out is covered against real Postgres in - * routers/judge/dispatch.db.test.ts, not here. - * - * Tests marked `it.skip` are written against the behaviour the product SHOULD - * have. Each one currently fails against the shipped handler; the comment - * above it names the file and lines responsible. They are deliberately left in - * place — unskipping one is the acceptance criterion for its fix. + * Judge roster edge cases: approval, the preferred track on an application, + * removal and its cache eviction, and an organiser pulling a project out of + * judging. Room judging has its own tests next to its services. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { appRouter } from "../root"; import { cache } from "../middleware/cache"; -import { db, judgeQueue, judgeVotes } from "@query/db"; +import { db, judges, judgeAssignments, judgingEntries } from "@query/db"; // Fully mock the DB at the file level, mirroring hackathon-flow.test.ts. const mockFindFirst = vi.fn(); @@ -59,8 +53,6 @@ vi.mock("@query/db", () => { return { db: { transaction: vi.fn().mockImplementation((callback) => callback(db)), - // The judging dispatch lock (pg_advisory_xact_lock). - execute: vi.fn().mockResolvedValue({ rows: [] }), query: { admins: table("admins"), users: table("users"), @@ -80,6 +72,8 @@ vi.mock("@query/db", () => { judgeVotes: table("judgeVotes"), judgeQueue: table("judgeQueue"), hackathonResults: table("hackathonResults"), + judgingEntries: table("judgingEntries"), + judgingPlacements: table("judgingPlacements"), stripePayments: table("stripePayments"), userAccountLinks: table("userAccountLinks"), auditLogs: table("auditLogs"), @@ -215,6 +209,18 @@ vi.mock("@query/db", () => { placement: "placement", publishedAt: "published_at", }, + judgingPlacements: { + id: "id", + hackathonId: "hackathon_id", + publishedAt: "published_at", + }, + judgingEntries: { + id: "id", + sourceProjectId: "source_project_id", + deletedAt: "deleted_at", + deletedById: "deleted_by_id", + updatedAt: "updated_at", + }, judgeQueue: { id: "id", judgeId: "judge_id", @@ -237,12 +243,8 @@ vi.mock("@query/db", () => { }); const HACK_A = "11111111-1111-4111-8111-111111111111"; -const HACK_B = "22222222-2222-4222-8222-222222222222"; const PROJECT_A = "33333333-3333-4333-8333-333333333333"; -const PROJECT_B = "44444444-4444-4444-8444-444444444444"; -const QUEUE_A = "55555555-5555-4555-8555-555555555555"; const JUDGE_ID = "66666666-6666-4666-8666-666666666666"; -const OTHER_JUDGE_ID = "77777777-7777-4777-8777-777777777777"; const JUDGE_ROW = { id: JUDGE_ID, @@ -251,35 +253,17 @@ const JUDGE_ROW = { isActive: true, name: "Grace Hopper", }; -// Super admin, because activating and deactivating a judge is restricted to -// that tier. It still passes isAdmin, so the rest of the suite is unaffected. +// Super admin, because removing a judge is restricted to that tier. It still +// passes isAdmin, so the rest of the suite is unaffected. const ADMIN_ROW = { userId: "admin_user", isActive: true, role: "super_admin" }; const PLAIN_ADMIN_ROW = { userId: "admin_user", isActive: true, role: "admin" }; -/** Returns successive elements of `items`, then undefined forever. */ -const seq = (items: unknown[]) => { - let i = 0; - return () => items[i++]; -}; - -const scores = { - scoreCreativity: 5, - scoreImpact: 5, - scoreScope: 5, - scoreClarity: 5, - scoreSoundness: 5, -}; - -describe("Judge edge cases", () => { +describe("Judge roster edge cases", () => { beforeEach(() => { vi.clearAllMocks(); cache.clear(); mockFindFirst.mockImplementation(() => undefined); mockFindMany.mockImplementation(() => []); - // Tests drive successive aggregate reads with mockReturnValueOnce, and - // clearAllMocks does not drain that queue — a test that queues more values - // than its handler consumes would otherwise hand its leftovers to whichever - // test runs next, making results depend on file order. mockSelect.mockReset(); mockSelect.mockReturnValue([{ count: 0 }]); mockInsert.mockReturnValue([{ id: "inserted" }]); @@ -297,37 +281,6 @@ describe("Judge edge cases", () => { req: { headers: { get: () => null } }, }) as any; - const judgeCaller = () => appRouter.createCaller(ctxFor("judge_user")); - const adminCaller = () => appRouter.createCaller(ctxFor("admin_user")); - - /** - * Wires mockFindFirst for the judge portal. `queue` is consumed in call - * order: `isJudge` burns the first judgeQueue lookup whenever the input - * carries only a queueId (procedures.ts resolves hackathonId that way). - */ - const wireJudge = (opts: { - queue?: unknown[]; - project?: Record; - hackathon?: Record; - myAssignment?: Record; - judge?: Record | undefined; - /** The judge's existing vote on the project, if any. */ - vote?: Record; - }) => { - const nextQueue = seq(opts.queue ?? []); - mockFindFirst.mockImplementation((table: string) => { - if (table === "judges") - return "judge" in opts ? opts.judge : JUDGE_ROW; - if (table === "judgingProjects") - return opts.project ?? { id: PROJECT_A, hackathonId: HACK_A }; - if (table === "hackathons") return opts.hackathon ?? { id: HACK_A }; - if (table === "judgeQueue") return nextQueue(); - if (table === "judgeAssignments") return opts.myAssignment; - if (table === "judgeVotes") return opts.vote; - return undefined; - }); - }; - /** Every row a mutation inserted into `table`, flattened. */ const insertsInto = (table: unknown) => mockInsert.mock.calls @@ -337,553 +290,139 @@ describe("Judge edge cases", () => { return Array.isArray(values) ? values : values ? [values] : []; }); - /** A judgeable project as loadPool reads it. */ - const poolRow = (id: string, tableNumber: number) => ({ - id, - tableNumber, - tracks: null, - challenges: null, - isCreateX: false, - }); - - /** - * Feeds the select reads dispatchNext makes, in its order: the assignments - * and projects behind the pool, this judge's own visits, then (only when it - * has to pick a new table) every vote and every open visit. Which table it - * picks is covered against real Postgres in dispatch.db.test.ts; these only - * need it to hand something back. - */ - const wireDispatch = (opts: { - pool: unknown[]; - mine?: unknown[]; - votes?: unknown[]; - open?: unknown[]; - }) => { - mockSelect - .mockReturnValueOnce([]) - .mockReturnValueOnce(opts.pool) - .mockReturnValueOnce(opts.mine ?? []) - .mockReturnValueOnce(opts.votes ?? []) - .mockReturnValueOnce(opts.open ?? []); - }; - - /** Inside the scoring window: tapped in a minute ago. */ - const recent = () => new Date(Date.now() - 60_000); - /** Past the 4:00 cutoff and its grace. */ - const lapsed = () => new Date(Date.now() - 5 * 60_000); - - // ===================================================================== - describe("1. A judge may only act on their own queue", () => { - // BUG: portal.ts:405-419 loads the row with eq(judgeQueue.id, queueId) - // only. Any active judge of the hackathon can complete another judge's - // assignment. Left skipped: the product does not enforce ownership. - it("refuses to force-skip a queue item that belongs to another judge", async () => { - wireJudge({ - queue: [ - { id: QUEUE_A, hackathonId: HACK_A }, - { - id: QUEUE_A, - judgeId: OTHER_JUDGE_ID, - hackathonId: HACK_A, - projectId: PROJECT_A, - isCompleted: false, - project: { id: PROJECT_A, tracks: [] }, - }, - ], - }); - - await expect( - judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A }), - ).rejects.toThrow(/not found|forbidden|access/i); - expect(mockUpdate).not.toHaveBeenCalled(); - }); - - // BUG: portal.ts:350-367 — same missing ownership predicate, and the - // MAX(order) subquery is computed over the CALLER's rows, so the victim's - // ordering is rewritten from a foreign sequence. - it("refuses to skip a queue item that belongs to another judge", async () => { - wireJudge({ - queue: [ - { id: QUEUE_A, hackathonId: HACK_A }, - { - id: QUEUE_A, - judgeId: OTHER_JUDGE_ID, - hackathonId: HACK_A, - order: 1, - isCompleted: false, - }, - ], - }); - - await expect( - judgeCaller().judge.skipProject({ queueId: QUEUE_A }), - ).rejects.toThrow(/not found|forbidden|access/i); - expect(mockUpdate).not.toHaveBeenCalled(); - }); - }); - - // ===================================================================== - describe("2. completeAndNext queue/vote consistency", () => { - const vote = { queueId: QUEUE_A, projectId: PROJECT_A, ...scores }; - - // BUG: portal.ts:267-305 writes the vote for input.projectId and completes - // input.queueId with no cross-check, and isJudge resolves the hackathon - // from projectId first, so a foreign-hackathon queueId still authorises. - it("refuses to score one project while completing a queue slot for another", async () => { - wireJudge({ - project: { id: PROJECT_A, hackathonId: HACK_A }, - queue: [ - { - id: QUEUE_A, - judgeId: JUDGE_ID, - hackathonId: HACK_B, - projectId: PROJECT_B, - }, - ], - }); - - await expect(judgeCaller().judge.completeAndNext(vote)).rejects.toThrow( - /bad request|mismatch|does not/i, - ); - expect(mockInsert).not.toHaveBeenCalled(); - }); - - // A stale queueId is tolerated (the judge may be retrying), and resolving - // the judge's own slot instead must still lead on to the next table rather - // than reporting judging finished. - it("does not declare judging finished when the queue row simply no longer exists", async () => { - wireJudge({ - project: { id: PROJECT_B, hackathonId: HACK_A, name: "Still Waiting" }, - queue: [ - undefined, - // The slot this judge owns for the project being scored. The stale - // queueId resolves to nothing, but they are still at this table. - { - id: QUEUE_A, - judgeId: JUDGE_ID, - hackathonId: HACK_A, - projectId: PROJECT_A, - isCompleted: false, - startedAt: recent(), - arrivedAt: recent(), - }, - ], - }); - wireDispatch({ - pool: [poolRow(PROJECT_A, 1), poolRow(PROJECT_B, 2)], - mine: [ - { - id: QUEUE_A, - projectId: PROJECT_A, - isCompleted: true, - startedAt: recent(), - arrivedAt: recent(), - completedAt: new Date(), - }, - ], - }); - mockInsert.mockReturnValue([{ id: "queue_b" }]); - - const res = await judgeCaller().judge.completeAndNext(vote); - - expect(res).toMatchObject({ done: false, nextQueueId: "queue_b" }); - expect(insertsInto(judgeVotes)).toHaveLength(1); - }); - - it("returns the same next project when the same completion is submitted twice", async () => { - const slot = { - id: QUEUE_A, - judgeId: JUDGE_ID, - hackathonId: HACK_A, - projectId: PROJECT_A, - isCompleted: false, - startedAt: recent(), - arrivedAt: recent(), - }; - const pool = [poolRow(PROJECT_A, 1), poolRow(PROJECT_B, 2)]; - - wireJudge({ - project: { id: PROJECT_A, hackathonId: HACK_A }, - queue: [slot], - }); - wireDispatch({ - pool, - mine: [{ ...slot, isCompleted: true, completedAt: new Date() }], - }); - mockInsert.mockReturnValue([{ id: "queue_b" }]); - const first = await judgeCaller().judge.completeAndNext(vote); - mockInsert.mockClear(); - - // Retry from a second tab: the slot is already completed and the vote is - // on record, and the visit the first call opened is still live, so - // dispatch hands that one back rather than opening another. - wireJudge({ - project: { id: PROJECT_A, hackathonId: HACK_A }, - queue: [{ ...slot, isCompleted: true }], - vote: { id: "vote_1" }, - }); - wireDispatch({ - pool, - mine: [ - { ...slot, isCompleted: true, completedAt: new Date() }, - { - id: "queue_b", - projectId: PROJECT_B, - isCompleted: false, - startedAt: new Date(), - arrivedAt: null, - completedAt: null, - }, - ], - }); - const second = await judgeCaller().judge.completeAndNext(vote); - - expect(first).toMatchObject({ done: false, nextQueueId: "queue_b" }); - expect(second).toMatchObject({ - done: false, - nextQueueId: "queue_b", - timedOut: false, - }); - // Nothing scored twice, no second visit opened. - expect(mockInsert).not.toHaveBeenCalled(); - }); - }); - - // ===================================================================== - describe("3. Judging window", () => { - // BUG: neither submitVote (portal.ts:190) nor completeAndNext - // (portal.ts:245) reads hackathons.judgingActive, so "close judging" is - // advisory only and the upsert can still overwrite scores afterwards. - it("rejects a vote once judging has been closed", async () => { - wireJudge({ - project: { id: PROJECT_A, hackathonId: HACK_A }, - hackathon: { id: HACK_A, judgingActive: false }, - }); - - await expect( - judgeCaller().judge.submitVote({ projectId: PROJECT_A, ...scores }), - ).rejects.toThrow(/clos/i); - }); - - // BUG: the handler never consults judgeQueue, so any judge can score any - // project in the hackathon, including ones routed away from them. - it("rejects a vote for a project that was never in the judge's queue", async () => { - wireJudge({ - project: { id: PROJECT_A, hackathonId: HACK_A }, - queue: [undefined], - }); - - await expect( - judgeCaller().judge.submitVote({ projectId: PROJECT_A, ...scores }), - ).rejects.toThrow(); - }); - }); - - // ===================================================================== - describe("4. skipProject response shape", () => { - const ownSlot = { - id: QUEUE_A, - judgeId: JUDGE_ID, - hackathonId: HACK_A, - projectId: PROJECT_A, - isCompleted: false, - startedAt: recent(), - arrivedAt: null, - }; - - it("returns the next project with its name and table number", async () => { - wireJudge({ - project: { id: PROJECT_B, name: "Next Project", tableNumber: 12 }, - queue: [{ id: QUEUE_A, hackathonId: HACK_A }, ownSlot], - }); - wireDispatch({ - pool: [poolRow(PROJECT_A, 1), poolRow(PROJECT_B, 12)], - mine: [{ ...ownSlot, isCompleted: true, completedAt: new Date() }], - }); - mockInsert.mockReturnValue([{ id: "queue_b" }]); - - const res = await judgeCaller().judge.skipProject({ queueId: QUEUE_A }); - - expect(res).toMatchObject({ done: false, queueId: "queue_b" }); - expect(res.project).toMatchObject({ name: "Next Project", tableNumber: 12 }); - }); - - // The pool never sends a judge back to a table they passed on, so skipping - // the last one they can be sent to ends their judging rather than serving - // the same table again. - it("reports done when the skipped table was the last one left", async () => { - wireJudge({ - queue: [{ id: QUEUE_A, hackathonId: HACK_A }, ownSlot], - }); - wireDispatch({ - pool: [poolRow(PROJECT_A, 1)], - mine: [{ ...ownSlot, isCompleted: true, completedAt: new Date() }], - }); - - const res = await judgeCaller().judge.skipProject({ queueId: QUEUE_A }); - - expect(res).toMatchObject({ done: true, project: null, queueId: null }); - // The slot is closed without a vote. - const closed = mockUpdate.mock.calls.some( - (call) => call[1]?.[0] === judgeQueue && call[2]?.[0]?.isCompleted === true, - ); - expect(closed).toBe(true); - expect(insertsInto(judgeVotes)).toHaveLength(0); - }); - }); + const judgeCaller = () => appRouter.createCaller(ctxFor("judge_user")); + const adminCaller = () => appRouter.createCaller(ctxFor("admin_user")); + const applicantCaller = () => + appRouter.createCaller(ctxFor("applicant_user")); // ===================================================================== - describe("7b. Approving a judge writes no queue", () => { - /** - * Approval used to build the judge's queue, because nothing else would. - * With the shared pool an active judge draws tables as they ask for them, - * so approval writes no judge_queue rows at all and only reports how many - * projects the judge can be sent to. - */ - const wireApproval = (opts: { - assignment?: Record | undefined; - projects?: Record[]; - }) => { + describe("1. Approving a judge", () => { + const wireApproval = () => { mockFindFirst.mockImplementation((table: string) => { if (table === "admins") return ADMIN_ROW; if (table === "judgeAssignments") - return "assignment" in opts - ? opts.assignment - : { id: "asn_1", judgeId: JUDGE_ID, hackathonId: HACK_A, track: null }; + return { id: "asn_1", judgeId: JUDGE_ID, hackathonId: HACK_A }; return undefined; }); mockUpdate.mockReturnValue([ { userId: "judge_user", hackathonId: HACK_A }, ]); - // The judge-row lock, then the two reads behind loadPool: every - // assignment in the hackathon, and its judgeable projects. - mockSelect - .mockReturnValueOnce([{ id: JUDGE_ID }]) - .mockReturnValueOnce([]) - .mockReturnValueOnce(opts.projects ?? []); }; - it("approves a judge without writing any judge_queue rows", async () => { - wireApproval({ - projects: [poolRow(PROJECT_A, 1), poolRow(PROJECT_B, 2)], - }); - - const res = await adminCaller().judge.setActive({ - judgeId: JUDGE_ID, - isActive: true, - }); - - expect(res).toMatchObject({ success: true, isActive: true }); - // The pool the judge will draw from, not a queue written for them. - expect(res.queuedProjects).toBe(2); - expect(insertsInto(judgeQueue)).toHaveLength(0); - // Nor is any existing visit marked done or rewritten: a judge suspended - // mid-event and reinstated keeps what they have scored. - const touchedQueue = mockUpdate.mock.calls.some( - (call) => call[1]?.[0] === judgeQueue, - ); - expect(touchedQueue).toBe(false); - }); - - /** - * Reported by review on #320. "Is the queue empty? then build it" is a read - * followed by a write, and judge_queue has no unique on (judge, project) — - * so two approvals arriving together both saw an empty queue and both - * built one, giving the judge every project twice. - */ - it("locks the judge row before deciding whether to build", async () => { - wireApproval({ projects: [poolRow(PROJECT_A, 1)] }); - - await adminCaller().judge.setActive({ - judgeId: JUDGE_ID, - isActive: true, - }); - - // The lock is what serialises two concurrent approvals; without it the - // second reads the queue the first has not committed yet. - const lockedForUpdate = mockSelect.mock.calls.some((call) => - (call[0] as [string, unknown[]][]).some(([method]) => method === "for"), - ); - expect(lockedForUpdate).toBe(true); - }); - - it("builds nothing for a judge with no assignment, and nothing on suspend", async () => { - wireApproval({ assignment: undefined }); + // Approval only flips the judge row and mirrors it onto the application. + // There is no queue to build: judges are seated in rooms on the day. + it("writes no rows beyond the judge and its application", async () => { + wireApproval(); const approved = await adminCaller().judge.setActive({ judgeId: JUDGE_ID, isActive: true, }); - expect(approved.queuedProjects).toBeNull(); + expect(approved).toEqual({ success: true, isActive: true }); const suspended = await adminCaller().judge.setActive({ judgeId: JUDGE_ID, isActive: false, }); - expect(suspended.queuedProjects).toBeNull(); - expect(mockInsert).not.toHaveBeenCalled(); - }); - }); - - // ===================================================================== - describe("7d. Correcting a judge's track", () => { - /** - * Reported by review on #320. - * - * Writing the new track while leaving the old queue alone makes the - * assignment and the queue disagree: the judge carries on scoring the pool - * they were routed to before, and nothing on any screen says so. Refuse - * first, name the cost, and only then offer the override — the same - * pattern assignJudgesToProjects uses. - */ - const wireTrackChange = (completedCount: number) => { - mockFindFirst.mockImplementation((table: string) => { - if (table === "admins") return ADMIN_ROW; - if (table === "judgeAssignments") - return { id: "asn_1", judgeId: JUDGE_ID, hackathonId: HACK_A }; - if (table === "hackathons") - return { tracks: ["Sports", "Health"], challenges: null }; - return undefined; - }); - mockFindMany.mockImplementation(() => []); - mockSelect.mockReturnValue([{ count: completedCount }]); - }; - - it("refuses a track change once the judge has scored", async () => { - wireTrackChange(4); - - await expect( - adminCaller().judge.updateAssignmentTrack({ - judgeId: JUDGE_ID, - hackathonId: HACK_A, - track: "Health", - }), - ).rejects.toMatchObject({ code: "CONFLICT" }); + expect(suspended).toEqual({ success: true, isActive: false }); - // Nothing may be written by a refused change — least of all the track, - // which would leave the assignment and the queue disagreeing. - expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockInsert).not.toHaveBeenCalled(); expect(mockDelete).not.toHaveBeenCalled(); + const updatedTables = new Set(mockUpdate.mock.calls.map((c) => c[1]?.[0])); + expect(updatedTables).toEqual(new Set([judges, judgeAssignments])); }); - it("names what the change would cost", async () => { - wireTrackChange(4); + it("mirrors approval onto the application's status", async () => { + wireApproval(); - await expect( - adminCaller().judge.updateAssignmentTrack({ - judgeId: JUDGE_ID, - hackathonId: HACK_A, - track: "Health", - }), - ).rejects.toThrow(/already scored 4 project/i); - }); - - // Completed slots survive the override: skipProject marks one done without - // writing a vote, so they cannot be rebuilt from the votes table. - it("keeps completed slots when forced", async () => { - wireTrackChange(4); - - const res = await adminCaller().judge.updateAssignmentTrack({ - judgeId: JUDGE_ID, - hackathonId: HACK_A, - track: "Health", - force: true, - }); + await adminCaller().judge.setActive({ judgeId: JUDGE_ID, isActive: true }); - expect(res).toMatchObject({ queueRebuilt: true, keptCompleted: 4 }); - // The delete that precedes a rebuild must be scoped to the unfinished - // part of the queue. - expect(mockDelete).toHaveBeenCalled(); + const mirrored = mockUpdate.mock.calls.find( + (c) => c[1]?.[0] === judgeAssignments, + ); + expect(mirrored?.[2]?.[0]).toEqual({ status: "approved" }); }); - it("changes the track freely when nothing has been scored", async () => { - wireTrackChange(0); - - const res = await adminCaller().judge.updateAssignmentTrack({ - judgeId: JUDGE_ID, - hackathonId: HACK_A, - track: "Health", - }); + it("is NOT_FOUND for a judge that does not exist", async () => { + wireApproval(); + mockUpdate.mockReturnValue([]); - expect(res).toMatchObject({ track: "Health", queueRebuilt: true }); + await expect( + adminCaller().judge.setActive({ judgeId: JUDGE_ID, isActive: true }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); }); }); // ===================================================================== - describe("7c. A judge's track must exist on the edition", () => { + describe("2. A judge's preferred track must exist on the edition", () => { /** - * Free text went straight into the routing column. Any string not on the - * edition classified the judge as sponsor/special, filtered their pool to - * zero, and no screen could correct it. + * Free text used to land straight in the track column, where it matched + * nothing on the edition. register keeps the edition's own labels only. */ - const wireTrack = (hackathon: Record) => { + const wireTrack = (labels: Record) => { mockFindFirst.mockImplementation((table: string) => { - if (table === "admins") return ADMIN_ROW; - if (table === "judges") return { hackathonId: HACK_A }; - if (table === "hackathons") return hackathon; - if (table === "judgeAssignments") return undefined; + if (table === "hackathons") + return { isPublic: true, status: "open", ...labels }; return undefined; }); - mockFindMany.mockImplementation(() => []); }; + const application = (preferredTrack: string) => ({ + hackathonId: HACK_A, + name: "Ada Lovelace", + email: "ada@example.com", + preferredTrack, + }); + + const applicationRow = () => + insertsInto(judgeAssignments).find((row) => "judgeId" in row); + it("refuses a track the hackathon does not have", async () => { wireTrack({ tracks: ["Sports"], challenges: ["AWS"] }); await expect( - adminCaller().judge.assignToHackathon({ - judgeId: JUDGE_ID, - hackathonId: HACK_A, - track: "Web3", - }), + applicantCaller().judge.register(application("Web3")), ).rejects.toThrow(/not a track or challenge/i); - expect(mockInsert).not.toHaveBeenCalled(); + expect(insertsInto(judgeAssignments)).toHaveLength(0); }); - // The routing comparison is exact, so a differently-cased match has to be - // stored in the edition's own spelling or it matches nothing later. - it("normalises the casing to the edition's own spelling", async () => { + it("stores the edition's own spelling of a differently cased track", async () => { wireTrack({ tracks: ["Sports"], challenges: null }); - await adminCaller().judge.assignToHackathon({ - judgeId: JUDGE_ID, - hackathonId: HACK_A, - track: "sports", - }); + await applicantCaller().judge.register(application("sports")); - const assignmentRow = mockInsert.mock.calls - .map((c) => c[2]?.[0]) - .find((row) => row && !Array.isArray(row) && "judgeId" in row); - expect(assignmentRow.track).toBe("Sports"); + expect(applicationRow()?.track).toBe("Sports"); }); it("accepts a challenge label and createX", async () => { wireTrack({ tracks: ["Sports"], challenges: ["AWS"] }); + await applicantCaller().judge.register(application("AWS")); + expect(applicationRow()?.track).toBe("AWS"); - await expect( - adminCaller().judge.assignToHackathon({ - judgeId: JUDGE_ID, - hackathonId: HACK_A, - track: "AWS", - }), - ).resolves.toBeDefined(); + mockInsert.mockClear(); + await applicantCaller().judge.register(application("createX")); + expect(applicationRow()?.track).toBe("createX"); + }); + + it("refuses a second application to the same edition", async () => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "hackathons") return { isPublic: true, status: "open" }; + if (table === "judges") return { id: JUDGE_ID }; + if (table === "judgeAssignments") return { id: "asn_1" }; + return undefined; + }); await expect( - adminCaller().judge.assignToHackathon({ - judgeId: JUDGE_ID, - hackathonId: HACK_A, - track: "createX", - }), - ).resolves.toBeDefined(); + applicantCaller().judge.register(application("")), + ).rejects.toMatchObject({ code: "CONFLICT" }); }); }); // ===================================================================== - describe("8. Removing a judge", () => { - // BUG: admin.ts:460-467 hard-deletes the judges row. judgeVotes.judgeId - // cascades, so every score that judge already submitted disappears and the - // normalization in getRankings shifts for every project. + describe("3. Removing a judge", () => { + // judge_vote.judge_id cascades, so deleting a judge who scored in a past + // edition would erase those scores. it("refuses to delete a judge who has already submitted votes", async () => { mockFindFirst.mockImplementation((table: string) => { if (table === "admins") return ADMIN_ROW; @@ -891,9 +430,6 @@ describe("Judge edge cases", () => { if (table === "judgeVotes") return { id: "vote_1", judgeId: JUDGE_ID }; return undefined; }); - mockFindMany.mockImplementation((table: string) => - table === "judgeVotes" ? [{ id: "vote_1", judgeId: JUDGE_ID }] : [], - ); await expect( adminCaller().judge.remove({ judgeId: JUDGE_ID }), @@ -902,9 +438,8 @@ describe("Judge edge cases", () => { }); /** - * Approving a judge is part of running the event, so any admin may; it - * was super-admin only while every admin saw the button. Deleting the - * judge, votes and all, stays super-admin. + * Approving a judge is part of running the event, so any admin may. + * Deleting the judge stays super-admin. */ it("lets a plain admin approve a judge but not remove one", async () => { mockFindFirst.mockImplementation((table: string) => @@ -922,850 +457,244 @@ describe("Judge edge cases", () => { expect(mockDelete).not.toHaveBeenCalled(); }); - // isJudge caches the judges row for 60s under - // `judge:::role` (procedures.ts:97-110). Nothing in - // admin.ts clears that key explicitly — the protection comes from - // cacheInvalidationMiddleware (trpc.ts:181-198), which has no entry for - // `judge.remove` and so falls back to evicting the whole `judge:*` - // namespace. This test pins that fallback: without it, a revoked judge - // would keep force-skip rights for a full minute on every warm instance. - it("locks a removed judge out on their very next request", async () => { - wireJudge({ - project: { id: PROJECT_A, hackathonId: HACK_A }, - // The judge legitimately holds this project, so the vote turns only on - // whether they are still a judge. - queue: [ - { - id: QUEUE_A, - judgeId: JUDGE_ID, - projectId: PROJECT_A, - isCompleted: false, - startedAt: recent(), - arrivedAt: recent(), - }, - ], - }); - await judgeCaller().judge.submitVote({ projectId: PROJECT_A, ...scores }); - + // judge.isJudge caches its answer for 60s under `judge::`. + // Removal has to clear it, or a removed judge keeps the Judge tab and the + // judge desk for a minute on every warm instance. + it("turns isJudge off on the removed judge's very next request", async () => { mockFindFirst.mockImplementation((table: string) => - table === "admins" ? ADMIN_ROW : undefined, + table === "judges" ? JUDGE_ROW : undefined, ); - await adminCaller().judge.remove({ judgeId: JUDGE_ID }); - - // The judges row is gone now. - mockFindFirst.mockImplementation((table: string) => - table === "judgingProjects" - ? { id: PROJECT_A, hackathonId: HACK_A } - : undefined, - ); - await expect( - judgeCaller().judge.submitVote({ projectId: PROJECT_A, ...scores }), - ).rejects.toThrow(/Judge access required/); - }); - }); + judgeCaller().judge.isJudge({ hackathonId: HACK_A }), + ).resolves.toMatchObject({ isJudge: true, judgeId: JUDGE_ID }); - // ===================================================================== - describe("9. Promoting submissions into judging", () => { - const asAdmin = () => mockFindFirst.mockImplementation((table: string) => table === "admins" ? ADMIN_ROW : undefined, ); + mockDelete.mockReturnValue([{ userId: "judge_user" }]); + await adminCaller().judge.remove({ judgeId: JUDGE_ID }); - const submission = (id: string, extra: Record = {}) => ({ - id, - hackathonId: HACK_A, - name: `Project ${id}`, - description: "d", - tracks: ["AI"], - challenges: null, - isCreateX: false, - teamMembers: ["Ada", "Grace"], - githubUrl: null, - demoUrl: null, - team: null, - ...extra, - }); - - it("writes nothing when no project has been submitted", async () => { - asAdmin(); - mockFindMany.mockReturnValue([]); - - const res = await adminCaller().judge.promoteSubmissions({ - hackathonId: HACK_A, - }); - - expect(res).toMatchObject({ created: 0, total: 0 }); - expect(mockInsert).not.toHaveBeenCalled(); - }); - - // The whole point of the source link: an organiser presses this again as - // late submissions land, and must not get a second copy of every project - // with a fresh table number. - it("skips submissions that are already judgeable", async () => { - asAdmin(); - mockFindMany.mockImplementation((table: string) => { - if (table === "hackathonProjects") - return [submission("s1"), submission("s2")]; - if (table === "judgingProjects") - return [{ id: "jp1", sourceProjectId: "s1", tableNumber: 7 }]; - return []; - }); - mockSelect.mockResolvedValue([{ count: 0 }]); - - const res = await adminCaller().judge.promoteSubmissions({ - hackathonId: HACK_A, - }); - - expect(res).toMatchObject({ created: 1, alreadyPresent: 1, total: 2 }); - - const rows = mockInsert.mock.calls[0]?.[2]?.[0]; - expect(rows).toHaveLength(1); - expect(rows[0].sourceProjectId).toBe("s2"); - // Numbering continues past the highest table already handed out. - expect(rows[0].tableNumber).toBe(8); - }); - - // hackathon_project.teamMembers is text[]; judging_project.teamMembers is - // a single text column. Assigning the array straight across puts - // "[object Object]" on a judge's screen. - it("flattens the team member array into the scalar column", async () => { - asAdmin(); - mockFindMany.mockImplementation((table: string) => - table === "hackathonProjects" ? [submission("s1")] : [], - ); - mockSelect.mockResolvedValue([{ count: 0 }]); - - await adminCaller().judge.promoteSubmissions({ hackathonId: HACK_A }); - - const rows = mockInsert.mock.calls[0]?.[2]?.[0]; - expect(rows[0].teamMembers).toBe("Ada, Grace"); - }); - - /** - * Judges draw tables from the shared pool, so a project promoted late is - * judgeable the moment its row exists. Promotion has no queues to append - * to and must not write any. - */ - it("writes no judge_queue rows and reports only the promotion counts", async () => { - asAdmin(); - mockFindMany.mockImplementation((table: string) => - table === "hackathonProjects" ? [submission("s1")] : [], - ); - - const res = await adminCaller().judge.promoteSubmissions({ - hackathonId: HACK_A, - }); - - expect(res).toEqual({ created: 1, alreadyPresent: 0, total: 1 }); - expect(insertsInto(judgeQueue)).toHaveLength(0); - }); - }); - - // ===================================================================== - describe("10. Rankings", () => { - const RANKING_PROJECTS = [ - { - id: "p_real", - name: "Real", - hackathonId: HACK_A, - tableNumber: 1, - votes: [ - { judgeId: "j_a", score: 45, judge: { user: { name: "A" } } }, - { judgeId: "j_b", score: 47, judge: { user: { name: "B" } } }, - ], - }, - { - id: "p_weak", - name: "Weak", - hackathonId: HACK_A, - tableNumber: 2, - votes: [ - { judgeId: "j_a", score: 30, judge: { user: { name: "A" } } }, - { judgeId: "j_b", score: 28, judge: { user: { name: "B" } } }, - ], - }, - { - id: "p_hype", - name: "Hype", - hackathonId: HACK_A, - tableNumber: 3, - votes: [{ judgeId: "j_single", score: 50, judge: { user: { name: "S" } } }], - }, - ]; - - it("does not let a lone perfect score from a single-vote judge take first place", async () => { - mockFindFirst.mockImplementation((table: string) => - table === "admins" ? ADMIN_ROW : undefined, - ); - mockFindMany.mockImplementation((table: string) => - table === "judgingProjects" ? RANKING_PROJECTS : [], - ); - - const res = await adminCaller().judge.getRankings({ hackathonId: HACK_A }); - - expect(res.rankings[0]?.project.id).toBe("p_real"); - const hype = res.rankings.find((r) => r.project.id === "p_hype"); - // zNormalize (helpers.ts:27) replaces a single-vote judge's score with - // the global mean, so the 50 is erased entirely. - expect(hype?.confidenceLevel).toBe("LOW"); - expect(hype?.normalizedAvg).toBe(res.globalAvg); - expect(hype?.scoreShift).toBe(-10); - }); - - // rankings.ts:324 caches the leaderboard for 30s, which would be long - // enough for two organizers to read two different top-3 orderings while - // calling winners. The save is CACHE_INVALIDATION_MAP["judge.submitVote"] - // (trpc.ts:158), which evicts `hackathon:*:rankings` after every vote. - it("shows a vote submitted moments earlier on the next rankings read", async () => { - mockFindFirst.mockImplementation((table: string) => - table === "admins" ? ADMIN_ROW : undefined, - ); - mockFindMany.mockImplementation((table: string) => - table === "judgingProjects" - ? [{ ...RANKING_PROJECTS[0], votes: [] }] - : [], - ); - const before = await adminCaller().judge.getRankings({ hackathonId: HACK_A }); - expect(before.rankings[0]?.voteCount).toBe(0); - - wireJudge({ - project: { id: "p_real", hackathonId: HACK_A }, - queue: [ - { - id: QUEUE_A, - judgeId: JUDGE_ID, - projectId: PROJECT_A, - isCompleted: false, - startedAt: recent(), - arrivedAt: recent(), - }, - ], - }); - await judgeCaller().judge.submitVote({ projectId: PROJECT_A, ...scores }); - - mockFindFirst.mockImplementation((table: string) => - table === "admins" ? ADMIN_ROW : undefined, - ); - mockFindMany.mockImplementation((table: string) => - table === "judgingProjects" ? [RANKING_PROJECTS[0]] : [], - ); - const after = await adminCaller().judge.getRankings({ hackathonId: HACK_A }); - - expect(after.rankings[0]?.voteCount).toBe(2); - }); - }); - - // ===================================================================== - describe("11. Judge analytics", () => { - // The judge an organizer needs mid-event is the one who has not started, so - // the list is driven by the queues as well as the votes. Iterating votes - // alone drops them entirely and they read as "not in this hackathon". - it("lists a judge who has a queue but has not started voting", async () => { - mockFindFirst.mockImplementation((table: string) => - table === "admins" ? ADMIN_ROW : undefined, - ); - mockSelect - .mockReturnValueOnce([ - { - judgeId: "j_busy", - projectId: PROJECT_A, - score: 40, - durationSeconds: 600, - judgeName: "Busy", - judgeUserId: "u_busy", - }, - ]) - .mockReturnValueOnce([ - { judgeId: "j_busy", total: 5, completed: 1 }, - { judgeId: "j_idle", total: 6, completed: 0 }, - ]); - - const res = (await adminCaller().judge.getJudgeAnalytics({ - hackathonId: HACK_A, - })) as { analytics: { judgeId: string; votesSubmitted: number }[] }; - - const idle = res.analytics.find((a) => a.judgeId === "j_idle"); - expect(idle).toBeDefined(); - expect(idle?.votesSubmitted).toBe(0); - }); - }); - - // ===================================================================== - describe("12. getMyAssignments hackathon resolution", () => { - // BUG: getMyAssignments takes no input, so isJudge falls back to the - // newest hackathon by startDate (procedures.ts:82-88). A judge for an - // older hackathon — or every current judge the moment a future draft row - // is created — is locked out of the procedure that lists their hackathons. - it("lets a judge list their assignments when a newer hackathon exists", async () => { - // The user judges HACK_A. HACK_B is newer by startDate, so the middleware - // resolves the context to HACK_B and finds no judges row there. - mockFindFirst.mockImplementation((table: string) => { - if (table === "hackathons") return { id: HACK_B }; - if (table === "judges") return undefined; - return undefined; - }); - mockFindMany.mockImplementation((table: string) => - table === "judgeAssignments" - ? [{ judgeId: JUDGE_ID, hackathonId: HACK_A }] - : [], - ); - - await expect(judgeCaller().judge.getMyAssignments()).resolves.toBeDefined(); - }); - - it("reports no judging context when the platform has no hackathons at all", async () => { + // The judges row is gone now. mockFindFirst.mockImplementation(() => undefined); - - await expect(judgeCaller().judge.getMyAssignments()).rejects.toThrow( - /No hackathon context found for judging/, - ); + await expect( + judgeCaller().judge.isJudge({ hackathonId: HACK_A }), + ).resolves.toMatchObject({ isJudge: false, judgeId: null }); }); }); // ===================================================================== - describe("12. Freezing results", () => { - const wireResults = (opts: { - judgingActive?: boolean; - published?: Record; - }) => { + /** + * An organiser pulling a project takes its judging entry with it, or judges + * keep seeing it and it can still be published as a placing. + */ + describe("4. Organiser withdrawal", () => { + const wireWithdraw = (opts: { + status?: string; + entry?: Record; + }) => mockFindFirst.mockImplementation((table: string) => { if (table === "admins") return ADMIN_ROW; - if (table === "hackathons") - return { id: HACK_A, judgingActive: opts.judgingActive ?? false }; - if (table === "hackathonResults") return opts.published; + if (table === "hackathonProjects") + return { + id: PROJECT_A, + hackathonId: HACK_A, + status: opts.status ?? "submitted", + }; + if (table === "judgingEntries") return opts.entry; return undefined; }); - mockFindMany.mockReturnValue([]); - }; - - /** - * The z-score normalisation runs over the whole vote set, so one late vote - * shifts every project's score. A snapshot taken while judging is live is - * already stale by the time anyone reads it. - */ - it("refuses to freeze results while judging is still live", async () => { - wireResults({ judgingActive: true }); - - await expect( - adminCaller().judge.computeResults({ hackathonId: HACK_A }), - ).rejects.toThrow(/still live/i); - expect(mockInsert).not.toHaveBeenCalled(); - }); + const entryUpdates = () => + mockUpdate.mock.calls.filter((call) => call[1]?.[0] === judgingEntries); - it("computes once judging has closed", async () => { - wireResults({ judgingActive: false }); + it("soft-deletes the project's judging entry", async () => { + wireWithdraw({}); - const res = await adminCaller().judge.computeResults({ - hackathonId: HACK_A, + await adminCaller().hackathon.adminWithdrawProject({ + projectId: PROJECT_A, }); - // No projects wired, so nothing to place — but it got past the guard. - expect(res).toMatchObject({ computed: 0 }); - }); - - // Recomputing under a published ordering would change placings people - // have already been told about, with no record that it happened. - it("refuses to recompute over published results", async () => { - wireResults({ judgingActive: false, published: { id: "r1" } }); - - await expect( - adminCaller().judge.computeResults({ hackathonId: HACK_A }), - ).rejects.toThrow(/already published/i); - }); - - /** - * The unique index is (hackathonId, projectId, track) and Postgres treats - * NULLs as distinct — so a null track makes onConflictDoUpdate infer an - * arbiter that can never match, and every recompute appends a second full - * ordering instead of upserting. Nothing in the product deletes result - * rows, so that is only fixable in psql. - */ - it("writes a non-null track so the upsert arbiter can match", async () => { - wireResults({ judgingActive: false }); - mockFindMany.mockImplementation((table: string) => - table === "judgingProjects" - ? [ - { - id: PROJECT_A, - hackathonId: HACK_A, - sourceProjectId: null, - name: "P", - tableNumber: 1, - zone: null, - category: null, - teamMembers: null, - tracks: null, - challenges: null, - isCreateX: false, - // Needs a real vote: a project nobody scored is deliberately - // left out of the snapshot rather than published at rank N. - votes: [ - { - judgeId: JUDGE_ID, - score: 42, - scoreCreativity: 8, - scoreImpact: 9, - scoreScope: 8, - scoreClarity: 9, - scoreSoundness: 8, - comment: null, - durationSeconds: 300, - judge: { user: { name: "Grace" } }, - }, - ], - }, - ] - : [], - ); - - await adminCaller().judge.computeResults({ hackathonId: HACK_A }); - - const rows = mockInsert.mock.calls.at(-1)?.[2]?.[0]; - expect(rows[0].track).toBe("overall"); - expect(rows[0].track).not.toBeNull(); - }); - - // Unjudged projects are reported, not published: "47th place, zero votes" - // is a worse thing to tell a team than nothing at all. - it("leaves projects nobody scored out of the snapshot", async () => { - wireResults({ judgingActive: false }); - mockFindMany.mockImplementation((table: string) => - table === "judgingProjects" - ? [ - { - id: PROJECT_A, - hackathonId: HACK_A, - sourceProjectId: null, - name: "Unjudged", - tableNumber: 1, - zone: null, - category: null, - teamMembers: null, - tracks: null, - challenges: null, - isCreateX: false, - votes: [], - }, - ] - : [], - ); - - const res = await adminCaller().judge.computeResults({ - hackathonId: HACK_A, + const updates = entryUpdates(); + expect(updates).toHaveLength(1); + expect(updates[0]?.[2]?.[0]).toMatchObject({ + deletedAt: expect.any(Date), + deletedById: "admin_user", }); - - expect(res).toMatchObject({ computed: 0, unjudged: 1 }); - expect(mockInsert).not.toHaveBeenCalled(); - }); - - // An upsert alone keeps the placing of a project withdrawn since the last - // compute, and publishResults would announce it with the rest. - it("clears the previous snapshot before writing the new one", async () => { - wireResults({ judgingActive: false }); - - await adminCaller().judge.computeResults({ hackathonId: HACK_A }); - - expect(mockDelete).toHaveBeenCalledTimes(1); - expect(mockInsert).not.toHaveBeenCalled(); - }); - - it("refuses to publish when nothing has been computed", async () => { - wireResults({ judgingActive: false }); - mockUpdate.mockReturnValue([]); - - await expect( - adminCaller().judge.publishResults({ hackathonId: HACK_A }), - ).rejects.toThrow(/compute the results first/i); + // Soft delete: nothing is removed, so evaluations keep their entry. + expect( + mockDelete.mock.calls.filter((call) => call[1]?.[0] === judgingEntries), + ).toHaveLength(0); }); - }); - // ===================================================================== - describe("13. Scan-to-start", () => { - const QR = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - - const wireScan = (opts: { - project?: Record; - slot?: Record; - }) => { - mockFindFirst.mockImplementation((table: string) => { - if (table === "judges") return JUDGE_ROW; - if (table === "hackathons") return { id: HACK_A, judgingActive: true }; - if (table === "judgingProjects") return opts.project; - if (table === "judgeQueue") return opts.slot; - return undefined; - }); - }; - - const project = { id: PROJECT_A, tableNumber: 7, withdrawnAt: null }; - - /** - * Scanning is what starts the clock, so it has to prove the judge is at a - * table that is actually theirs. Without this a judge could scan any card - * in the room and score a project they were never routed to. - */ - it("refuses a table that is not in this judge's queue", async () => { - wireScan({ project, slot: undefined }); + it("asks for confirmation when the project is in judging", async () => { + wireWithdraw({ entry: { id: "entry_1" } }); await expect( - judgeCaller().judge.startByQrCode({ qrCode: QR }), - ).rejects.toThrow(/not the table you were sent to/i); - + adminCaller().hackathon.adminWithdrawProject({ projectId: PROJECT_A }), + ).rejects.toMatchObject({ code: "CONFLICT" }); expect(mockUpdate).not.toHaveBeenCalled(); - }); - it("refuses a withdrawn project", async () => { - wireScan({ - project: { ...project, withdrawnAt: new Date() }, - slot: { id: QUEUE_A, judgeId: JUDGE_ID }, + await adminCaller().hackathon.adminWithdrawProject({ + projectId: PROJECT_A, + force: true, }); - - await expect( - judgeCaller().judge.startByQrCode({ qrCode: QR }), - ).rejects.toThrow(/does not match a project/i); + expect(entryUpdates()).toHaveLength(1); }); - it("stamps arrival on first scan", async () => { - wireScan({ - project, - slot: { id: QUEUE_A, judgeId: JUDGE_ID, arrivedAt: null, startedAt: null }, - }); + it("clears the edition's cached public results", async () => { + wireWithdraw({}); + cache.set(`hackathon:${HACK_A}:results`, [{ id: "stale" }], 60); - const res = await judgeCaller().judge.startByQrCode({ qrCode: QR }); + await adminCaller().hackathon.adminWithdrawProject({ + projectId: PROJECT_A, + }); - expect(res).toMatchObject({ queueId: QUEUE_A, alreadyStarted: false }); - const write = mockUpdate.mock.calls.at(-1)?.[2]?.[0]; - expect(write.arrivedAt).toBeInstanceOf(Date); + expect(cache.get(`hackathon:${HACK_A}:results`)).toBeNull(); }); - // Re-scanning out of uncertainty must not restart the clock, which would - // otherwise let a long visit be quietly reset to zero. - it("does not restart the clock on a second scan", async () => { - // Inside the window: past the cutoff a second scan is refused instead. - const arrivedAt = recent(); - wireScan({ - project, - slot: { id: QUEUE_A, judgeId: JUDGE_ID, arrivedAt, startedAt: arrivedAt }, - }); - - const res = await judgeCaller().judge.startByQrCode({ qrCode: QR }); + it("treats the judging status as in judging too", async () => { + wireWithdraw({ status: "judging" }); - expect(res.alreadyStarted).toBe(true); - expect(mockUpdate).not.toHaveBeenCalled(); + await expect( + adminCaller().hackathon.adminWithdrawProject({ projectId: PROJECT_A }), + ).rejects.toMatchObject({ code: "CONFLICT" }); }); }); - // ===================================================================== /** - * The floor view. Every column it reads was already stored; nothing put it - * in one place, so finding a stalled judge on the day meant walking over to - * look at them. + * The public results tab reads one list: frozen placings from the retired + * pool-dispatch judging (past editions) and placements from room judging. */ - describe("10. Live judge progress", () => { - const MIN = 60 * 1000; - - const judgeRow = (id: string, name: string) => ({ - id, - name, - email: `${name.toLowerCase()}@example.com`, - isActive: true, + describe("5. Public results", () => { + const legacyRow = { + id: "result_1", + track: "overall", + placement: 2, + weightedScore: "41.50", + project: { id: "jp_1", name: "Old name", teamMembers: "Ada, Grace" }, + sourceProject: { + id: "hp_1", + name: "Tide", + githubUrl: "https://github.com/ada/tide", + demoUrl: null, + team: { id: "team_1", name: "Team Tide" }, + }, + }; + const placementRow = (over: Record) => ({ + id: "placement_1", + scope: "overall", + scopeLabel: "Overall", + placement: 1, + score: "4.25", + entry: { + id: "entry_1", + title: "Beacon", + submissionUrl: "https://devpost.com/software/beacon", + demoLinks: ["https://beacon.example"], + sourceProject: null, + }, + ...over, }); - /** - * liveProgress reads the judges and the projects through the query API, - * and the visits (joined to their projects) then the votes through - * select, in that order. - */ - const wireFloor = (opts: { - judges: unknown[]; - visits?: unknown[]; - votes?: unknown[]; - projects?: unknown[]; - }) => { + const wireResults = () => { mockFindFirst.mockImplementation((table: string) => - table === "admins" ? ADMIN_ROW : undefined, + table === "hackathons" ? { id: HACK_A, status: "completed" } : undefined, ); mockFindMany.mockImplementation((table: string) => { - if (table === "judges") return opts.judges; - if (table === "judgingProjects") return opts.projects ?? []; + if (table === "hackathonResults") return [legacyRow]; + if (table === "judgingPlacements") + return [ + placementRow({ + id: "placement_2", + scope: "challenge_1", + scopeLabel: "Best Use of MongoDB", + score: null, + }), + placementRow({}), + ]; return []; }); - mockSelect.mockReset(); - mockSelect - .mockReturnValueOnce(opts.visits ?? []) - .mockReturnValueOnce(opts.votes ?? []) - .mockReturnValue([]); }; - const visit = (over: Record = {}) => ({ - judgeId: JUDGE_ID, - projectId: PROJECT_A, - isCompleted: false, - startedAt: null, - arrivedAt: null, - tableNumber: 7, - projectName: "Flood Mapper", - ...over, - }); - - it("reports a judge who has not been sent to a table yet", async () => { - wireFloor({ - judges: [judgeRow(JUDGE_ID, "Ada")], - projects: [{ id: PROJECT_A }], - }); - - const res = await adminCaller().judge.liveProgress({ - hackathonId: HACK_A, - }); - - expect(res.judges[0]).toMatchObject({ - judgeId: JUDGE_ID, - status: "not_started", - scored: 0, - voided: 0, - idleMinutes: null, - current: null, - }); - expect(res.totals).toMatchObject({ judges: 1, scored: 0, voided: 0 }); - }); - - it("shows which table a judge is standing at, and for how long", async () => { - wireFloor({ - judges: [judgeRow(JUDGE_ID, "Ada")], - visits: [ - visit({ - startedAt: new Date(Date.now() - 3 * MIN), - arrivedAt: new Date(Date.now() - 2 * MIN), - }), - ], - projects: [{ id: PROJECT_A }], - }); - - const res = await adminCaller().judge.liveProgress({ - hackathonId: HACK_A, - }); - - expect(res.judges[0]).toMatchObject({ status: "judging", idleMinutes: 0 }); - // Timed from the tap, not from hand-out: the walk is not judging. - expect(res.judges[0]!.current).toMatchObject({ - tableNumber: 7, - projectName: "Flood Mapper", - phase: "judging", - }); - expect(res.judges[0]!.current!.seconds).toBeGreaterThanOrEqual(120); - expect(res.judges[0]!.current!.seconds).toBeLessThan(130); - }); - - it("counts idle minutes from the last vote", async () => { - wireFloor({ - judges: [judgeRow(JUDGE_ID, "Ada")], - visits: [ - visit({ - isCompleted: true, - startedAt: new Date(Date.now() - 30 * MIN), - arrivedAt: new Date(Date.now() - 28 * MIN), - }), - ], - votes: [ - { - judgeId: JUDGE_ID, - projectId: PROJECT_A, - votedAt: new Date(Date.now() - 25 * MIN), - durationSeconds: 180, - }, - ], - projects: [{ id: PROJECT_A }], - }); - - const res = await adminCaller().judge.liveProgress({ - hackathonId: HACK_A, - }); - - expect(res.judges[0]).toMatchObject({ - status: "between", - scored: 1, - voided: 0, - idleMinutes: 25, - }); - }); - - it("counts how many projects nobody has seen, and the fewest looks any has had", async () => { - wireFloor({ - judges: [judgeRow(JUDGE_ID, "Ada"), judgeRow(OTHER_JUDGE_ID, "Grace")], - votes: [ - { judgeId: JUDGE_ID, projectId: PROJECT_A, votedAt: new Date(), durationSeconds: 200 }, - { judgeId: OTHER_JUDGE_ID, projectId: PROJECT_A, votedAt: new Date(), durationSeconds: 200 }, - { judgeId: JUDGE_ID, projectId: PROJECT_B, votedAt: new Date(), durationSeconds: 200 }, - ], - projects: [{ id: PROJECT_A }, { id: PROJECT_B }, { id: "project_c" }], - }); - - const res = await adminCaller().judge.liveProgress({ - hackathonId: HACK_A, - }); - - expect(res.coverage).toMatchObject({ - projects: 3, - unseen: 1, - minLooks: 0, - atTarget: 1, - }); - expect(res.totals.scored).toBe(3); - }); - - it("puts the judge who has not started above the one who is judging", async () => { - wireFloor({ - judges: [judgeRow(JUDGE_ID, "Ada"), judgeRow(OTHER_JUDGE_ID, "Grace")], - visits: [ - visit({ - startedAt: new Date(Date.now() - 2 * MIN), - arrivedAt: new Date(Date.now() - 1 * MIN), - }), - ], - projects: [{ id: PROJECT_A }], - }); + it("normalises both kinds of placing into one shape", async () => { + wireResults(); - const res = await adminCaller().judge.liveProgress({ + const results = await judgeCaller().hackathon.getResults({ hackathonId: HACK_A, }); - expect(res.judges.map((j) => [j.judgeId, j.status])).toEqual([ - [OTHER_JUDGE_ID, "not_started"], - [JUDGE_ID, "judging"], + expect(results).toEqual([ + { + id: "placement_1", + scope: "overall", + scopeLabel: "Overall", + placement: 1, + projectName: "Beacon", + teamName: null, + githubUrl: null, + demoUrl: "https://beacon.example", + submissionUrl: "https://devpost.com/software/beacon", + score: 4.25, + }, + { + id: "result_1", + scope: "overall", + scopeLabel: "Overall", + placement: 2, + projectName: "Tide", + teamName: "Team Tide", + githubUrl: "https://github.com/ada/tide", + demoUrl: null, + submissionUrl: null, + score: 41.5, + }, + { + id: "placement_2", + scope: "challenge_1", + scopeLabel: "Best Use of MongoDB", + placement: 1, + projectName: "Beacon", + teamName: null, + githubUrl: null, + demoUrl: "https://beacon.example", + submissionUrl: "https://devpost.com/software/beacon", + score: null, + }, ]); }); - it("is refused to somebody who is not staff", async () => { - mockFindFirst.mockImplementation(() => undefined); - - await expect( - appRouter - .createCaller(ctxFor("random_user")) - .judge.liveProgress({ hackathonId: HACK_A }), - ).rejects.toMatchObject({ code: "FORBIDDEN" }); - }); - }); - - // ===================================================================== - /** - * An organiser pulling a project. Marking the judging entry withdrawn was - * not enough: every queue read kept routing judges to the pulled table. - */ - describe("14. Organiser withdrawal", () => { - const wireWithdraw = () => - mockFindFirst.mockImplementation((table: string) => { - if (table === "admins") return ADMIN_ROW; - if (table === "hackathonProjects") - return { id: PROJECT_A, hackathonId: HACK_A, status: "submitted" }; - return undefined; - }); - - it("drops the project's unscored queue slots", async () => { - wireWithdraw(); - mockUpdate.mockReturnValue([{ id: "judging_row" }]); - - await adminCaller().hackathon.adminWithdrawProject({ - projectId: PROJECT_A, - }); - - // Exactly one delete, on judge_queue. The audit write behind the - // withdrawal may also prune audit_logs, which is not this. - const queueDeletes = mockDelete.mock.calls.filter( - (call) => call[1]?.[0] === judgeQueue, + it("drops a placing whose project an organiser pulled", async () => { + wireResults(); + mockFindMany.mockImplementation((table: string) => + table === "judgingPlacements" + ? [ + placementRow({ + entry: { + id: "entry_1", + title: "Beacon", + submissionUrl: null, + demoLinks: [], + deletedAt: new Date(), + sourceProject: null, + }, + }), + ] + : [], ); - expect(queueDeletes).toHaveLength(1); - }); - - it("touches no queue when the project was never promoted", async () => { - wireWithdraw(); - mockUpdate.mockReturnValue([]); - - await adminCaller().hackathon.adminWithdrawProject({ - projectId: PROJECT_A, - }); - - expect( - mockDelete.mock.calls.filter((call) => call[1]?.[0] === judgeQueue), - ).toHaveLength(0); - }); - }); - - // ===================================================================== - /** - * The 4:00 cutoff, counted from the tap or scan. A score that arrives after - * it (plus a few seconds' grace) would be a look nobody can vouch for, so - * the table goes back into the pool instead. - */ - describe("15. The scoring cutoff", () => { - const slotArrived = (arrivedAt: Date) => ({ - id: QUEUE_A, - judgeId: JUDGE_ID, - hackathonId: HACK_A, - projectId: PROJECT_A, - isCompleted: false, - startedAt: arrivedAt, - arrivedAt, - }); - - it("refuses a first score past the cutoff, but lets an existing one be revised", async () => { - wireJudge({ - project: { id: PROJECT_A, hackathonId: HACK_A }, - queue: [slotArrived(lapsed())], - }); await expect( - judgeCaller().judge.submitVote({ projectId: PROJECT_A, ...scores }), - ).rejects.toMatchObject({ - code: "CONFLICT", - message: expect.stringMatching(/time ran out/i), - }); - expect(mockInsert).not.toHaveBeenCalled(); - - // The same late slot, but the judge already scored inside the window: - // correcting that score is allowed while judging is open. - wireJudge({ - project: { id: PROJECT_A, hackathonId: HACK_A }, - queue: [slotArrived(lapsed())], - vote: { id: "vote_1" }, - }); - - await expect( - judgeCaller().judge.submitVote({ projectId: PROJECT_A, ...scores }), - ).resolves.toBeDefined(); - expect(insertsInto(judgeVotes)).toHaveLength(1); + judgeCaller().hackathon.getResults({ hackathonId: HACK_A }), + ).resolves.toEqual([]); }); - it("voids a completion past the cutoff and moves the judge on", async () => { - wireJudge({ - project: { id: PROJECT_A, hackathonId: HACK_A }, - queue: [slotArrived(lapsed())], - }); - // Nothing left to hand out, so the judge is done once the slot closes. - wireDispatch({ pool: [] }); + it("serves the second read from the 60s cache", async () => { + wireResults(); - const res = await judgeCaller().judge.completeAndNext({ - queueId: QUEUE_A, - projectId: PROJECT_A, - ...scores, - }); + await judgeCaller().hackathon.getResults({ hackathonId: HACK_A }); + const reads = mockFindMany.mock.calls.length; + await judgeCaller().hackathon.getResults({ hackathonId: HACK_A }); - expect(res).toMatchObject({ timedOut: true, done: true }); - expect(insertsInto(judgeVotes)).toHaveLength(0); - // The slot is closed, so the table goes back to the pool. - const closed = mockUpdate.mock.calls.some( - (call) => call[1]?.[0] === judgeQueue && call[2]?.[0]?.isCompleted === true, - ); - expect(closed).toBe(true); + expect(mockFindMany.mock.calls.length).toBe(reads); }); }); - - // isJudge reads ids before the procedure's schema runs, and each one goes - // into a uuid column: a malformed id was a Postgres error and a 500. - it("refuses a malformed id before it reaches the database", async () => { - await expect( - judgeCaller().judge.getNextTable({ hackathonId: "not-a-uuid" }), - ).rejects.toMatchObject({ code: "BAD_REQUEST" }); - expect(mockFindFirst).not.toHaveBeenCalled(); - }); }); diff --git a/packages/api/src/.internal-tests/participant-edge.test.ts b/packages/api/src/.internal-tests/participant-edge.test.ts index a7299164..d73a221e 100644 --- a/packages/api/src/.internal-tests/participant-edge.test.ts +++ b/packages/api/src/.internal-tests/participant-edge.test.ts @@ -55,7 +55,7 @@ vi.mock("@query/db", async () => { eventCheckIns: table("eventCheckIns"), judges: table("judges"), judgeAssignments: table("judgeAssignments"), - judgingProjects: table("judgingProjects"), + judgingEntries: table("judgingEntries"), judgeVotes: table("judgeVotes"), judgeQueue: table("judgeQueue"), stripePayments: table("stripePayments"), @@ -142,6 +142,7 @@ vi.mock("@query/db", async () => { userId: "user_id", teamId: "team_id", registrationStatus: "registration_status", + attendanceConfirmedAt: "attendance_confirmed_at", hasSubmittedProject: "has_submitted_project", registeredAt: "registered_at", }, @@ -208,10 +209,10 @@ vi.mock("@query/db", async () => { isActive: "is_active", }, judgeAssignments: { judgeId: "judge_id", hackathonId: "hackathon_id" }, - judgingProjects: { + judgingEntries: { id: "id", - hackathonId: "hackathon_id", - tableNumber: "table_number", + sourceProjectId: "source_project_id", + deletedAt: "deleted_at", }, judgeVotes: { judgeId: "judge_id", projectId: "project_id", score: "score" }, judgeQueue: { @@ -287,6 +288,8 @@ const registrationInput = (overrides: Record = {}) => ({ country: "United States", whyAttend: "I want to build with data.", agreeToCodeOfConduct: true as const, + mlhCodeOfConduct: true as const, + mlhDataSharing: true as const, ...overrides, }); @@ -373,6 +376,24 @@ describe("Participant edge cases", () => { ).rejects.toThrow(/captain/i); }); + // An organiser removed it from judging: the removed judging entry is the + // record, and re-saving would put the project back in the gallery. + it("refuses a resubmit after an organiser removed the project from judging", async () => { + const caller = teammateCaller(20, { captainId: "user_a" }); + await caller.team.submitProject(projectInput()); + + const base = mockFindFirst.getMockImplementation()!; + mockFindFirst.mockImplementation((table, ...rest) => + table === "judgingEntries" + ? { id: "entry_1" } + : base(table, ...rest), + ); + + await expect( + caller.team.submitProject(projectInput({ name: "Renamed" })), + ).rejects.toThrow(/organiser withdrew/i); + }); + // BUG: team.ts:494 only looks up an existing project when input.teamId is // set, so the teamId-less path always takes the INSERT branch. it("updates the existing project instead of inserting a duplicate on resubmit", async () => { @@ -632,6 +653,23 @@ describe("Participant edge cases", () => { caller.team.withdrawProject({ hackathonId: HACK_A }), ).rejects.toThrow(/Judging has started/i); }); + + // Promotion into room judging is recorded as a judging entry; that alone + // means judging has the project, whatever the submission's status says. + it("refuses to withdraw once the project has a live judging entry", async () => { + const caller = captainCaller(20, { + currentMembers: 1, + project: { id: PROJECT_A, teamId: TEAM_A, status: "submitted" }, + }); + const base = mockFindFirst.getMockImplementation()!; + mockFindFirst.mockImplementation((table, ...rest) => + table === "judgingEntries" ? { id: "entry_1" } : base(table, ...rest), + ); + + await expect( + caller.team.withdrawProject({ hackathonId: HACK_A }), + ).rejects.toThrow(/Judging has started/i); + }); }); // ===================================================================== @@ -917,6 +955,289 @@ describe("Participant edge cases", () => { }); }); + // An acceptance email holds a seat until the day. Saying "I'm coming" is how + // organisers tell the people who are from the people who have gone quiet. + describe("Confirming attendance", () => { + const wire = (participant: Record | undefined) => + mockFindFirst.mockImplementation((table: string) => + table === "hackathonParticipants" ? participant : undefined, + ); + const writes = () => + mockUpdate.mock.calls.filter((c) => c[1]?.[0] === hackathonParticipants); + + it("stamps an accepted hacker's registration", async () => { + wire({ + id: PARTICIPANT_A, + registrationStatus: "approved", + attendanceConfirmedAt: null, + }); + mockUpdate.mockReturnValueOnce([{ id: PARTICIPANT_A }]); + + const res = await callerFor("user_a").hackathon.confirmAttendance({ + hackathonId: HACK_A, + }); + + expect(res.confirmedAt).toBeInstanceOf(Date); + expect(writes()).toHaveLength(1); + expect(writes()[0]?.[2][0]).toMatchObject({ + attendanceConfirmedAt: res.confirmedAt, + }); + // Only an accepted, still-silent row may take the stamp. + expect(JSON.stringify(writes()[0]?.[3])).toContain("approved"); + }); + + it("keeps the first answer and writes nothing the second time", async () => { + const first = new Date(Date.now() - DAY); + wire({ + id: PARTICIPANT_A, + registrationStatus: "approved", + attendanceConfirmedAt: first, + }); + + const res = await callerFor("user_a").hackathon.confirmAttendance({ + hackathonId: HACK_A, + }); + + expect(res.confirmedAt).toBe(first); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("evicts only this person's registration list", async () => { + wire({ + id: PARTICIPANT_A, + registrationStatus: "approved", + attendanceConfirmedAt: null, + }); + mockUpdate.mockReturnValueOnce([{ id: PARTICIPANT_A }]); + cache.set("hackathon:registrations:user_a", ["cached"], 60); + cache.set("hackathon:registrations:other_user", ["cached"], 60); + cache.set(`hackathon:${HACK_A}:participants`, ["cached"], 60); + + await callerFor("user_a").hackathon.confirmAttendance({ + hackathonId: HACK_A, + }); + + expect(cache.get("hackathon:registrations:user_a")).toBeNull(); + expect(cache.get("hackathon:registrations:other_user")).not.toBeNull(); + expect(cache.get(`hackathon:${HACK_A}:participants`)).not.toBeNull(); + }); + + it.each(["pending", "waitlisted", "rejected"])( + "refuses somebody who is %s", + async (registrationStatus) => { + wire({ + id: PARTICIPANT_A, + registrationStatus, + attendanceConfirmedAt: null, + }); + + await expect( + callerFor("user_a").hackathon.confirmAttendance({ + hackathonId: HACK_A, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(mockUpdate).not.toHaveBeenCalled(); + }, + ); + + it("refuses somebody who never registered", async () => { + wire(undefined); + + await expect( + callerFor("user_a").hackathon.confirmAttendance({ hackathonId: HACK_A }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + // Rejected between the read and the write: the WHERE matches nothing, and + // the stamp must not be reported as taken. + it("refuses when the registration changed after it was read", async () => { + wire({ + id: PARTICIPANT_A, + registrationStatus: "approved", + attendanceConfirmedAt: null, + }); + mockUpdate.mockReturnValueOnce([]); + + await expect( + callerFor("user_a").hackathon.confirmAttendance({ hackathonId: HACK_A }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + }); + + it("needs a signed-in user", async () => { + await expect( + callerFor().hackathon.confirmAttendance({ hackathonId: HACK_A }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + }); + + // Typos get caught after the form is sent. Until an organiser has looked, the + // applicant can fix them; after that the answers are what was decided on. + describe("Editing a pending application", () => { + const wire = ( + participant: Record | undefined, + hackathon: Record | null = runningHackathon(-24), + ) => + mockFindFirst.mockImplementation((table: string) => { + if (table === "hackathons") return hackathon; + if (table === "hackathonParticipants") return participant; + return undefined; + }); + const pending = { id: PARTICIPANT_A, registrationStatus: "pending" }; + const editInput = (overrides: Record = {}) => { + const { + agreeToCodeOfConduct: _a, + mlhCodeOfConduct: _b, + mlhDataSharing: _c, + ...answers + } = registrationInput(); + return { ...answers, ...overrides }; + }; + const writes = () => + mockUpdate.mock.calls.filter((c) => c[1]?.[0] === hackathonParticipants); + + it("replaces the answers of a pending application", async () => { + wire(pending); + mockUpdate.mockReturnValueOnce([{ id: PARTICIPANT_A }]); + + await expect( + callerFor("user_a").hackathon.updateMyRegistration( + editInput({ phone: "555-0199", major: "Mathematics" }), + ), + ).resolves.toEqual({ success: true }); + + expect(writes()[0]?.[2][0]).toMatchObject({ + phone: "555-0199", + major: "Mathematics", + }); + // Held to the row and to the status it was read at. + expect(JSON.stringify(writes()[0]?.[3])).toContain(PARTICIPANT_A); + expect(JSON.stringify(writes()[0]?.[3])).toContain("pending"); + }); + + it("clears an optional answer that was left blank", async () => { + wire(pending); + mockUpdate.mockReturnValueOnce([{ id: PARTICIPANT_A }]); + + await callerFor("user_a").hackathon.updateMyRegistration( + editInput({ pronouns: undefined, resumeUrl: "", shirtSize: undefined }), + ); + + expect(writes()[0]?.[2][0]).toMatchObject({ + pronouns: null, + resumeUrl: null, + shirtSize: null, + }); + }); + + it("leaves the consents and the status alone", async () => { + wire(pending); + mockUpdate.mockReturnValueOnce([{ id: PARTICIPANT_A }]); + + await callerFor("user_a").hackathon.updateMyRegistration(editInput()); + + const set = writes()[0]?.[2][0]; + expect(set).not.toHaveProperty("registrationStatus"); + expect(set).not.toHaveProperty("agreeToCodeOfConduct"); + expect(set).not.toHaveProperty("mlhCodeOfConduct"); + expect(set).not.toHaveProperty("mlhDataSharing"); + expect(set).not.toHaveProperty("hackathonId"); + expect(set).not.toHaveProperty("userId"); + }); + + it("evicts only this person's registration list", async () => { + wire(pending); + mockUpdate.mockReturnValueOnce([{ id: PARTICIPANT_A }]); + cache.set("hackathon:registrations:user_a", ["cached"], 60); + cache.set("hackathon:registrations:other_user", ["cached"], 60); + + await callerFor("user_a").hackathon.updateMyRegistration(editInput()); + + expect(cache.get("hackathon:registrations:user_a")).toBeNull(); + expect(cache.get("hackathon:registrations:other_user")).not.toBeNull(); + }); + + it("holds the new answers to the rules the form had", async () => { + wire(pending); + + await expect( + callerFor("user_a").hackathon.updateMyRegistration( + editInput({ whyAttend: " " }), + ), + ).rejects.toThrow(/Tell us why you want to attend/); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it.each(["approved", "waitlisted", "rejected", "checked_in"])( + "refuses once the application is %s", + async (registrationStatus) => { + wire({ id: PARTICIPANT_A, registrationStatus }); + + await expect( + callerFor("user_a").hackathon.updateMyRegistration(editInput()), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(mockUpdate).not.toHaveBeenCalled(); + }, + ); + + it("refuses once registration has closed", async () => { + wire(pending, runningHackathon(-24, { status: "in_progress" })); + + await expect( + callerFor("user_a").hackathon.updateMyRegistration(editInput()), + ).rejects.toThrow(/Registration has closed/); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("refuses after the deadline", async () => { + wire( + pending, + runningHackathon(-24, { + registrationDeadline: new Date(Date.now() - HOUR), + }), + ); + + await expect( + callerFor("user_a").hackathon.updateMyRegistration(editInput()), + ).rejects.toThrow(/Registration has closed/); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + // The registration is looked up by the caller's own user id, so somebody + // else's application is simply not found. + it("only ever looks at the caller's own registration", async () => { + wire(undefined); + + await expect( + callerFor("user_b").hackathon.updateMyRegistration(editInput()), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + + const lookup = mockFindFirst.mock.calls.find( + (c) => c[0] === "hackathonParticipants", + ); + expect(JSON.stringify(lookup?.[1]?.where)).toContain("user_b"); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("refuses an unknown hackathon", async () => { + wire(pending, null); + + await expect( + callerFor("user_a").hackathon.updateMyRegistration(editInput()), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + // An organiser decided between the read and the write: the WHERE carries + // the status, matches nothing, and the decision stands. + it("refuses when it was reviewed after being read", async () => { + wire(pending); + mockUpdate.mockReturnValueOnce([]); + + await expect( + callerFor("user_a").hackathon.updateMyRegistration(editInput()), + ).rejects.toMatchObject({ code: "CONFLICT" }); + }); + }); + describe("6. Registration under contention", () => { // BUG: the duplicate guard at registration.ts:95-108 is an unlocked // findFirst; the hackathon_participant_hackathon_user_idx violation that follows is diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts index 2bbb5c03..c402667a 100644 --- a/packages/api/src/.internal-tests/qr-checkin.test.ts +++ b/packages/api/src/.internal-tests/qr-checkin.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { appRouter } from "../root"; import { cache } from "../middleware/cache"; -import { db, events, eventCheckIns, hackathonEventAttendees } from "@query/db"; +import { + db, + events, + eventCheckIns, + hackathonCheckInFlags, + hackathonEventAttendees, +} from "@query/db"; // The real rule: a hardcoded "2026-fall" would fail every January. import { currentTerm } from "@query/db/services/membership"; import { __onRollback } from "./_db-tx-mock"; @@ -21,6 +27,9 @@ const mockInsert = vi.fn(); const mockUpdate = vi.fn(); const mockDelete = vi.fn(); const mockSelect = vi.fn(); +// A participant's earlier check-ins, which recordEventAttendance reads as +// attendance joined to its event. Empty unless a test says otherwise. +const mockEarlierAttendance = vi.fn<() => unknown[]>(() => []); vi.mock("@query/db", async () => { const { createTransactionMock } = await import("./_db-tx-mock"); @@ -100,7 +109,7 @@ vi.mock("@query/db", async () => { hackathonParticipants: table("hackathonParticipants"), hackathonTeams: table("hackathonTeams"), hackathonProjects: table("hackathonProjects"), - judgingProjects: table("judgingProjects"), + judgingEntries: table("judgingEntries"), hackathonEvents: table("hackathonEvents"), hackathonEventAttendees: table("hackathonEventAttendees"), members: table("members"), @@ -172,11 +181,10 @@ vi.mock("@query/db", async () => { status: "status", submittedAt: "submitted_at", }, - judgingProjects: { + judgingEntries: { id: "id", sourceProjectId: "source_project_id", - withdrawnAt: "withdrawn_at", - tableNumber: "table_number", + deletedAt: "deleted_at", }, hackathonEvents: { id: "id", @@ -189,6 +197,12 @@ vi.mock("@query/db", async () => { eventId: "event_id", participantId: "participant_id", }, + hackathonCheckInFlags: { + participantId: "participant_id", + eventId: "event_id", + conflictEventId: "conflict_event_id", + attempts: "attempts", + }, members: { id: "id", userId: "user_id", hackathonId: "hackathon_id" }, events: { id: "id", @@ -262,7 +276,10 @@ describe("QR check-in", () => { cache.clear(); // Aggregate reads default to "nothing there"; the concurrency tests below // override this to hand back the live row a FOR UPDATE would have locked. - mockSelect.mockReturnValue([{ count: 0 }]); + mockSelect.mockImplementation((_op, _sel, _from, _where, joined) => + joined ? mockEarlierAttendance() : [{ count: 0 }], + ); + mockEarlierAttendance.mockReturnValue([]); }); const createMockCtx = (userId?: string) => @@ -834,10 +851,15 @@ describe("QR check-in", () => { expect(mockInsert).toHaveBeenCalledTimes(1); const [, insertArgs, valArgs] = mockInsert.mock.calls[0] as any[]; expect(insertArgs[0]).toBe(hackathonEventAttendees); - expect(valArgs[0]).toEqual({ + // The attendance row also says how it was recorded and by whom, and + // carries the event's points at the time of the scan. + expect(valArgs[0]).toMatchObject({ eventId: HACK_EVENT, participantId: PARTICIPANT, + method: "scan", }); + expect(valArgs[0]).toHaveProperty("pointsAwarded"); + expect(valArgs[0]).toHaveProperty("checkedInById"); // The roster write is what makes the analytics tile count real arrivals // rather than manual admin decisions — and what lets the team submit. const rosterWrite = mockUpdate.mock.calls[0]; @@ -894,6 +916,110 @@ describe("QR check-in", () => { expect(err.message).toMatch(/is already checked into/); }); + it("refuses a scan into an event that runs at the same time as one already attended", async () => { + // Named and typed outright: the default fixture reads as a meal, and + // meals are exempt. + const caller = scannerCtx({ + event: hackEvent({ + name: "Full Sail Fireside", + type: "sponsor_session", + endTime: new Date(Date.now() + HOUR), + }), + }); + mockEarlierAttendance.mockReturnValue([ + { + event: { + id: CLUB_EVENT, + name: "BNY Workshop", + type: "workshop", + startTime: new Date(Date.now() - 2 * HOUR), + endTime: new Date(Date.now() + 2 * HOUR), + }, + checkedInAt: new Date(Date.now() - 90 * 60 * 1000), + }, + ]); + + const err: any = await caller.hackathon + .scanParticipantPass({ + hackathonId: HACK_A, + eventId: HACK_EVENT, + participantId: PARTICIPANT, + }) + .catch((e: unknown) => e); + + expect(err.code).toBe("FORBIDDEN"); + expect(err.message).toBe( + "Ada Lovelace is already checked into BNY Workshop, which runs at the same time as Full Sail Fireside. If that was a mistake, remove them from BNY Workshop first.", + ); + // No attendance and no arrival, but the attempt is on record for + // organisers: it is the one write that survives the refusal. + const inserts = mockInsert.mock.calls as any[][]; + expect( + inserts.filter((c) => c[1][0] === hackathonEventAttendees), + ).toHaveLength(0); + const flags = inserts.filter((c) => c[1][0] === hackathonCheckInFlags); + expect(flags).toHaveLength(1); + expect(flags[0]![2][0]).toMatchObject({ + hackathonId: HACK_A, + participantId: PARTICIPANT, + eventId: HACK_EVENT, + conflictEventId: CLUB_EVENT, + rule: "overlap", + method: "scan", + attemptedById: "admin_user_id", + attempts: 1, + }); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("writes the flag after the refused scan has rolled back, not inside it", async () => { + const caller = scannerCtx({ + event: hackEvent({ + name: "Full Sail Fireside", + type: "sponsor_session", + endTime: new Date(Date.now() + HOUR), + }), + }); + mockEarlierAttendance.mockReturnValue([ + { + event: { + id: CLUB_EVENT, + name: "BNY Workshop", + type: "workshop", + startTime: new Date(Date.now() - 2 * HOUR), + endTime: new Date(Date.now() + 2 * HOUR), + }, + checkedInAt: new Date(Date.now() - 90 * 60 * 1000), + }, + ]); + // A write made through a transaction registers an undo that runs when the + // refusal throws; one made on the pool registers nothing. + let flagRolledBack = false; + mockInsert.mockImplementation((_op, [table]) => { + if (table === hackathonCheckInFlags) { + __onRollback(() => { + flagRolledBack = true; + }); + } + return [{}]; + }); + + await expect( + caller.hackathon.scanParticipantPass({ + hackathonId: HACK_A, + eventId: HACK_EVENT, + participantId: PARTICIPANT, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect( + (mockInsert.mock.calls as any[][]).filter( + (c) => c[1][0] === hackathonCheckInFlags, + ), + ).toHaveLength(1); + expect(flagRolledBack).toBe(false); + }); + // BUG: scanParticipantPass matches on (id, hackathonId) only // (admin.ts:287-298) and never compares startTime/endTime to now, so one // pass of the scanner can check a participant into the whole weekend. @@ -1053,6 +1179,23 @@ describe("QR check-in", () => { expect(res.submittedById).toBeUndefined(); }); + it("tells the team when their project is in judging", async () => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "hackathonParticipants") + return { id: PARTICIPANT, teamId: null }; + if (table === "hackathonProjects") + return { id: "project_1", teamId: null, name: "Solo Entry" }; + if (table === "judgingEntries") return { id: "entry_1" }; + return undefined; + }); + + const res: any = await appRouter + .createCaller(createMockCtx("solo_user")) + .team.mySubmission({ hackathonId: HACK_A }); + + expect(res.inJudging).toBe(true); + }); + // BUG: admin.ts:190 returns `updated: participantIds.length` — the count // submitted, not the count changed — while each UPDATE is scoped by // (id, hackathonId) so foreign ids are silent no-ops. diff --git a/packages/api/src/.internal-tests/reopen-grade.test.ts b/packages/api/src/.internal-tests/reopen-grade.test.ts new file mode 100644 index 00000000..218ec8bc --- /dev/null +++ b/packages/api/src/.internal-tests/reopen-grade.test.ts @@ -0,0 +1,463 @@ +/** + * judging.admin.reopenGrade: an organiser deletes one judge's evaluation, and + * any draft for the same project and challenge, so the project is open for + * another judge. The edition's draft placings were ranked with that grade, so + * they go in the same transaction and results have to be computed again + * before they can be published. Refused once results are published. + * + * The schema is the real one; only the database handle is a stand-in, handed + * to the router through the context the way check-in-flags-organizer.test.ts + * does. It applies the equality conditions the router asks for, so a missing + * edition condition lets another edition's evaluation through and fails the + * test. It keeps every statement in order, which is how the tests see the + * drafts being locked before the project's grading lock and both before a + * delete. + */ +import { beforeEach, describe, expect, it } from "vitest"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { + auditLogs, + hackathons, + judgingConfigs, + judgingEvaluationDrafts, + judgingEvaluationRatings, + judgingEvaluationResponses, + judgingEvaluationRevisions, + judgingEvaluations, + judgingPlacements, +} from "@query/db"; +import { appRouter } from "../root"; +import { cache } from "../middleware/cache"; + +const HACK_A = "11111111-1111-4111-8111-111111111111"; +const HACK_B = "22222222-2222-4222-8222-222222222222"; +const ENTRY = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"; +const CHALLENGE = "cccccccc-cccc-4ccc-8ccc-ccccccccccc1"; +const JUDGE = "dddddddd-dddd-4ddd-8ddd-ddddddddddd1"; +const EVAL = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1"; +const EVAL_OTHER_EDITION = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee2"; +const DRAFT_1 = "ffffffff-ffff-4fff-8fff-fffffffffff1"; +const DRAFT_2 = "ffffffff-ffff-4fff-8fff-fffffffffff2"; +const NO_SUCH = "99999999-9999-4999-8999-999999999999"; + +const dialect = new PgDialect(); +const render = (condition: unknown) => dialect.sqlToQuery(condition as never); + +type Row = Record; +type Event = { + kind: "select" | "execute" | "insert" | "delete" | "update"; + table?: unknown; + where?: unknown; + /** A select that takes row locks. */ + locking?: boolean; +}; + +const camel = (name: string) => + name.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()); +const TERM = /"[a-z_]+"\."([a-z_]+)" = \$(\d+)/g; + +/** A row filter from a where of `column = value` terms joined by and. */ +function matcher(where: unknown) { + const { sql: text, params } = render(where); + const terms = [...text.matchAll(TERM)].map( + (match) => [camel(match[1]!), params[Number(match[2]) - 1]] as const, + ); + if (text.replace(TERM, "").replace(/[()\s]|and/g, "")) { + throw new Error(`The fake database cannot evaluate: ${text}`); + } + return (row: Row) => terms.every(([key, value]) => row[key] === value); +} + +let adminRow: Row | undefined; +let published = false; +const store = new Map(); +const events: Event[] = []; + +const rowsOf = (table: unknown, where: unknown) => { + const rows = store.get(table) ?? []; + return where ? rows.filter(matcher(where)) : [...rows]; +}; + +const fakeDb: any = { + query: { admins: { findFirst: async () => adminRow } }, + select: () => { + const event: Event = { kind: "select" }; + events.push(event); + const chain: any = { + then: (onOk: any, onErr: any) => { + // Counts of placements: those that carry a publish time (the published + // check), or every one of the edition's (what publish finds to publish). + const placings = () => { + const { sql: text, params } = render(event.where); + const count = text.includes('"published_at" is not null') + ? published + ? 3 + : 0 + : (store.get(judgingPlacements) ?? []).filter((row) => + params.includes(row.hackathonId), + ).length; + return [{ value: count, n: count }]; + }; + const rows = + event.table === judgingPlacements + ? placings() + : rowsOf(event.table, event.where); + return Promise.resolve(rows).then(onOk, onErr); + }, + }; + chain.from = (table: unknown) => { + event.table = table; + return chain; + }; + chain.where = (where: unknown) => { + event.where = where; + return chain; + }; + chain.for = () => { + event.locking = true; + return chain; + }; + for (const method of ["innerJoin", "leftJoin", "orderBy", "limit"]) { + chain[method] = () => chain; + } + return chain; + }, + execute: async () => { + events.push({ kind: "execute" }); + }, + insert: (table: unknown) => ({ + values: (values: Row) => { + events.push({ kind: "insert", table, where: values }); + return Object.assign(Promise.resolve(undefined), { + onConflictDoNothing: () => ({ returning: async () => [] }), + returning: async () => [values], + }); + }, + }), + update: (table: unknown) => ({ + set: () => ({ + where: (where: unknown) => { + events.push({ kind: "update", table, where }); + return Object.assign(Promise.resolve(undefined), { + returning: async () => [], + }); + }, + }), + }), + delete: (table: unknown) => ({ + where: (where: unknown) => { + events.push({ kind: "delete", table, where }); + // The placings the delete takes: this edition's that carry no publish + // time, which the fake reads off the condition's parameters. + const hit = + table === judgingPlacements + ? (store.get(table) ?? []).filter( + (row) => + render(where).params.includes(row.hackathonId) && + row.publishedAt === null, + ) + : []; + if (table === judgingPlacements) { + store.set( + table, + (store.get(table) ?? []).filter((row) => !hit.includes(row)), + ); + } + return Object.assign(Promise.resolve([]), { + returning: async () => hit.map((row) => ({ id: row.id })), + }); + }, + }), + transaction: async (callback: (tx: unknown) => unknown) => callback(fakeDb), +}; + +const callerAs = (userId: string | undefined) => + appRouter.createCaller({ + db: fakeDb, + session: userId ? { user: { id: userId } } : null, + userId, + cache, + clientIp: "127.0.0.1", + req: { headers: { get: () => null } }, + } as any); + +const admin = () => callerAs("admin_user"); +const reopen = ( + over: Partial<{ hackathonId: string; evaluationId: string }> = {}, +) => + admin().judging.admin.reopenGrade({ + hackathonId: HACK_A, + evaluationId: EVAL, + ...over, + }); + +// Without the audit log's own housekeeping, which prunes old rows. +const deletes = () => + events.filter( + (event) => event.kind === "delete" && event.table !== auditLogs, + ); +const audits = () => + events + .filter((event) => event.kind === "insert" && event.table === auditLogs) + .map((event) => event.where as Row); + +const configRow = (hackathonId: string, state = "open") => ({ + hackathonId, + state, + openedAt: null, + closedAt: null, + showAllScores: false, + inventoryLockedAt: null, + groupsInitializedAt: null, + itineraryPublished: false, + emergencyMode: false, + claimsStartedAt: null, +}); + +beforeEach(() => { + cache.clear(); + events.length = 0; + published = false; + adminRow = { userId: "admin_user", isActive: true, role: "admin" }; + store.clear(); + store.set(hackathons, [ + { id: HACK_A, name: "Hacklytics 2027", startDate: null, endDate: null }, + { id: HACK_B, name: "Hacklytics 2026", startDate: null, endDate: null }, + ]); + store.set(judgingConfigs, [configRow(HACK_A), configRow(HACK_B)]); + store.set(judgingEvaluations, [ + { + id: EVAL, + hackathonId: HACK_A, + entryId: ENTRY, + challengeId: CHALLENGE, + judgeId: JUDGE, + revision: 2, + isComplete: false, + }, + { + id: EVAL_OTHER_EDITION, + hackathonId: HACK_B, + entryId: ENTRY, + challengeId: CHALLENGE, + judgeId: JUDGE, + revision: 1, + isComplete: true, + }, + ]); + store.set(judgingPlacements, [ + { id: "p1", hackathonId: HACK_A, scope: "overall", publishedAt: null }, + { id: "p2", hackathonId: HACK_A, scope: CHALLENGE, publishedAt: null }, + { id: "p3", hackathonId: HACK_B, scope: "overall", publishedAt: null }, + ]); + store.set(judgingEvaluationDrafts, [ + { id: DRAFT_1, entryId: ENTRY, challengeId: CHALLENGE }, + { id: DRAFT_2, entryId: ENTRY, challengeId: CHALLENGE }, + { id: "elsewhere", entryId: NO_SUCH, challengeId: CHALLENGE }, + ]); +}); + +describe("judging.admin.reopenGrade", () => { + it("deletes the evaluation and every draft for the project and challenge", async () => { + await expect(reopen()).resolves.toEqual({ + reopened: true, + drafts: 2, + placings: 2, + }); + + const [draftDelete, evaluationDelete] = deletes(); + expect(deletes()).toHaveLength(3); + expect(draftDelete!.table).toBe(judgingEvaluationDrafts); + expect(render(draftDelete!.where).params).toEqual([DRAFT_1, DRAFT_2]); + expect(evaluationDelete!.table).toBe(judgingEvaluations); + expect(render(evaluationDelete!.where).params).toEqual([EVAL]); + }); + + it("deletes an evaluation with no draft beside it", async () => { + store.set(judgingEvaluationDrafts, []); + + await expect(reopen()).resolves.toMatchObject({ + reopened: true, + drafts: 0, + }); + + expect(deletes().map((event) => event.table)).toEqual([ + judgingEvaluations, + judgingPlacements, + ]); + }); + + it("locks the drafts, then the project's grading lock, before it deletes", async () => { + await reopen(); + + const draftLock = events.findIndex( + (event) => + event.kind === "select" && + event.table === judgingEvaluationDrafts && + event.locking, + ); + const gradingLock = events.findIndex((event) => event.kind === "execute"); + const firstDelete = events.findIndex((event) => event.kind === "delete"); + const configLock = events.findIndex( + (event) => + event.kind === "select" && + event.table === judgingConfigs && + event.locking, + ); + expect(configLock).toBeGreaterThan(-1); + expect(draftLock).toBeGreaterThan(configLock); + expect(gradingLock).toBeGreaterThan(draftLock); + expect(firstDelete).toBeGreaterThan(gradingLock); + }); + + it("deletes the edition's draft placings once the grade is gone, and no other edition's", async () => { + await expect(reopen()).resolves.toMatchObject({ placings: 2 }); + + const [, evaluationDelete, placingDelete] = deletes(); + expect(evaluationDelete!.table).toBe(judgingEvaluations); + expect(placingDelete!.table).toBe(judgingPlacements); + const query = render(placingDelete!.where); + expect(query.params).toEqual([HACK_A]); + expect(query.sql).toContain('"judging_placement"."hackathon_id" = $1'); + expect(query.sql).toContain('"published_at" is null'); + expect(store.get(judgingPlacements)!.map((row) => row.id)).toEqual(["p3"]); + }); + + it("leaves nothing to publish until results are computed again", async () => { + const publish = () => + admin().judging.results.publish({ hackathonId: HACK_A }); + + // The draft placings as computed, with the grade in them. + await expect(publish()).resolves.toMatchObject({ publishedAt: expect.any(Date) }); + + await reopen(); + + await expect(publish()).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + message: expect.stringContaining( + "Reopening a grade clears the draft placings, so compute again after one.", + ), + }); + }); + + it("reports none cleared when results were never computed", async () => { + store.set(judgingPlacements, []); + + await expect(reopen()).resolves.toMatchObject({ + reopened: true, + placings: 0, + }); + }); + + it("refuses once results are published, and deletes nothing", async () => { + published = true; + + await expect(reopen()).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + message: + "Results are published. Unpublish them first, then reopen the grade and compute results again.", + }); + + expect(deletes()).toEqual([]); + expect(audits()).toEqual([]); + }); + + it("reads published from the placings of this edition", async () => { + await reopen(); + + const placings = events.find( + (event) => event.kind === "select" && event.table === judgingPlacements, + )!; + const query = render(placings.where); + expect(query.params).toContain(HACK_A); + expect(query.sql).toContain('"published_at" is not null'); + }); + + it("is allowed while judging is closed", async () => { + store.set(judgingConfigs, [configRow(HACK_A, "closed")]); + + await expect(reopen()).resolves.toMatchObject({ reopened: true }); + + expect(deletes()).toHaveLength(3); + }); + + it("does not reach an evaluation of another edition", async () => { + await expect( + reopen({ evaluationId: EVAL_OTHER_EDITION }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + + expect(deletes()).toEqual([]); + }); + + it("says so when the evaluation is already gone", async () => { + await expect(reopen({ evaluationId: NO_SUCH })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "That evaluation is gone. Refresh the list.", + }); + }); + + it("is audited as a critical action, with what was deleted", async () => { + await reopen(); + + expect(audits()).toEqual([ + expect.objectContaining({ + userId: "admin_user", + action: "judging.reopenGrade", + resourceId: EVAL, + severity: "critical", + metadata: { + hackathonId: HACK_A, + entryId: ENTRY, + challengeId: CHALLENGE, + judgeId: JUDGE, + revision: 2, + wasComplete: false, + draftsDeleted: 2, + placingsDeleted: 2, + }, + }), + ]); + }); + + it("is for admins only", async () => { + adminRow = undefined; + await expect(reopen()).rejects.toMatchObject({ code: "FORBIDDEN" }); + + adminRow = { userId: "admin_user", isActive: true, role: "bug_tester" }; + cache.clear(); + await expect(reopen()).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(deletes()).toEqual([]); + }); + + it("rejects ids that are not uuids", async () => { + await expect(reopen({ evaluationId: "not-a-uuid" })).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + }); +}); + +describe("what goes with an evaluation", () => { + const foreignKeys = (table: Parameters[0]) => + getTableConfig(table).foreignKeys.map((key) => { + const reference = key.reference(); + return { + to: reference.foreignTable, + columns: reference.columns.map((column) => column.name), + onDelete: key.onDelete, + }; + }); + + it.each([ + ["ratings", judgingEvaluationRatings], + ["responses", judgingEvaluationResponses], + ["revisions", judgingEvaluationRevisions], + ])("deletes its %s by cascade", (_, table) => { + expect(foreignKeys(table)).toContainEqual({ + to: judgingEvaluations, + columns: ["evaluation_id"], + onDelete: "cascade", + }); + }); +}); diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts index 66416552..7df7543f 100644 --- a/packages/api/src/.internal-tests/routers.test.ts +++ b/packages/api/src/.internal-tests/routers.test.ts @@ -15,6 +15,24 @@ const mockInsert = vi.fn(); const mockUpdate = vi.fn(); const mockDelete = vi.fn(); +// Every send is a no-op here, so a procedure that emails (judge.create, ...) +// can never reach a real mail server from a test run. Listed rather than +// built from the real module: loading that pulls in the mail transport, which +// alone could push the first test that emails past its timeout. +vi.mock("@query/auth/email", () => ({ + sendTransactionalEmail: vi.fn(), + sendAnnouncementEmail: vi.fn(), + sendRegistrationOpenEmail: vi.fn(), + sendJudgeApprovedEmail: vi.fn(), + sendJudgeAddedEmail: vi.fn(), + sendJudgingTimesPostedEmail: vi.fn(), + sendInitiativeDecisionEmail: vi.fn(), + sendSubteamApplicationEmail: vi.fn(), + sendResultsPublishedEmail: vi.fn(), + sendJudgingClaimEmail: vi.fn(), + sendAcceptanceEmail: vi.fn(), +})); + vi.mock("@query/db", () => { return { db: { @@ -856,7 +874,7 @@ describe("Router Integration and Access Control Verification Suite", () => { description: "This is a long description of the awesome project.", }), ).rejects.toThrowError( - "Project edits are closed. Devposts must be final 34 hours after the hacking starts.", + "Project edits are closed. Submissions must be final 34 hours after the hacking starts.", ); }); @@ -960,6 +978,54 @@ describe("Router Integration and Access Control Verification Suite", () => { "Project submission closed. The submission window ended 36 hours after the hacking started.", ); }); + + // joinTeam refuses a closed team, so the flag decides who can get in. + describe("team.setOpen", () => { + const hackathonId = "00000000-0000-4000-8000-000000000001"; + const teamId = "00000000-0000-4000-8000-000000000002"; + const closedFlag = [ + "update", + expect.anything(), + [expect.objectContaining({ isOpen: false })], + expect.anything(), + ]; + + beforeEach(() => { + mockFindFirst.mockImplementation((table) => { + if (table === "hackathons") { + return { + id: hackathonId, + startDate: new Date(), + hackingStartTime: null, + }; + } + if (table === "hackathonTeams") { + return { id: teamId, captainId: "captain_user_id", hackathonId }; + } + return null; + }); + }); + + it("refuses anyone but the captain", async () => { + const caller = appRouter.createCaller(createMockCtx("member_user_id")); + await expect( + caller.team.setOpen({ hackathonId, teamId, isOpen: false }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(mockUpdate).not.toHaveBeenCalledWith(...closedFlag); + }); + + it("lets the captain close the team and drops the cached roster", async () => { + const cacheKey = `hackathon:${hackathonId}:teams`; + cache.set(cacheKey, [], 60); + + const caller = appRouter.createCaller(createMockCtx("captain_user_id")); + await expect( + caller.team.setOpen({ hackathonId, teamId, isOpen: false }), + ).resolves.toEqual({ success: true }); + expect(mockUpdate).toHaveBeenCalledWith(...closedFlag); + expect(cache.get(cacheKey)).toBeNull(); + }); + }); }); describe("8. Hackathon Participant Registration (does it add users to the hackathon)", () => { @@ -1012,6 +1078,8 @@ describe("Router Integration and Access Control Verification Suite", () => { country: "United States", whyAttend: "I want to build with data.", agreeToCodeOfConduct: true, + mlhCodeOfConduct: true, + mlhDataSharing: true, }); expect(res.id).toBe("participant_new"); @@ -1049,6 +1117,8 @@ describe("Router Integration and Access Control Verification Suite", () => { country: "United States", whyAttend: "I want to build with data.", agreeToCodeOfConduct: true, + mlhCodeOfConduct: true, + mlhDataSharing: true, }), ).rejects.toThrowError("You are already registered for this hackathon"); }); @@ -1084,6 +1154,8 @@ describe("Router Integration and Access Control Verification Suite", () => { country: "United States", whyAttend: "I want to build with data.", agreeToCodeOfConduct: true, + mlhCodeOfConduct: true, + mlhDataSharing: true, }) .catch((error: unknown) => error); expect(String((outcome as Error)?.message ?? "")).not.toMatch(/full/); @@ -1114,6 +1186,8 @@ describe("Router Integration and Access Control Verification Suite", () => { country: "United States", whyAttend: "I want to build with data.", agreeToCodeOfConduct: true, + mlhCodeOfConduct: true, + mlhDataSharing: true, }), ).rejects.toThrowError("Registration is not open for this hackathon"); }); @@ -1685,7 +1759,7 @@ describe("Router Integration and Access Control Verification Suite", () => { ); }); - describe("13. Judging System, Queue & Live Rankings", () => { + describe("13. Judge roster", () => { const hackathonId = "00000000-0000-4000-8000-000000000050"; it("should allow admin to create a judge profile", async () => { @@ -1716,76 +1790,6 @@ describe("Router Integration and Access Control Verification Suite", () => { expect(judge.name).toBe("Judge Dread"); }); - - it("should calculate bias-corrected Z-score and Bayesian rankings for hackathon projects", async () => { - const ctx = createMockCtx("admin_user_id"); - - mockFindFirst.mockImplementation((table) => { - if (table === "admins") { - return { id: "admin_1", userId: "admin_user_id", role: "admin", isActive: true }; - } - return null; - }); - - mockFindMany.mockImplementation((table) => { - if (table === "judgingProjects") { - return [ - { - id: "proj_1", - name: "AI Health Assistant", - tableNumber: 1, - votes: [ - { - judgeId: "j1", - score: 45, - scoreCreativity: 9, - scoreImpact: 9, - scoreScope: 9, - scoreClarity: 9, - scoreSoundness: 9, - judge: { name: "Strict Judge", user: { name: "Strict Judge" } }, - }, - { - judgeId: "j2", - score: 48, - scoreCreativity: 10, - scoreImpact: 10, - scoreScope: 9, - scoreClarity: 10, - scoreSoundness: 9, - judge: { name: "Lenient Judge", user: { name: "Lenient Judge" } }, - }, - ], - }, - { - id: "proj_2", - name: "Simple Web App", - tableNumber: 2, - votes: [ - { - judgeId: "j1", - score: 25, - scoreCreativity: 5, - scoreImpact: 5, - scoreScope: 5, - scoreClarity: 5, - scoreSoundness: 5, - judge: { name: "Strict Judge", user: { name: "Strict Judge" } }, - }, - ], - }, - ]; - } - return []; - }); - - const caller = appRouter.createCaller(ctx); - const res = await caller.judge.getRankings({ hackathonId }); - - expect(res.rankings.length).toBe(2); - expect(res.rankings[0].project.name).toBe("AI Health Assistant"); - expect(res.rankings[0].weightedScore).toBeGreaterThan(res.rankings[1].weightedScore); - }); }); describe("14. User Profile & Image Validation", () => { diff --git a/packages/api/src/.internal-tests/schedule-judges.test.ts b/packages/api/src/.internal-tests/schedule-judges.test.ts new file mode 100644 index 00000000..94fcd334 --- /dev/null +++ b/packages/api/src/.internal-tests/schedule-judges.test.ts @@ -0,0 +1,1094 @@ +/** + * Judges on the saved schedule: saving gives every presentation a judge as far + * as there are judges, the board shows who grades what and where judges run + * short, "assign judges" picks again for what has not started, and an + * organiser can set one appointment's judge by hand until it starts. + * + * The schema is the real one and so is the scheduling code; only the database + * handle is a stand-in, handed to the router through the context the way + * check-in-flags-organizer.test.ts does. It keeps the rows of the tables the + * router reads and records what it writes. The CP-SAT runner is a stub, so a + * test decides what the script "printed" and sees exactly what it was asked. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { + auditLogs, + hackathons, + judgingAppointments, + judgingChallenges, + judgingConfigs, + judgingEntryChallenges, + judgingEvaluationDrafts, + judgingEvaluations, + judgingGuestSessions, + judgingJudges, + judgingPresence, + judgingRooms, + judgingSchedules, +} from "@query/db"; +import { appRouter } from "../root"; +import { cache } from "../middleware/cache"; +import { assignJudges } from "../services/judging/schedule/judges"; +import type * as Store from "../services/judging/evaluation-store"; +import type * as Runner from "../services/judging/schedule/cpsat-runner"; + +const mockAvailable = vi.fn(); +const mockRun = vi.fn(); + +// Nothing is due on any board read here, so there is nothing to reconcile. +vi.mock("../services/judging/evaluation-store", async (importActual) => ({ + ...(await importActual()), + reconcileDueDrafts: async () => undefined, +})); +vi.mock("../services/judging/schedule/cpsat-runner", async (importActual) => ({ + ...(await importActual()), + cpsatAvailable: () => mockAvailable(), + runCpsat: (...args: unknown[]) => mockRun(...args), +})); + +const id = (n: number) => + `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`; +const HACK = id(1); +const GENERAL = id(10); +const ACME = id(11); +const G1 = id(20); +const G2 = id(21); +const A1 = id(22); +const A2 = id(23); +const E1 = id(30); +const E2 = id(31); +const E3 = id(32); +const APPT = [id(40), id(41), id(42), id(43)] as const; +const [P1, P2, P3, P4] = APPT; +const M1 = id(50); +const M2 = id(51); +const GS = id(52); +const NOROOM = id(53); +const SCHEDULE = id(60); + +// Slots are 10 minutes from 14:00. It is 15:00, so P1 (14:00) is over and the +// rest (15:30 and 16:00) are still to come. +const NOW = new Date("2027-02-06T15:00:00Z"); +const at = (time: string) => new Date(`2027-02-06T${time}:00Z`); + +const dialect = new PgDialect(); +const render = (condition: unknown) => dialect.sqlToQuery(condition as never); + +type Row = Record; +type Write = { table: unknown; values: Row | Row[]; where?: unknown }; + +const camel = (name: string) => + name.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()); +const TERM = /"[a-z_]+"\."([a-z_]+)" = \$(\d+)/g; + +/** A row filter from a where of `column = value` terms, or null for anything else. */ +function matcher(where: unknown) { + const { sql: text, params } = render(where); + const terms = [...text.matchAll(TERM)].map( + (match) => [camel(match[1]!), params[Number(match[2]) - 1]] as const, + ); + if (text.replace(TERM, "").replace(/[()\s]|and/g, "")) return null; + return (row: Row) => terms.every(([key, value]) => row[key] === value); +} + +let adminRow: Row | undefined; +const store = new Map(); +const inserts: Write[] = []; +const updates: Write[] = []; +// Counts reads of a table, so a test can change what a later read returns. +const reads = new Map(); +let onRead: ((table: unknown, count: number) => Row[] | undefined) | null = + null; + +const fakeDb: any = { + query: { admins: { findFirst: async () => adminRow } }, + select: () => { + let table: unknown = null; + let where: unknown = null; + const chain: any = { + then: (ok: any, err: any) => { + const count = (reads.get(table) ?? 0) + 1; + reads.set(table, count); + const rows = onRead?.(table, count) ?? store.get(table) ?? []; + const keep = where ? matcher(where) : null; + return Promise.resolve(keep ? rows.filter(keep) : [...rows]).then( + ok, + err, + ); + }, + }; + chain.from = (from: unknown) => { + table = from; + return chain; + }; + chain.where = (condition: unknown) => { + where = condition; + return chain; + }; + for (const method of ["innerJoin", "leftJoin", "orderBy", "limit", "for"]) { + chain[method] = () => chain; + } + return chain; + }, + insert: (table: unknown) => ({ + values: (values: Row | Row[]) => { + inserts.push({ table, values }); + return Object.assign(Promise.resolve(undefined), { + onConflictDoNothing: () => ({ returning: async () => [] }), + returning: async () => [values], + }); + }, + }), + update: (table: unknown) => ({ + set: (values: Row) => ({ + where: (where: unknown) => { + const keep = matcher(where); + const hit = keep ? (store.get(table) ?? []).filter(keep) : []; + for (const row of hit) Object.assign(row, values); + updates.push({ table, values, where }); + return Object.assign(Promise.resolve(undefined), { + returning: async () => hit.map((row) => ({ ...row })), + }); + }, + }), + }), + delete: () => ({ where: async () => [] }), + transaction: async (callback: (tx: unknown) => unknown) => callback(fakeDb), +}; + +const callerAs = (userId: string | undefined) => + appRouter.createCaller({ + db: fakeDb, + session: userId ? { user: { id: userId } } : null, + userId, + cache, + clientIp: "127.0.0.1", + req: { headers: { get: () => null } }, + } as any); +const admin = () => callerAs("admin_user"); + +const audits = () => + inserts + .filter((write) => write.table === auditLogs) + .map((write) => write.values as Row); + +/** The appointments an update touched, by the ids in its where. */ +const touched = (write: Write) => + render(write.where).params.filter((p) => APPT.includes(p as never)); + +/** Which judge each appointment was left with by the judge updates. */ +function judgeAfter() { + const result = new Map(); + for (const write of updates) { + if (write.table !== judgingAppointments) continue; + if (!("assignedJudgeId" in (write.values as Row))) continue; + for (const apptId of touched(write)) { + result.set(apptId as string, (write.values as Row).assignedJudgeId); + } + } + return result; +} + +const appointment = (apptId: string, over: Row): Row => ({ + id: apptId, + scheduleId: SCHEDULE, + hackathonId: HACK, + revision: 0, + assignedJudgeId: null, + entryDeletedAt: null, + deadlineAt: null, + ...over, +}); +const times = (start: string) => ({ + startsAt: at(start), + deadlineAt: at(start), + endsAt: new Date(at(start).getTime() + 10 * 60_000), +}); + +// Over (P1), then P2 and P3 at once, then P4 for the sponsor. +const appointments = (): Row[] => [ + appointment(P1, { + entryId: E1, + challengeId: GENERAL, + roomId: G1, + entryTitle: "Project 1", + ...times("14:00"), + }), + appointment(P2, { + entryId: E2, + challengeId: GENERAL, + roomId: G1, + entryTitle: "Project 2", + ...times("15:30"), + }), + appointment(P3, { + entryId: E3, + challengeId: GENERAL, + roomId: G2, + entryTitle: "Project 3", + ...times("15:30"), + }), + appointment(P4, { + entryId: E1, + challengeId: ACME, + roomId: A1, + entryTitle: "Project 1", + ...times("16:00"), + }), +]; + +const judge = (judgeId: string, kind: string, displayName: string) => ({ + id: judgeId, + kind, + userId: kind === "member" ? `user-${displayName}` : null, + displayName, +}); + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + vi.clearAllMocks(); + cache.clear(); + inserts.length = 0; + updates.length = 0; + reads.clear(); + onRead = null; + mockAvailable.mockReturnValue(false); + adminRow = { userId: "admin_user", isActive: true, role: "admin" }; + + store.clear(); + // Every table the router reads by edition holds that edition's rows. + const set = store.set.bind(store); + store.set = (table, rows) => + set( + table, + table === hackathons || + table === judgingSchedules || + table === judgingConfigs + ? rows + : rows.map((row) => ({ hackathonId: HACK, ...row })), + ); + store.set(hackathons, [ + { id: HACK, name: "Hacklytics 2027", startDate: null, endDate: null }, + ]); + store.set(judgingConfigs, [{ hackathonId: HACK, state: "open" }]); + store.set(judgingSchedules, [ + { + id: SCHEDULE, + hackathonId: HACK, + windowStart: at("14:00"), + windowEnd: at("17:00"), + setupMinutes: 2, + judgingMinutes: 6, + teardownMinutes: 2, + sameBuildingBreakMinutes: 10, + crossBuildingBreakMinutes: 20, + reducedBreaksAcknowledgedAt: null, + firstResultAt: null, + createdById: "admin_user", + createdAt: NOW, + }, + ]); + const challenge = (cid: string, label: string, general: boolean) => ({ + id: cid, + label, + parentId: null, + mode: "scheduled", + isGeneral: general, + isGroup: false, + }); + store.set(judgingChallenges, [ + challenge(GENERAL, "General", true), + challenge(ACME, "Acme", false), + ]); + const room = ( + rid: string, + name: string, + challengeId: string, + building: string, + ) => ({ + id: rid, + name, + kind: "table", + challengeId, + buildingId: building, + buildingName: building, + archivedAt: null, + }); + store.set(judgingRooms, [ + room(G1, "Table G1", GENERAL, "ENG"), + room(G2, "Table G2", GENERAL, "ENG"), + room(A1, "Acme 1", ACME, "HEC"), + room(A2, "Acme 2", ACME, "HEC"), + ]); + store.set(judgingEntryChallenges, [ + { entryId: E1, challengeId: GENERAL, title: "Project 1" }, + { entryId: E2, challengeId: GENERAL, title: "Project 2" }, + { entryId: E3, challengeId: GENERAL, title: "Project 3" }, + { entryId: E1, challengeId: ACME, title: "Project 1" }, + ]); + store.set(judgingAppointments, appointments()); + store.set(judgingEvaluations, []); + store.set(judgingEvaluationDrafts, []); + store.set(judgingPresence, []); + store.set(judgingJudges, [ + judge(M1, "member", "Ada"), + judge(M2, "member", "Bea"), + judge(GS, "guest", "Grace"), + judge(NOROOM, "guest", "Nobody"), + ]); + store.set(judgingGuestSessions, [ + { judgeId: GS, roomId: A1, createdAt: NOW }, + ]); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +/** Gives an appointment a judge in the stored rows. */ +const give = (apptId: string, judgeId: string) => { + store + .get(judgingAppointments)! + .find((a) => a.id === apptId)!.assignedJudgeId = judgeId; +}; + +// --------------------------------------------------------------------------- +describe("judging.schedule.get", () => { + it("shows each appointment's judge, each judge's load and where judges run short", async () => { + // Only Ada and Grace have joined: two general presentations at 15:30 need two. + store.set(judgingJudges, [ + judge(M1, "member", "Ada"), + judge(GS, "guest", "Grace"), + ]); + give(P2, M1); + give(P4, GS); + + const board = await admin().judging.schedule.get({ hackathonId: HACK }); + + expect( + board.appointments.map((a) => [ + a.id, + a.assignedJudgeName, + a.canAssignJudge, + ]), + ).toEqual([ + [P1, null, false], + [P2, "Ada", true], + [P3, null, true], + [P4, "Grace", true], + ]); + expect(board.judging).toMatchObject({ + judges: 2, + assigned: 2, + unassigned: 1, + loads: [ + { judgeId: M1, name: "Ada", kind: "member", load: 1, available: true }, + { + judgeId: GS, + name: "Grace", + kind: "guest", + roomName: "Acme 1", + load: 1, + }, + ], + warnings: [ + { + slot: 9, + challengeId: GENERAL, + challengeLabel: "General", + presentations: 2, + judgesFree: 1, + message: "Slot 10: 2 presentations in General, only 1 judge free", + }, + ], + }); + expect(board.judging!.warnings[0]!.startsAt).toEqual(at("15:30")); + }); + + it("lists a judge who holds appointments but can no longer take any", async () => { + store.set(judgingJudges, [ + judge(M1, "member", "Ada"), + judge(GS, "guest", "Grace"), + ]); + // Grace's session was revoked: she has no room, but P4 is still hers. + store.set(judgingGuestSessions, []); + give(P4, GS); + + const board = await admin().judging.schedule.get({ hackathonId: HACK }); + expect(board.judging!.loads).toEqual([ + expect.objectContaining({ judgeId: M1, load: 0, available: true }), + expect.objectContaining({ + judgeId: GS, + name: "Grace", + load: 1, + available: false, + }), + ]); + }); + + it("says nothing of judges before a schedule is saved", async () => { + store.set(judgingSchedules, []); + const board = await admin().judging.schedule.get({ hackathonId: HACK }); + expect(board.judging).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +describe("judging.schedule.get: where each assigned judge has got", () => { + const evaluation = ( + judgeId: string, + entryId: string, + challengeId: string, + appointmentId: string | null, + ratingsComplete = true, + ) => ({ judgeId, entryId, challengeId, appointmentId, ratingsComplete }); + const draft = ( + judgeId: string, + entryId: string, + challengeId: string, + appointmentId: string | null, + updatedAt = NOW, + ) => ({ judgeId, entryId, challengeId, appointmentId, updatedAt }); + + // The clock stays at 15:00: the rate limiter reads it, and a clock that + // runs backwards between tests starves its bucket. Presentations start + // earlier instead. + const startAt = (apptId: string, time: string) => { + store.get(judgingAppointments)!.find((a) => a.id === apptId)!.startsAt = + at(time); + }; + + const progress = async () => { + const board = await admin().judging.schedule.get({ hackathonId: HACK }); + return Object.fromEntries( + board.appointments.map((a) => [a.id, a.judgeProgress]), + ); + }; + + it("tells graded, grading, taken over and not started apart, and leaves the rest blank", async () => { + // It is 15:00: P1 is over, P2 and P3 began long enough ago for the + // takeover time to have passed and P4 is still to come. + startAt(P2, "14:10"); + startAt(P3, "14:20"); + give(P1, M1); + give(P2, M1); + give(P3, M2); + give(P4, GS); + store.set(judgingEvaluations, [ + // Ada graded P1. + evaluation(M1, E1, GENERAL, P1), + // Bea graded P2, which was Ada's. + evaluation(M2, E2, GENERAL, P2), + ]); + + await expect(progress()).resolves.toEqual({ + [P1]: "graded", + [P2]: "taken_over", + // Nobody has begun it and Bea had 5 minutes. + [P3]: "not_started", + // Not started yet. + [P4]: null, + }); + + // A draft is a start. + store.set(judgingEvaluations, []); + store.set(judgingEvaluationDrafts, [ + draft(M2, E3, GENERAL, P3), + draft(M2, E2, GENERAL, P2), + ]); + await expect(progress()).resolves.toMatchObject({ + [P1]: "not_started", + // Ada's, with Bea's draft on it. + [P2]: "taken_over", + // Bea's own. + [P3]: "grading", + }); + }); + + it("gives the assigned judge five minutes from the start before it flags them", async () => { + give(P2, M1); + startAt(P2, "14:56"); + await expect(progress()).resolves.toMatchObject({ [P2]: null }); + startAt(P2, "14:55"); + await expect(progress()).resolves.toMatchObject({ [P2]: "not_started" }); + }); + + it("does not count a draft left without its deadline once the presentation is over", async () => { + // P1's deadline was 14:00. Ada only opened it, so her empty draft was + // kept without its deadline and will never be submitted. + give(P1, M1); + store.set(judgingEvaluationDrafts, [ + { ...draft(M1, E1, GENERAL, P1, at("13:58")), deadlineAt: null }, + ]); + await expect(progress()).resolves.toMatchObject({ [P1]: "not_started" }); + + // Still carrying it: due, and about to be submitted for her. + store.set(judgingEvaluationDrafts, [ + { ...draft(M1, E1, GENERAL, P1, at("13:58")), deadlineAt: at("14:00") }, + ]); + await expect(progress()).resolves.toMatchObject({ [P1]: "grading" }); + }); + + it("matches evaluations and drafts to the presentation the way the board does", async () => { + startAt(P2, "14:10"); + startAt(P3, "14:20"); + give(P2, M1); + give(P3, M1); + store.set(judgingEvaluations, [ + // Names P2, whatever its challenge. + evaluation(M1, E2, ACME, P2), + // Names no slot: the project and a challenge under P3's root. + evaluation(M1, E3, GENERAL, null), + ]); + // Another project's evaluation, and a slot P3 was moved from. + store.set(judgingEvaluationDrafts, [ + draft(M2, E2, GENERAL, P3), + draft(M2, E1, GENERAL, null), + ]); + + await expect(progress()).resolves.toMatchObject({ + [P2]: "graded", + [P3]: "graded", + }); + }); + + it("leaves a presentation with no judge, or whose project is gone, blank", async () => { + startAt(P2, "14:10"); + startAt(P3, "14:20"); + give(P2, M1); + store.get(judgingAppointments)!.find((a) => a.id === P2)!.entryDeletedAt = + NOW; + store.set(judgingEvaluations, [evaluation(M2, E1, GENERAL, P1)]); + + const board = await admin().judging.schedule.get({ hackathonId: HACK }); + expect( + board.appointments.map((a) => [a.id, a.removed, a.judgeProgress]), + ).toEqual([ + // Graded by someone, but nobody was assigned. + [P1, false, null], + [P2, true, null], + [P3, false, null], + [P4, false, null], + ]); + }); + + it("reads a presentation's status from evaluations that hold every rating", async () => { + store.set(judgingEvaluations, [evaluation(M1, E1, GENERAL, P1, false)]); + const short = await admin().judging.schedule.get({ hackathonId: HACK }); + expect(short.appointments[0]).toMatchObject({ + id: P1, + status: "incomplete", + evaluationCount: 1, + }); + + store.set(judgingEvaluations, [evaluation(M1, E1, GENERAL, P1, true)]); + const whole = await admin().judging.schedule.get({ hackathonId: HACK }); + expect(whole.appointments[0]).toMatchObject({ id: P1, status: "complete" }); + }); +}); + +// --------------------------------------------------------------------------- +describe("judging.schedule.assignJudges", () => { + const run = () => + admin().judging.schedule.assignJudges({ hackathonId: HACK }); + + it("gives every appointment that has not started a judge who may grade it", async () => { + const result = await run(); + + expect(result).toMatchObject({ + solver: "in-app", + changed: 3, + assigned: 3, + unassigned: 0, + warnings: [], + }); + const after = judgeAfter(); + // The sponsor presentation goes to the sponsor's own judge. + expect(after.get(P4)).toBe(GS); + // The two at 15:30 are in different rooms at once: two judges. + expect(new Set([after.get(P2), after.get(P3)])).toEqual(new Set([M1, M2])); + // The one that is over is left as it is. + expect(after.has(P1)).toBe(false); + expect(audits()).toEqual([ + expect.objectContaining({ + action: "judging.schedule.assignJudges", + resourceId: HACK, + metadata: expect.objectContaining({ + solver: "in-app", + changed: 3, + unassigned: 0, + }), + }), + ]); + }); + + it("keeps the judge of an appointment a judge has begun, and plans around it", async () => { + give(P2, M1); + // Ada has a draft on P2: it stays hers, so P3 goes to Bea. + store.set(judgingEvaluationDrafts, [ + { entryId: E2, challengeId: GENERAL, appointmentId: null }, + ]); + + const result = await run(); + + expect(result).toMatchObject({ kept: 1, changed: 2 }); + const after = judgeAfter(); + expect(after.has(P2)).toBe(false); + expect(after.get(P3)).toBe(M2); + }); + + it("leaves appointments alone that already have the judge it would choose", async () => { + await run(); + const first = judgeAfter(); + updates.length = 0; + for (const [apptId, judgeId] of first) give(apptId, judgeId!); + + const again = await run(); + expect(again.changed).toBe(0); + expect(updates.filter((u) => u.table === judgingAppointments)).toEqual([]); + }); + + it("runs CP-SAT when the server has it and takes its assignments", async () => { + mockAvailable.mockReturnValue(true); + mockRun.mockImplementation(async (problem: Row) => ({ + status: "solved", + log: [], + solution: JSON.stringify({ + format: "query-judging-schedule-solution", + version: 1, + fingerprint: problem.fingerprint, + status: "optimal", + relaxed: false, + placements: problem.placements, + // Bea at 15:30 in G1 and Ada in G2: the in-app order is the other way. + assignments: [ + { entryId: E2, challengeId: GENERAL, judgeId: M2 }, + { entryId: E3, challengeId: GENERAL, judgeId: M1 }, + { entryId: E1, challengeId: ACME, judgeId: GS }, + ], + }), + })); + + const result = await admin().judging.schedule.assignJudges({ + hackathonId: HACK, + seconds: 30, + }); + + expect(result.solver).toBe("cp-sat"); + expect(judgeAfter().get(P2)).toBe(M2); + expect(judgeAfter().get(P3)).toBe(M1); + // It was asked for the schedule as it stands, with the judges. + const [problem, options] = mockRun.mock.calls[0]!; + expect(options).toMatchObject({ seconds: 30 }); + expect(problem.placements).toHaveLength(3); + expect(problem.judges.map((j: Row) => j.id)).toEqual([M1, M2, GS]); + }); + + it("falls back to the in-app assigner when CP-SAT fails or answers against the rules", async () => { + mockAvailable.mockReturnValue(true); + mockRun.mockResolvedValueOnce({ + status: "failed", + reason: "no python", + log: [], + }); + expect((await run()).solver).toBe("in-app"); + + // Ada in two rooms at once. + mockRun.mockImplementationOnce(async (problem: Row) => ({ + status: "solved", + log: [], + solution: JSON.stringify({ + format: "query-judging-schedule-solution", + version: 1, + fingerprint: problem.fingerprint, + status: "optimal", + relaxed: false, + placements: problem.placements, + assignments: [ + { entryId: E2, challengeId: GENERAL, judgeId: M1 }, + { entryId: E3, challengeId: GENERAL, judgeId: M1 }, + ], + }), + })); + expect((await run()).solver).toBe("in-app"); + }); + + it("warns of each slot where judges run short", async () => { + store.set(judgingJudges, [judge(M1, "member", "Ada")]); + const result = await run(); + expect(result.unassigned).toBe(1); + expect(result.warnings.map((w) => w.message)).toEqual([ + "Slot 10: 2 presentations in General, only 1 judge free", + ]); + }); + + it("refuses when no judge can be assigned yet", async () => { + store.set(judgingJudges, [judge(NOROOM, "guest", "Nobody")]); + await expect(run()).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); + + it("refuses without a saved schedule", async () => { + store.set(judgingSchedules, []); + await expect(run()).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("refuses anyone but an admin", async () => { + adminRow = undefined; + await expect( + callerAs("a_hacker").judging.schedule.assignJudges({ hackathonId: HACK }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(updates).toEqual([]); + }); + + it("refuses to write when the judges changed while it worked", async () => { + // The first read of judges sees three; the read inside the transaction two. + onRead = (table, count) => + table === judgingJudges && count > 1 + ? [judge(M1, "member", "Ada"), judge(GS, "guest", "Grace")] + : undefined; + await expect(run()).rejects.toMatchObject({ code: "CONFLICT" }); + expect(updates.filter((u) => u.table === judgingAppointments)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +describe("judging.schedule.setAssignedJudge", () => { + const set = (appointmentId: string, judgeId: string | null) => + admin().judging.schedule.setAssignedJudge({ + hackathonId: HACK, + appointmentId, + judgeId, + }); + + it("sets the judge of an appointment that has not started, and audits it", async () => { + const result = await set(P2, M1); + + expect(result).toEqual({ + appointmentId: P2, + fromJudgeId: null, + toJudgeId: M1, + }); + expect(judgeAfter().get(P2)).toBe(M1); + expect(audits()).toEqual([ + expect.objectContaining({ + action: "judging.schedule.setJudge", + resourceId: P2, + metadata: expect.objectContaining({ toJudgeId: M1 }), + }), + ]); + }); + + it("clears a judge with null", async () => { + give(P2, M1); + await set(P2, null); + expect(judgeAfter().get(P2)).toBeNull(); + }); + + it("refuses a judge who may not grade it: a guest outside their room", async () => { + await expect(set(P2, GS)).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "Grace cannot grade General at Table G1.", + }); + // Their own room is fine. + await expect(set(P4, GS)).resolves.toMatchObject({ toJudgeId: GS }); + }); + + it("refuses a judge who is already grading something else then", async () => { + give(P3, M1); + await expect(set(P2, M1)).rejects.toMatchObject({ + code: "CONFLICT", + message: "Ada is already grading Project 3 at that time.", + }); + await expect(set(P2, M2)).resolves.toMatchObject({ toJudgeId: M2 }); + }); + + it("refuses a judge who is not one of the edition's, or a guest with no room", async () => { + await expect(set(P2, id(99))).rejects.toMatchObject({ code: "NOT_FOUND" }); + await expect(set(P2, NOROOM)).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("refuses once the presentation has started or a judge has begun on it", async () => { + await expect(set(P1, M1)).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + }); + + store.set(judgingEvaluationDrafts, [ + { entryId: E2, challengeId: GENERAL, appointmentId: P2 }, + ]); + await expect(set(P2, M1)).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + }); + expect(judgeAfter().size).toBe(0); + }); + + it("refuses an appointment that does not exist", async () => { + await expect(set(id(98), M1)).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("refuses anyone but an admin", async () => { + adminRow = undefined; + await expect( + callerAs("a_hacker").judging.schedule.setAssignedJudge({ + hackathonId: HACK, + appointmentId: P2, + judgeId: M1, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(updates).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +describe("judging.schedule.judgeChoices", () => { + it("lists the judges who may grade it, free ones first, with those booked marked", async () => { + give(P3, M1); + give(P4, M2); + + const result = await admin().judging.schedule.judgeChoices({ + hackathonId: HACK, + appointmentId: P2, + }); + + expect(result.appointment).toMatchObject({ + id: P2, + challengeLabel: "General", + roomName: "Table G1", + canAssignJudge: true, + }); + expect(result.choices).toEqual([ + expect.objectContaining({ + judgeId: M2, + name: "Bea", + load: 1, + booked: false, + }), + expect.objectContaining({ + judgeId: M1, + name: "Ada", + load: 1, + booked: true, + bookedWith: "Project 3", + }), + ]); + }); + + it("offers a sponsor presentation to the sponsor's judge and the members", async () => { + const result = await admin().judging.schedule.judgeChoices({ + hackathonId: HACK, + appointmentId: P4, + }); + expect(result.choices.map((c) => c.judgeId).sort()).toEqual( + [GS, M1, M2].sort(), + ); + }); +}); + +// --------------------------------------------------------------------------- +describe("judging.schedule.move", () => { + const staffed = () => + store.set( + judgingPresence, + [G1, G2, A1, A2].map((roomId) => ({ + judgeId: M1, + roomId, + joinedAt: at("14:00"), + lastSeenAt: NOW, + leftAt: null, + })), + ); + const move = (appointmentId: string, roomId: string, startsAt: Date) => + admin().judging.schedule.move({ + hackathonId: HACK, + appointmentId, + revision: 0, + roomId, + startsAt, + }); + const written = () => updates.filter((u) => u.table === judgingAppointments); + + it("clears a guest's assignment when the presentation moves to another room", async () => { + staffed(); + give(P4, GS); + + const moved = await move(P4, A2, at("16:00")); + + expect(moved.clearedJudgeId).toBe(GS); + expect(written()[0]!.values).toMatchObject({ + roomId: A2, + assignedJudgeId: null, + }); + }); + + it("clears a member's assignment when they would be in two places at the new time", async () => { + staffed(); + give(P2, M1); + give(P4, M1); + + // P4 is at 16:00; moving P2 there puts Ada in two rooms at once. + const moved = await move(P2, G2, at("16:00")); + + expect(moved.clearedJudgeId).toBe(M1); + expect(written()[0]!.values).toMatchObject({ assignedJudgeId: null }); + }); + + it("keeps the judge when they can still grade it at the new time", async () => { + staffed(); + give(P2, M1); + + const moved = await move(P2, G2, at("15:40")); + + expect(moved.clearedJudgeId).toBeNull(); + expect("assignedJudgeId" in (written()[0]!.values as Row)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +describe("saving a schedule", () => { + const timing = { + windowStart: at("16:00"), + windowEnd: at("19:00"), + setupMinutes: 2, + judgingMinutes: 6, + teardownMinutes: 2, + sameBuildingBreakMinutes: 10, + crossBuildingBreakMinutes: 20, + }; + const rowsWritten = () => + inserts + .filter((write) => write.table === judgingAppointments) + .flatMap((write) => write.values as Row[]); + + beforeEach(() => { + // Nothing saved yet. + store.set(judgingSchedules, []); + store.set(judgingAppointments, []); + }); + + it("gives every presentation a judge as far as there are judges", async () => { + const preview = await admin().judging.schedule.preview({ + hackathonId: HACK, + timing, + }); + if (!preview.ok) throw new Error("the inventory fits the window"); + + const saved = await admin().judging.schedule.save({ + hackathonId: HACK, + timing, + candidateKey: preview.candidateKey, + }); + + expect(saved).toMatchObject({ appointments: 4, judged: 4 }); + const rows = rowsWritten(); + expect(rows).toHaveLength(4); + // Grace judges Acme's presentation; the three general ones are Ada's and Bea's. + const judgeOf = (challengeId: string) => + rows + .filter((r) => r.challengeId === challengeId) + .map((r) => r.assignedJudgeId); + expect(judgeOf(ACME)).toEqual([GS]); + expect(judgeOf(GENERAL).every((j) => j === M1 || j === M2)).toBe(true); + expect(audits()[0]).toMatchObject({ + action: "judging.schedule.save", + metadata: expect.objectContaining({ judged: 4 }), + }); + }); + + it("still saves when no judge has joined: the presentations have none yet", async () => { + store.set(judgingJudges, []); + const preview = await admin().judging.schedule.preview({ + hackathonId: HACK, + timing, + }); + if (!preview.ok) throw new Error("the inventory fits the window"); + + const saved = await admin().judging.schedule.save({ + hackathonId: HACK, + timing, + candidateKey: preview.candidateKey, + }); + + expect(saved).toMatchObject({ appointments: 4, judged: 0 }); + expect(rowsWritten().every((r) => r.assignedJudgeId === null)).toBe(true); + }); + + it("puts the judges in the CP-SAT problem, and saves the assignments CP-SAT chose", async () => { + const problem = await admin().judging.schedule.cpsatProblem({ + hackathonId: HACK, + timing, + }); + const file = JSON.parse(problem.contents); + expect(problem.judges).toBe(3); + expect(file.judges.map((j: Row) => j.id)).toEqual([M1, M2, GS]); + expect(file.judges.find((j: Row) => j.id === GS)).toMatchObject({ + kind: "guest", + roomId: A1, + challengeIds: [ACME], + }); + + // The in-app schedule with its judges, the two general judges swapped. + const preview = await admin().judging.schedule.preview({ + hackathonId: HACK, + timing, + }); + if (!preview.ok) throw new Error("the inventory fits the window"); + const placements = preview.appointments.map((a) => ({ + entryId: a.entryId, + challengeId: a.challengeId, + roomId: a.roomId, + slot: (a.startsAt.getTime() - timing.windowStart.getTime()) / 600_000, + })); + const inputs = { + rooms: file.rooms, + tasks: file.tasks, + slots: file.slotCount, + sameGap: file.sameGap, + crossGap: file.crossGap, + }; + const inApp = assignJudges(inputs, placements, file.judges).assignment; + const swapped = inApp.map((a) => ({ + ...a, + judgeId: a.judgeId === M1 ? M2 : a.judgeId === M2 ? M1 : a.judgeId, + })); + const solution = JSON.stringify({ + format: "query-judging-schedule-solution", + version: 1, + fingerprint: file.fingerprint, + status: "feasible", + relaxed: false, + placements, + assignments: swapped, + }); + + const checked = await admin().judging.schedule.previewCpsat({ + hackathonId: HACK, + timing, + solution, + }); + expect(checked.judging).toEqual({ + judges: 3, + presentations: 4, + unassigned: 0, + source: "cp-sat", + }); + + await admin().judging.schedule.saveCpsat({ + hackathonId: HACK, + timing, + solution, + candidateKey: checked.candidateKey, + }); + const chosen = new Map( + swapped.map((a) => [`${a.entryId}:${a.challengeId}`, a.judgeId]), + ); + for (const row of rowsWritten()) { + expect(row.assignedJudgeId).toBe( + chosen.get(`${row.entryId}:${row.challengeId}`), + ); + } + expect(audits()[0]).toMatchObject({ + metadata: expect.objectContaining({ solver: "cp-sat", judges: "cp-sat" }), + }); + }); +}); diff --git a/packages/api/src/middleware/cache.ts b/packages/api/src/middleware/cache.ts index 295926a7..8e9326ce 100644 --- a/packages/api/src/middleware/cache.ts +++ b/packages/api/src/middleware/cache.ts @@ -228,6 +228,10 @@ export const CacheKeys = { judge: (userId: string) => `judge:${userId}`, projectLeader: (userId: string) => `project-leader:${userId}`, portalContext: (userId: string) => `user:${userId}:portal`, + // judging.results.list's per-judge calibration; the caller adds what it was + // computed from, so a changed evaluation reads a new key. + judgingCalibration: (hackathonId: string) => + `judging:${hackathonId}:calibration`, } as const; export const invalidatePortalContext = (userId: string) => { diff --git a/packages/api/src/middleware/procedures.ts b/packages/api/src/middleware/procedures.ts index 99539cfe..db6ce2f7 100644 --- a/packages/api/src/middleware/procedures.ts +++ b/packages/api/src/middleware/procedures.ts @@ -1,17 +1,8 @@ import { TRPCError } from "@trpc/server"; -import { protectedProcedure } from "../trpc"; -import { - admins, - hackathonBans, - users, - judges, - judgingProjects, - judgeQueue, - projectLeaders, -} from "@query/db"; +import { protectedProcedure, textUploadProcedure } from "../trpc"; +import { admins, hackathonBans, users, projectLeaders } from "@query/db"; import { eq, and } from "drizzle-orm"; import { CacheKeys } from "./cache"; -import { resolveHackathonId } from "../services/portal-context"; import { isStaffRole, isBugTesterRole, @@ -101,7 +92,7 @@ const READ_ONLY_MESSAGE = // mutation behind this gate (and isSuperAdmin, built on it) refuses them // without each procedure having to remember to. Cached 60s per user to avoid // a round trip on every request. -export const isAdmin = protectedProcedure.use(async ({ ctx, next, type }) => { +const requireAdmin = async (ctx: Context, type: string) => { const admin = await loadAdminRow(ctx); const readOnly = !!admin && isBugTesterRole(admin.role); @@ -117,9 +108,23 @@ export const isAdmin = protectedProcedure.use(async ({ ctx, next, type }) => { throw new TRPCError({ code: "FORBIDDEN", message: READ_ONLY_MESSAGE }); } + return admin; +}; + +export const isAdmin = protectedProcedure.use(async ({ ctx, next, type }) => { + const admin = await requireAdmin(ctx, type); return next({ ctx: { ...ctx, admin } }); }); +// isAdmin for a file uploaded as text (a Devpost export): the same check, on +// a procedure that takes up to 12 MB and does not refuse markup in prose. +export const isAdminUpload = textUploadProcedure.use( + async ({ ctx, next, type }) => { + const admin = await requireAdmin(ctx, type); + return next({ ctx: { ...ctx, admin } }); + }, +); + // Super admin. Must be used after isAdmin. export const isSuperAdmin = isAdmin.use(async ({ ctx, next }) => { if (ctx.admin.role !== "super_admin") { @@ -184,93 +189,13 @@ export const isProjectLeader = protectedProcedure.use(async ({ ctx, next }) => { }); }); -// Verifies the caller is an active judge for a specific hackathon. Cached 60s -// per user per hackathon. -const UUID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -export const isJudge = protectedProcedure.use(async ({ ctx, next, getRawInput }) => { - const db = ctx.db as NonNullable; - - // Try to resolve hackathonId from rawInput - const rawInput = await getRawInput(); - let hackathonId: string | undefined; - if (rawInput && typeof rawInput === "object") { - const inputObj = rawInput as Record; - // This runs before the procedure's own schema, and every id below goes into - // a uuid column: a malformed one made Postgres throw, surfacing as a 500. - for (const key of ["hackathonId", "projectId", "queueId"]) { - const value = inputObj[key]; - if (typeof value === "string" && !UUID_PATTERN.test(value)) { - throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid ${key}` }); - } - } - if (typeof inputObj.hackathonId === "string") { - hackathonId = inputObj.hackathonId; - } else if (typeof inputObj.projectId === "string") { - const project = await db.query.judgingProjects.findFirst({ - where: eq(judgingProjects.id, inputObj.projectId), - columns: { hackathonId: true }, - }); - hackathonId = project?.hackathonId; - } else if (typeof inputObj.queueId === "string") { - const queue = await db.query.judgeQueue.findFirst({ - where: eq(judgeQueue.id, inputObj.queueId), - columns: { hackathonId: true }, - }); - hackathonId = queue?.hackathonId; - } - } - - // Fallback when the input names nothing that resolves. Shares the platform's - // single definition of "the current hackathon": ordering by start date alone - // picks next year's draft the day staff create it, which would authorize this - // judge against an edition they were never assigned to. - if (!hackathonId) { - hackathonId = await resolveHackathonId(db); - } - - if (!hackathonId) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "No hackathon context found for judging", - }); - } - - const cacheKey = `${CacheKeys.judge(ctx.userId as string)}:${hackathonId}:role`; - let judge = ctx.cache.get(cacheKey); - - if (!judge) { - judge = - (await db.query.judges.findFirst({ - where: and( - eq(judges.userId, ctx.userId as string), - eq(judges.hackathonId, hackathonId), - eq(judges.isActive, true), - ), - })) ?? null; - - if (judge) ctx.cache.set(cacheKey, judge, 60); - } - - if (!judge) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Judge access required for this hackathon", - }); - } - - return next({ ctx: { ...ctx, judge } }); - }, -); - /** Cache key for a hackathon-ban lookup. Shared with the ban/unban mutations. */ export const hackathonBanCacheKey = (email: string) => `hackathon-ban:${email.trim().toLowerCase()}`; // Refuses people banned from hackathons. Goes on the procedures where someone // takes part — registering, the interest list, teams, submitting, applying to -// judge — and not on reads or on the ways out (withdraw, leave), so a banned +// judge, event-code check-in — and not on reads or on the ways out (withdraw, leave), so a banned // person can still see and undo what they already did. The ban is by email, // so it holds across providers and new accounts on the same address. Cached // 60s, the negative answer too; ban and unban clear the key. diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 25b909f1..2f22b756 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -6,6 +6,8 @@ import { resumeRouter } from "./routers/resume"; import { hackathonRouter } from "./routers/hackathon"; import { eventRouter } from "./routers/events"; import { judgeRouter } from "./routers/judge"; +import { judgingRouter } from "./routers/judging"; +import { pointsRouter } from "./routers/points"; import { stripeRouter } from "./routers/stripe"; import { teamRouter } from "./routers/team"; import { initiativeRouter } from "./routers/initiative"; @@ -20,6 +22,8 @@ export const appRouter = createTRPCRouter({ hackathon: hackathonRouter, events: eventRouter, judge: judgeRouter, + judging: judgingRouter, + points: pointsRouter, stripe: stripeRouter, team: teamRouter, initiative: initiativeRouter, diff --git a/packages/api/src/routers/admin.ts b/packages/api/src/routers/admin.ts index 96de8cdb..9fa73f56 100644 --- a/packages/api/src/routers/admin.ts +++ b/packages/api/src/routers/admin.ts @@ -13,11 +13,38 @@ import { } from "@query/db"; import { eq, and, count, gte, inArray } from "drizzle-orm"; import { CacheKeys, invalidatePortalContext } from "../middleware/cache"; -import { isAdmin, isSuperAdmin } from "../middleware/procedures"; +import { isAdmin } from "../middleware/procedures"; +import { recordAdminAction } from "../middleware/audit"; import { compareTerms, currentTerm } from "@query/db/services/membership"; -import { isExpiredAdmin, isStaffRole } from "../types/portal-context"; +import { + isBugTesterRole, + isExpiredAdmin, + isStaffRole, +} from "../types/portal-context"; import type { DrizzleDB } from "@query/db"; import { startOfEasternDay } from "../services/eastern-time"; +import { readLiveHackathons } from "../services/metrics"; + +// Roles are a super admin's to give, with one exception: anyone on the admin +// team may staff the check-in desk by granting and revoking the volunteer tier, +// which scans badges and nothing else. Anything that would touch another role, +// or hand out permissions, stays behind the super admin. +const assertMayManage = ( + admin: { role: string }, + change: { roles: (string | undefined)[]; permissions?: unknown }, +) => { + if (admin.role === "super_admin") return; + if ( + change.permissions === undefined && + change.roles.every((role) => role === undefined || role === "volunteer") + ) { + return; + } + throw new TRPCError({ + code: "FORBIDDEN", + message: "Super admin access required", + }); +}; export const adminRouter = createTRPCRouter({ isAdmin: protectedProcedure.query(async ({ ctx }) => { @@ -78,6 +105,8 @@ export const adminRouter = createTRPCRouter({ totalEvents: number; totalHackathons: number; checkinsToday: number; + badgeScansToday: number; + doorCheckinsToday: number; }>( cacheKey, async () => { @@ -114,13 +143,19 @@ export const adminRouter = createTRPCRouter({ .where(gte(eventCheckIns.checkedInAt, startOfToday)), ]); + const badgeScansToday = badgeScansResult[0]?.count ?? 0; + const doorCheckinsToday = doorCheckinsResult[0]?.count ?? 0; + const result = { totalParticipants: participantsResult[0]?.count ?? 0, totalEvents: eventsResult[0]?.count ?? 0, totalHackathons: hackathonsResult[0]?.count ?? 0, - checkinsToday: - (badgeScansResult[0]?.count ?? 0) + - (doorCheckinsResult[0]?.count ?? 0), + // Kept as the sum so a caller that only knew the blended number + // still moves. The page reads the two halves, because a hackathon + // weekend and a club meeting are not the same turnout. + badgeScansToday, + doorCheckinsToday, + checkinsToday: badgeScansToday + doorCheckinsToday, }; return result; @@ -129,6 +164,16 @@ export const adminRouter = createTRPCRouter({ ); }), + // The same counts the Prometheus gauges use, for the analytics page. A + // scrape is optional; this is what staff open during the weekend. + hackathonLive: isAdmin.query(async ({ ctx }) => { + return await ctx.cache.getOrSet( + "admin:hackathon-live", + () => readLiveHackathons(ctx.db as DrizzleDB), + 15, + ); + }), + // Membership growth for the Insights page. One pass over the member table // instead of a bucket query per month: the club is a few thousand rows, and // the cumulative series has to see everything before the window anyway. @@ -281,9 +326,18 @@ export const adminRouter = createTRPCRouter({ // sign-in address. Exact rather than a prefix search: a partial match list is // how the wrong Alex ends up with scanner access, and it also means this // cannot enumerate the user table — it confirms an address already known. - findUserByEmail: isSuperAdmin + findUserByEmail: isAdmin .input(z.object({ email: z.string().trim().email().max(255) })) .query(async ({ ctx, input }) => { + // isAdmin lets a bug tester through for reads; this one confirms that an + // address has an account, which they have no business doing. + if (isBugTesterRole(ctx.admin.role)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Admin access required", + }); + } + const user = await (ctx.db as DrizzleDB).query.users.findFirst({ // Stored lowercased by every writer. where: eq(users.email, input.email.toLowerCase()), @@ -300,7 +354,7 @@ export const adminRouter = createTRPCRouter({ return { ...user, existingRole: existing ?? null }; }), - create: isSuperAdmin + create: isAdmin .input( z.object({ userId: z.string().min(1).max(255), @@ -322,6 +376,11 @@ export const adminRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { + assertMayManage(ctx.admin, { + roles: [input.role], + permissions: input.permissions, + }); + // Parallel check: user exists AND not already admin const [user, existingAdmin] = await Promise.all([ (ctx.db as DrizzleDB).query.users.findFirst({ @@ -367,10 +426,18 @@ export const adminRouter = createTRPCRouter({ ctx.cache.delete(`admins:list`); invalidatePortalContext(input.userId); + await recordAdminAction(ctx.db as DrizzleDB, { + userId: ctx.userId, + action: "admin.create", + resourceId: input.userId, + severity: "warn", + metadata: { role: input.role, grantedBy: ctx.admin.role }, + }); + return newAdmin; }), - update: isSuperAdmin + update: isAdmin .input( z.object({ adminId: z.string().uuid(), @@ -387,6 +454,12 @@ export const adminRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { + // A role this caller may not set is refused before any lookup. + assertMayManage(ctx.admin, { + roles: [input.role], + permissions: input.permissions, + }); + const targetAdmin = await (ctx.db as DrizzleDB).query.admins.findFirst({ where: eq(admins.id, input.adminId), }); @@ -394,6 +467,10 @@ export const adminRouter = createTRPCRouter({ if (!targetAdmin) { throw new TRPCError({ code: "NOT_FOUND", message: "Admin not found" }); } + // Both ends of the change count: the role being set above, and the role + // being held here. Without this a plain admin could "grant volunteer" to + // a super admin and demote them. + assertMayManage(ctx.admin, { roles: [targetAdmin.role] }); if (targetAdmin.userId === ctx.userId && input.isActive === false) { throw new TRPCError({ code: "BAD_REQUEST", @@ -453,10 +530,23 @@ export const adminRouter = createTRPCRouter({ ctx.cache.delete(`admins:list`); invalidatePortalContext(targetAdmin.userId); + await recordAdminAction(ctx.db as DrizzleDB, { + userId: ctx.userId, + action: "admin.update", + resourceId: targetAdmin.userId, + severity: "warn", + metadata: { + from: targetAdmin.role, + to: updatedAdmin.role, + isActive: updatedAdmin.isActive, + changedBy: ctx.admin.role, + }, + }); + return updatedAdmin; }), - remove: isSuperAdmin + remove: isAdmin .input(z.object({ adminId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const targetAdmin = await (ctx.db as DrizzleDB).query.admins.findFirst({ @@ -466,6 +556,7 @@ export const adminRouter = createTRPCRouter({ if (!targetAdmin) { throw new TRPCError({ code: "NOT_FOUND", message: "Admin not found" }); } + assertMayManage(ctx.admin, { roles: [targetAdmin.role] }); if (targetAdmin.userId === ctx.userId) { throw new TRPCError({ code: "BAD_REQUEST", @@ -481,6 +572,14 @@ export const adminRouter = createTRPCRouter({ ctx.cache.delete(`admins:list`); invalidatePortalContext(targetAdmin.userId); + await recordAdminAction(ctx.db as DrizzleDB, { + userId: ctx.userId, + action: "admin.remove", + resourceId: targetAdmin.userId, + severity: "warn", + metadata: { role: targetAdmin.role, removedBy: ctx.admin.role }, + }); + return { success: true }; }), }); diff --git a/packages/api/src/routers/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts index 0955a4c8..4737c9f8 100644 --- a/packages/api/src/routers/hackathon/admin.ts +++ b/packages/api/src/routers/hackathon/admin.ts @@ -6,8 +6,15 @@ import { import { TRPCError } from "@trpc/server"; import { createTRPCRouter } from "../../trpc"; import { isAdmin, isScanner } from "../../middleware/procedures"; -import { isUniqueViolation } from "../../middleware/db-errors"; import { recordAdminAction } from "../../middleware/audit"; +import { + alreadyCheckedInMessage, + fullName, + personName, + recordEventAttendance, + searchParticipants, +} from "../../services/points"; +import { flaggingRuleBreaks } from "../../services/check-in-flags"; import { CacheKeys } from "../../middleware/cache"; import { MASS_EMAIL_BATCH } from "../../services/email-limits"; import { @@ -15,9 +22,10 @@ import { hackathonParticipants, hackathonEvents, hackathonEventAttendees, + memberResumes, users, } from "@query/db"; -import { eq, and, inArray, ne, notInArray, sql } from "drizzle-orm"; +import { eq, and, inArray, isNull, ne, notInArray, sql } from "drizzle-orm"; import type { DrizzleDB } from "@query/db"; // A seat belongs to somebody accepted. maxParticipants caps acceptances, not @@ -132,6 +140,7 @@ const buildAttendeeWhere = (input: { hackathonId: string; search?: string; status?: z.infer; + unconfirmedOnly?: boolean; }) => { const clauses = [eq(hackathonParticipants.hackathonId, input.hackathonId)]; @@ -139,6 +148,15 @@ const buildAttendeeWhere = (input: { clauses.push(eq(hackathonParticipants.registrationStatus, input.status)); } + // Accepted and silent: the people whose seats are worth chasing before the + // event. Checked-in people are not "unconfirmed", they are here. + if (input.unconfirmedOnly) { + clauses.push( + eq(hackathonParticipants.registrationStatus, "approved"), + isNull(hackathonParticipants.attendanceConfirmedAt), + ); + } + const term = input.search?.trim(); if (term) { // Escaped so a literal % or _ in a name searches for that character instead @@ -163,6 +181,157 @@ const buildAttendeeWhere = (input: { return and(...clauses); }; +/** + * Checks one accepted participant into one event and, on their first arrival, + * into the hackathon. A pass scan and the desk's manual admit are this same + * write; they differ only in how the staffer knew who was in front of them, + * which is what the attendance row records as its method. + */ +async function checkInParticipant( + ctx: { + db: DrizzleDB; + cache: { delete: (key: string) => boolean }; + userId: string; + }, + input: { hackathonId: string; eventId: string; participantId: string }, + method: "scan" | "manual", +) { + // 1. Verify participant exists and belongs to this hackathon + const participant = await ctx.db.query.hackathonParticipants.findFirst({ + where: and( + eq(hackathonParticipants.id, input.participantId), + eq(hackathonParticipants.hackathonId, input.hackathonId), + ), + with: { user: true }, + }); + + if (!participant) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Participant not found or not registered for this hackathon.", + }); + } + + // 1b. Only accepted participants may check in. Pending and waitlisted are not + // yet admitted; rejected never are. + if ( + participant.registrationStatus !== "approved" && + participant.registrationStatus !== "checked_in" + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `${participant.user.name || participant.user.email} is not accepted for this hackathon (status: ${participant.registrationStatus}).`, + }); + } + + // 2. Verify event exists and belongs to this hackathon + const event = await ctx.db.query.hackathonEvents.findFirst({ + where: and( + eq(hackathonEvents.id, input.eventId), + eq(hackathonEvents.hackathonId, input.hackathonId), + ), + }); + + if (!event) { + throw new TRPCError({ code: "NOT_FOUND", message: "Event not found." }); + } + + // 2b. A pass is only good for the window the event runs in. Nothing else + // bounds a scan, so a scanner left on the wrong event would keep admitting + // people to a meal that finished hours ago. + if (event.endTime < new Date()) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `${event.name} has already ended.`, + }); + } + + // 3. Check for existing check-in to prevent duplicates + const existingScan = await ctx.db.query.hackathonEventAttendees.findFirst({ + where: and( + eq(hackathonEventAttendees.eventId, input.eventId), + eq(hackathonEventAttendees.participantId, input.participantId), + ), + }); + + const attendance = { + event, + participant: { + id: participant.id, + name: participant.user.name || participant.user.email, + }, + method, + actorId: ctx.userId, + }; + + if (existingScan) { + throw new TRPCError({ + code: "CONFLICT", + message: alreadyCheckedInMessage(attendance), + }); + } + + // 4. Record attendance. The unique index is the real guard, and + // recordEventAttendance turns a racing scanner's 23505 into the same + // conflict as the read above. It also refuses an event that overlaps, or + // comes too soon after, one this person is already in; that refusal ends the + // scan before the promotion below. + // Attendance and the roster promotion are one transaction. Split, a promotion + // failing after the attendance row committed left the scan half-done — and + // the retry hits the duplicate guard, so that attendee could never submit. + // Rolling the attendance back makes a rescan the fix. + let promotedToCheckedIn = false; + + const attendAndPromote = () => ctx.db.transaction(async (tx) => { + await recordEventAttendance(tx, attendance); + + // The first scan is the check-in. `checked_in` previously had no writer + // anywhere, which was harmless while it was only a label — but submitting now + // requires it, so nobody could submit and the error told them to do what they + // had just done. `registrationStatus = 'approved'` is in the WHERE, not only + // the read: a rejection landing between read and write would otherwise be + // overwritten. `checkedInAt` is stamped once, so it points at arrival. + const [promoted] = await tx + .update(hackathonParticipants) + .set({ + registrationStatus: "checked_in", + ...(participant.checkedInAt ? {} : { checkedInAt: new Date() }), + updatedAt: new Date(), + }) + .where( + and( + eq(hackathonParticipants.id, input.participantId), + eq(hackathonParticipants.registrationStatus, "approved"), + ), + ) + .returning({ id: hackathonParticipants.id }); + + promotedToCheckedIn = !!promoted; + }); + + // A refusal for the check-in rules rolls that transaction back, so the flag + // for organisers is written here, on the pool, once it has. + await flaggingRuleBreaks(ctx.db, input.hackathonId, attendAndPromote); + + // A scan changes one event's attendee count and, on the first one, this + // attendee's roster row. This runs at every door all weekend, so eviction + // stays narrow — the old blanket pattern wiped every cached registration. + ctx.cache.delete(`hackathon:${input.hackathonId}:events`); + + if (promotedToCheckedIn) { + evictParticipantCaches(ctx.cache, input.hackathonId, [participant.userId]); + } + + return { + success: true, + checkedIn: promotedToCheckedIn, + pointsAwarded: event.points, + eventName: event.name, + name: participant.user.name || participant.user.email, + message: `Successfully checked in ${participant.user.name || participant.user.email}!`, + }; +} + export const hackathonAdminRouter = createTRPCRouter({ adminGetAttendees: isAdmin .input( @@ -172,6 +341,7 @@ export const hackathonAdminRouter = createTRPCRouter({ offset: z.number().int().min(0).default(0), search: z.string().trim().max(200).optional(), status: PARTICIPANT_STATUSES.optional(), + unconfirmedOnly: z.boolean().optional(), }), ) .query(async ({ ctx, input }) => { @@ -204,8 +374,25 @@ export const hackathonAdminRouter = createTRPCRouter({ .where(where), ]); + // A resume uploaded to the portal lives in member_resume, not on the + // application row, so the row's own resumeUrl is empty for those people. + // Flagged here so the roster can link /api/resume/ without + // guessing; only this page's users, never the whole table. + const userIds = rows.map((row) => row.userId); + const stored = + userIds.length === 0 + ? [] + : await db + .select({ userId: memberResumes.userId }) + .from(memberResumes) + .where(inArray(memberResumes.userId, userIds)); + const hasResume = new Set(stored.map((row) => row.userId)); + return { - attendees: rows, + attendees: rows.map((row) => ({ + ...row, + hasStoredResume: hasResume.has(row.userId), + })), // How many match the current filter, so the pager knows where it ends. Not // the unfiltered total — conflating them makes the last page unreachable. matching: totals?.count ?? 0, @@ -225,6 +412,7 @@ export const hackathonAdminRouter = createTRPCRouter({ hackathonId: z.string().uuid("Invalid hackathon ID"), search: z.string().trim().max(200).optional(), status: PARTICIPANT_STATUSES.optional(), + unconfirmedOnly: z.boolean().optional(), }), ) .query(async ({ ctx, input }) => { @@ -248,6 +436,7 @@ export const hackathonAdminRouter = createTRPCRouter({ hackathonId: z.string().uuid("Invalid hackathon ID"), search: z.string().trim().max(200).optional(), status: PARTICIPANT_STATUSES.optional(), + unconfirmedOnly: z.boolean().optional(), }), ) .query(async ({ ctx, input }) => { @@ -960,7 +1149,10 @@ export const hackathonAdminRouter = createTRPCRouter({ eq(hackathonEventAttendees.participantId, input.participantId), ), ) - .returning({ id: hackathonEventAttendees.id }); + .returning({ + id: hackathonEventAttendees.id, + pointsAwarded: hackathonEventAttendees.pointsAwarded, + }); if (deleted.length === 0) { throw new TRPCError({ @@ -979,6 +1171,8 @@ export const hackathonAdminRouter = createTRPCRouter({ metadata: { eventId: input.eventId, hackathonId: input.hackathonId, + // The row carried the points, so undoing it takes them back. + pointsRemoved: deleted[0]?.pointsAwarded ?? 0, }, }); @@ -988,6 +1182,26 @@ export const hackathonAdminRouter = createTRPCRouter({ }), scanParticipantPass: isScanner + .input( + z.object({ + hackathonId: z.string().uuid("Invalid hackathon ID"), + eventId: z.string().uuid("Invalid event ID"), + participantId: z.string().uuid("Invalid participant ID"), + }), + ) + .mutation(({ ctx, input }) => + checkInParticipant( + { db: ctx.db as DrizzleDB, cache: ctx.cache, userId: ctx.userId }, + input, + "scan", + ), + ), + + // The desk for a hacker with no phone: staff look at a photo ID, find the + // person by name and admit them. The same check-in as a pass scan, recorded + // as a manual one, with a trail of who vouched for whom since no pass was + // read. + admit: isScanner .input( z.object({ hackathonId: z.string().uuid("Invalid hackathon ID"), @@ -996,145 +1210,173 @@ export const hackathonAdminRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - // 1. Verify participant exists and belongs to this hackathon - const participant = await ( - ctx.db as DrizzleDB - ).query.hackathonParticipants.findFirst({ + const db = ctx.db as DrizzleDB; + + const result = await checkInParticipant( + { db, cache: ctx.cache, userId: ctx.userId }, + input, + "manual", + ); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "hackathon.admit", + resourceId: input.participantId, + metadata: { + eventId: input.eventId, + hackathonId: input.hackathonId, + checkedIn: result.checkedIn, + }, + }); + + return result; + }), + + // The station without a camera: staff type part of a name or email. Each + // match says whether that person is already in this event, so the list + // answers "did I get them?" without a second lookup. + searchEventCheckIn: isScanner + .input( + z.object({ + hackathonId: z.string().uuid("Invalid hackathon ID"), + eventId: z.string().uuid("Invalid event ID"), + query: z.string().trim().min(1).max(120), + }), + ) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const event = await db.query.hackathonEvents.findFirst({ where: and( - eq(hackathonParticipants.id, input.participantId), - eq(hackathonParticipants.hackathonId, input.hackathonId), + eq(hackathonEvents.id, input.eventId), + eq(hackathonEvents.hackathonId, input.hackathonId), ), - with: { user: true }, + columns: { id: true }, }); - if (!participant) { - throw new TRPCError({ - code: "NOT_FOUND", - message: - "Participant not found or not registered for this hackathon.", - }); + if (!event) { + throw new TRPCError({ code: "NOT_FOUND", message: "Event not found." }); } - // 1b. Only accepted participants may check in. Pending and waitlisted are not - // yet admitted; rejected never are. - if ( - participant.registrationStatus !== "approved" && - participant.registrationStatus !== "checked_in" - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `${participant.user.name || participant.user.email} is not accepted for this hackathon (status: ${participant.registrationStatus}).`, - }); - } + const matches = await searchParticipants(db, { + hackathonId: input.hackathonId, + query: input.query, + limit: 20, + }); + + const attended = + matches.length === 0 + ? [] + : await db + .select({ participantId: hackathonEventAttendees.participantId }) + .from(hackathonEventAttendees) + .where( + and( + eq(hackathonEventAttendees.eventId, input.eventId), + inArray( + hackathonEventAttendees.participantId, + matches.map((match) => match.id), + ), + ), + ); + const inEvent = new Set(attended.map((row) => row.participantId)); + + return matches.map((match) => ({ + ...match, + checkedIntoEvent: inEvent.has(match.id), + })); + }), - // 2. Verify event exists and belongs to this hackathon - const event = await (ctx.db as DrizzleDB).query.hackathonEvents.findFirst( - { + // Checks one person in by name. Unlike the pass scan it never admits anyone + // to the hackathon: arrival happens at the registration desk, and a name in + // a search box is not proof somebody is in the building. + manualEventCheckIn: isScanner + .input( + z.object({ + hackathonId: z.string().uuid("Invalid hackathon ID"), + eventId: z.string().uuid("Invalid event ID"), + participantId: z.string().uuid("Invalid participant ID"), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const [participant, event] = await Promise.all([ + db.query.hackathonParticipants.findFirst({ + where: and( + eq(hackathonParticipants.id, input.participantId), + eq(hackathonParticipants.hackathonId, input.hackathonId), + ), + columns: { + id: true, + firstName: true, + lastName: true, + registrationStatus: true, + }, + with: { user: { columns: { name: true, email: true } } }, + }), + db.query.hackathonEvents.findFirst({ where: and( eq(hackathonEvents.id, input.eventId), eq(hackathonEvents.hackathonId, input.hackathonId), ), - }, - ); + columns: { + id: true, + name: true, + type: true, + points: true, + startTime: true, + endTime: true, + }, + }), + ]); + + if (!participant) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That person is not registered for this hackathon.", + }); + } if (!event) { throw new TRPCError({ code: "NOT_FOUND", message: "Event not found." }); } - // 2b. A pass is only good for the window the event runs in. Nothing else - // bounds a scan, so a scanner left on the wrong event would keep admitting - // people to a meal that finished hours ago. - if (event.endTime < new Date()) { + const name = fullName( + personName({ ...participant, accountName: participant.user.name }), + participant.user.email, + ); + + if (participant.registrationStatus !== "checked_in") { throw new TRPCError({ code: "FORBIDDEN", - message: `${event.name} has already ended.`, + message: `${name} has not checked in at the registration desk yet. Send them there first.`, }); } - // 3. Check for existing check-in to prevent duplicates - const existingScan = await ( - ctx.db as DrizzleDB - ).query.hackathonEventAttendees.findFirst({ - where: and( - eq(hackathonEventAttendees.eventId, input.eventId), - eq(hackathonEventAttendees.participantId, input.participantId), - ), - }); - - const alreadyCheckedIn = `${participant.user.name || participant.user.email} is already checked into ${event.name}.`; - - if (existingScan) { + // Same bound as the pass scan, for the same reason: a station left on + // a finished event keeps handing out its points. + if (event.endTime < new Date()) { throw new TRPCError({ - code: "CONFLICT", - message: alreadyCheckedIn, + code: "FORBIDDEN", + message: `${event.name} has already ended. Credit somebody who was there with a points adjustment.`, }); } - // 4. Record attendance. The unique index event_attendee_event_participant_idx - // is the real guard: - // two scanners can both pass the read above, so the loser's 23505 has to read - // as the same conflict, not a raw INTERNAL_SERVER_ERROR. - // Attendance and the roster promotion are one transaction. Split, a promotion - // failing after the attendance row committed left the scan half-done — and - // the retry hits the duplicate guard, so that attendee could never submit. - // Rolling the attendance back makes a rescan the fix. - let promotedToCheckedIn = false; - - await (ctx.db as DrizzleDB).transaction(async (tx) => { - try { - await tx.insert(hackathonEventAttendees).values({ - eventId: input.eventId, - participantId: input.participantId, - }); - } catch (error: unknown) { - if (isUniqueViolation(error)) { - throw new TRPCError({ - code: "CONFLICT", - message: alreadyCheckedIn, - }); - } - throw error; - } - - // The first scan is the check-in. `checked_in` previously had no writer - // anywhere, which was harmless while it was only a label — but submitting now - // requires it, so nobody could submit and the error told them to do what they - // had just done. `registrationStatus = 'approved'` is in the WHERE, not only - // the read: a rejection landing between read and write would otherwise be - // overwritten. `checkedInAt` is stamped once, so it points at arrival. - const [promoted] = await tx - .update(hackathonParticipants) - .set({ - registrationStatus: "checked_in", - ...(participant.checkedInAt ? {} : { checkedInAt: new Date() }), - updatedAt: new Date(), - }) - .where( - and( - eq(hackathonParticipants.id, input.participantId), - eq(hackathonParticipants.registrationStatus, "approved"), - ), - ) - .returning({ id: hackathonParticipants.id }); - - promotedToCheckedIn = !!promoted; - }); + const { pointsAwarded } = await flaggingRuleBreaks( + db, + input.hackathonId, + () => + recordEventAttendance(db, { + event, + participant: { id: participant.id, name }, + method: "manual", + actorId: ctx.userId, + }), + ); - // A scan changes one event's attendee count and, on the first one, this - // attendee's roster row. This runs at every door all weekend, so eviction - // stays narrow — the old blanket pattern wiped every cached registration. ctx.cache.delete(`hackathon:${input.hackathonId}:events`); - if (promotedToCheckedIn) { - evictParticipantCaches(ctx.cache, input.hackathonId, [ - participant.userId, - ]); - } - - return { - success: true, - checkedIn: promotedToCheckedIn, - message: `Successfully checked in ${participant.user.name || participant.user.email}!`, - }; + return { name, eventName: event.name, pointsAwarded }; }), }); diff --git a/packages/api/src/routers/hackathon/announce.ts b/packages/api/src/routers/hackathon/announce.ts index 783a9473..a37feafa 100644 --- a/packages/api/src/routers/hackathon/announce.ts +++ b/packages/api/src/routers/hackathon/announce.ts @@ -25,8 +25,9 @@ import { users, } from "@query/db"; import type { DrizzleDB } from "@query/db"; -import { createTRPCRouter } from "../../trpc"; +import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { isAdmin } from "../../middleware/procedures"; +import { rateLimit } from "../../middleware/security"; // Mass announcements: "registration is open", "results are up". Separate from // sendMassAcceptanceEmails because an acceptance also changes a participant's @@ -44,20 +45,63 @@ const MAX_RECIPIENTS_PER_CALL = 500; // short enough that a request killed by a deploy does not strand rows. const CLAIM_TIMEOUT_MS = 15 * 60 * 1000; -// `not_accepted` is its own audience and deliberately last. Rejected and -// waitlisted applicants are excluded everywhere else — a "see you this -// weekend" to somebody turned down is worse than no email — but excluding -// them everywhere left silence as the only outcome. Chosen on purpose. +// `not_accepted`, `waitlisted` and `rejected` are their own audiences and +// deliberately last. Rejected and waitlisted applicants are excluded everywhere +// else — a "see you this weekend" to somebody turned down is worse than no +// email — but excluding them everywhere left silence as the only outcome. +// Chosen on purpose. The two narrow ones exist because the two groups need +// different messages: "we have no place for you" is wrong for someone still +// waiting on a seat. const AUDIENCES = [ "interested", "registered", "approved", "checked_in", "not_accepted", + "waitlisted", + "rejected", ] as const; type Audience = (typeof AUDIENCES)[number]; +// How many announcements listForMe returns to a hacker's event page. +const LATEST_FOR_ME = 3; + +// The message itself, shared by createAnnouncement and sendTestToMe so a test +// goes through exactly the validation the real send does. +const messageInput = z.object({ + subject: z.string().trim().min(1).max(200), + heading: z.string().trim().min(1).max(200), + body: z.string().trim().min(1).max(5000), + ctaLabel: z.string().trim().max(60).optional(), + // Also rendered as a link on hackers' event pages, which only link a web + // address. `.url()` alone accepts data: and mailto: too. + ctaUrl: z + .string() + .url() + .max(500) + .refine((value) => { + try { + const protocol = new URL(value).protocol; + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } + }, "The button link must start with http:// or https://") + .optional(), +}); + +// A CTA label without a target renders a dead button, and a target without a +// label renders nothing — both only visible once it is in someone's inbox. +const assertCompleteCta = (input: { ctaLabel?: string; ctaUrl?: string }) => { + if (!!input.ctaLabel !== !!input.ctaUrl) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "A button needs both a label and a link, or neither.", + }); + } +}; + // Everyone in the chosen audience as `{ userId, email }`. Email comes from // the users table rather than the interest or participant row, so it is the // address they actually use; it is then copied onto the recipient row, @@ -83,7 +127,8 @@ const resolveAudience = async ( // "registered" is everyone holding a seat, at any stage. Rejected and // waitlisted are excluded from it and the two narrower audiences; reaching - // them is what "not_accepted" is for, and it has to be picked deliberately. + // them is what "not_accepted" (both), "waitlisted" and "rejected" are for, + // and one has to be picked deliberately. const statuses = audience === "registered" ? (["pending", "approved", "checked_in"] as const) @@ -126,20 +171,28 @@ export const hackathonAnnounceRouter = createTRPCRouter({ // Announcements for this edition and how far each got, so a reopened tab can // pick an unfinished send back up instead of starting a duplicate. + // + // `sentAt` alone does not mean "emailed": an in-app post is born with it + // stamped. Only sendBatch sets `claimedAt`, so `sent` counts rows with both, + // and `inAppOnly` is an announcement whose every row is stamped but never + // claimed. `pending` is counted directly, not total - sent - failed, which + // would leave in-app rows looking like an unfinished send. listAnnouncements: isAdmin .input(z.object({ hackathonId: z.string().uuid() })) .query(async ({ ctx, input }) => { const db = ctx.db as DrizzleDB; - const rows = await db + return await db .select({ id: hackathonAnnouncements.id, subject: hackathonAnnouncements.subject, audience: hackathonAnnouncements.audience, createdAt: hackathonAnnouncements.createdAt, total: sql`count(${hackathonAnnouncementRecipients.id})::int`, - sent: sql`count(${hackathonAnnouncementRecipients.sentAt})::int`, + sent: sql`count(${hackathonAnnouncementRecipients.id}) filter (where ${hackathonAnnouncementRecipients.sentAt} is not null and ${hackathonAnnouncementRecipients.claimedAt} is not null)::int`, failed: sql`count(${hackathonAnnouncementRecipients.failedAt})::int`, + pending: sql`count(${hackathonAnnouncementRecipients.id}) filter (where ${hackathonAnnouncementRecipients.sentAt} is null and ${hackathonAnnouncementRecipients.failedAt} is null)::int`, + inAppOnly: sql`count(${hackathonAnnouncementRecipients.id}) > 0 and count(${hackathonAnnouncementRecipients.id}) filter (where ${hackathonAnnouncementRecipients.sentAt} is not null and ${hackathonAnnouncementRecipients.claimedAt} is null) = count(${hackathonAnnouncementRecipients.id})`, }) .from(hackathonAnnouncements) .leftJoin( @@ -153,11 +206,6 @@ export const hackathonAnnounceRouter = createTRPCRouter({ .groupBy(hackathonAnnouncements.id) .orderBy(desc(hackathonAnnouncements.createdAt)) .limit(50); - - return rows.map((row) => ({ - ...row, - pending: row.total - row.sent - row.failed, - })); }), // Freezes a message and its audience. Sends nothing. The recipient rows are @@ -165,14 +213,12 @@ export const hackathonAnnounceRouter = createTRPCRouter({ // that never ran, a closed tab and a second click converge on the same set. createAnnouncement: isAdmin .input( - z.object({ + messageInput.extend({ hackathonId: z.string().uuid(), audience: z.enum(AUDIENCES), - subject: z.string().trim().min(1).max(200), - heading: z.string().trim().min(1).max(200), - body: z.string().trim().min(1).max(5000), - ctaLabel: z.string().trim().max(60).optional(), - ctaUrl: z.string().url().max(500).optional(), + // Published to the recipients' event pages and not emailed. The rows are + // written already marked delivered, so sendBatch has nothing to claim. + inAppOnly: z.boolean().optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -190,14 +236,7 @@ export const hackathonAnnounceRouter = createTRPCRouter({ }); } - // A CTA label without a target renders a dead button, and a target without a - // label renders nothing — both only visible once it is in someone's inbox. - if (!!input.ctaLabel !== !!input.ctaUrl) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "A button needs both a label and a link, or neither.", - }); - } + assertCompleteCta(input); const all = await resolveAudience(db, input.hackathonId, input.audience); @@ -238,12 +277,20 @@ export const hackathonAnnounceRouter = createTRPCRouter({ }); } + // In-app only has no send step to resume. A recipient left without `sentAt` + // would show as an unfinished send, and "Resume sending" would then email + // everybody; stamping it here means `sentAt` on these rows reads "delivered + // to the event page", not "emailed". `claimedAt` stays null, and sendBatch + // claims every row it emails, so the two remain distinguishable. + const deliveredAt = input.inAppOnly ? new Date() : null; + for (let i = 0; i < recipients.length; i += 1000) { await db.insert(hackathonAnnouncementRecipients).values( recipients.slice(i, i + 1000).map((row) => ({ announcementId: announcement.id, userId: row.userId, email: row.email as string, + ...(deliveredAt ? { sentAt: deliveredAt } : {}), })), ); } @@ -251,9 +298,117 @@ export const hackathonAnnounceRouter = createTRPCRouter({ return { announcementId: announcement.id, totalRecipients: recipients.length, + inAppOnly: !!input.inAppOnly, }; }), + // The composed message, to the caller's own address only. No announcement and + // no recipient rows are written, so a test leaves nothing for sendBatch to + // find and cannot be mistaken for a send. Same send function and same + // validation as the real thing. + sendTestToMe: isAdmin.input(messageInput).mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + assertCompleteCta(input); + + // Every test is a real SMTP message. A burst of three covers fixing a typo and + // trying again; after that, one every ten seconds. + const limit = rateLimit(`announcement-test:${ctx.userId}`, 3, 0.1); + if (!limit.allowed) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + // Not `retryAfter`: that is the limiter's backoff, not when the next + // token refills, so it would say "1 seconds" and then refuse again. + message: "Too many test emails. Try again in a few seconds.", + }); + } + + const me = await db.query.users.findFirst({ + where: eq(users.id, ctx.userId as string), + columns: { email: true }, + }); + + if (!me?.email) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Your account has no email address to send a test to.", + }); + } + + const { sendAnnouncementEmail } = await import("@query/auth/email"); + + try { + await sendAnnouncementEmail({ + email: me.email, + // Marked so a test is never mistaken for the real one in an inbox. + subject: `[Test] ${input.subject}`, + heading: input.heading, + body: input.body, + ctaLabel: input.ctaLabel, + ctaUrl: input.ctaUrl, + }); + } catch (error) { + // Deliberate operational logging, as in sendBatch. + // eslint-disable-next-line no-console + console.error("[Email Service] Announcement test failed:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "The test email could not be sent.", + }); + } + + return { sentTo: me.email }; + }), + + // The latest announcements addressed to the caller, for their event page. + // Read off the recipient rows written at compose time: the audience was frozen + // then and is not recomputed, so somebody who registered afterwards does not + // see a message that was never sent to them. Only the message comes back, not + // the audience, other recipients or send state. + listForMe: protectedProcedure + .input(z.object({ hackathonId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const rows = await db + .select({ + subject: hackathonAnnouncements.subject, + heading: hackathonAnnouncements.heading, + body: hackathonAnnouncements.body, + ctaLabel: hackathonAnnouncements.ctaLabel, + ctaUrl: hackathonAnnouncements.ctaUrl, + createdAt: hackathonAnnouncements.createdAt, + deliveredAt: hackathonAnnouncementRecipients.sentAt, + }) + .from(hackathonAnnouncements) + .innerJoin( + hackathonAnnouncementRecipients, + and( + eq( + hackathonAnnouncementRecipients.announcementId, + hackathonAnnouncements.id, + ), + eq(hackathonAnnouncementRecipients.userId, ctx.userId), + ), + ) + .where(eq(hackathonAnnouncements.hackathonId, input.hackathonId)) + .orderBy(desc(hackathonAnnouncements.createdAt)) + .limit(LATEST_FOR_ME); + + return rows.map((row) => ({ + subject: row.subject, + heading: row.heading, + body: row.body, + cta: + row.ctaLabel && row.ctaUrl + ? { label: row.ctaLabel, url: row.ctaUrl } + : null, + // Not yet emailed to this person, or the address bounced: the message is + // still theirs, dated from when it was composed. + sentAt: row.deliveredAt ?? row.createdAt, + })); + }), + // Sends the next batch. Call until `done`. Each recipient is marked the // moment their send returns, so nothing depends on the caller keeping count — // the client's offset arithmetic used to be the only thing between a dropped diff --git a/packages/api/src/routers/hackathon/content.ts b/packages/api/src/routers/hackathon/content.ts index 53b14158..e5cee72c 100644 --- a/packages/api/src/routers/hackathon/content.ts +++ b/packages/api/src/routers/hackathon/content.ts @@ -5,10 +5,10 @@ import { hackathonParticipants, hackathonProjects, hackathonResults, - judgeQueue, - judgingProjects, + judgingEntries, + judgingPlacements, } from "@query/db"; -import { eq, and, inArray, isNotNull } from "drizzle-orm"; +import { eq, and, inArray, isNotNull, isNull } from "drizzle-orm"; import { callerIsAdmin, isAdmin } from "../../middleware/procedures"; import { recordAdminAction } from "../../middleware/audit"; import { assertHackathonVisible } from "./visibility"; @@ -19,6 +19,25 @@ import type { DrizzleDB } from "@query/db"; const PUBLIC_PROJECT_STATUSES: (typeof hackathonProjects.$inferSelect)["status"][] = ["submitted", "judging", "winner"]; +/** One published placing, from either judging system, in the shape the results tab reads. */ +export type PublicResult = { + id: string; + /** "overall", a legacy track label, or a judging_challenge id. */ + scope: string; + scopeLabel: string; + placement: number; + projectName: string; + teamName: string | null; + githubUrl: string | null; + demoUrl: string | null; + /** The Devpost page, for projects imported from Devpost. */ + submissionUrl: string | null; + score: number | null; +}; + +const toScore = (value: string | null) => + value === null || Number.isNaN(Number(value)) ? null : Number(value); + export const hackathonContentRouter = createTRPCRouter({ // Fixes a submitted project on a team's behalf. team.submitProject refuses // every edit once the window closes and withdrawProject tells participants to @@ -93,54 +112,57 @@ export const hackathonContentRouter = createTRPCRouter({ }); } - if (existing.status === "judging" && !input.force) { + // In judging either by status or because it was promoted into a judging + // entry that is still live. + const liveEntry = await db.query.judgingEntries.findFirst({ + where: and( + eq(judgingEntries.sourceProjectId, input.projectId), + isNull(judgingEntries.deletedAt), + ), + columns: { id: true }, + }); + const inJudging = existing.status === "judging" || !!liveEntry; + + if (inJudging && !input.force) { throw new TRPCError({ code: "CONFLICT", message: - "Judges are already scoring this project. Withdrawing it removes it from the results — confirm to continue.", + "This project is in judging. Withdrawing it removes it from judging and the results — confirm to continue.", }); } - await db - .update(hackathonProjects) - .set({ - status: "draft", - submittedAt: null, - withdrawnByAdminAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(hackathonProjects.id, input.projectId)); - - // The judging entry has to go with it, or the CONFLICT message above is a - // lie: judges keep being routed to the table, the votes keep counting, and - // the project can still be published as a placing. - const pulled = await db - .update(judgingProjects) - .set({ withdrawnAt: new Date() }) - .where(eq(judgingProjects.sourceProjectId, input.projectId)) - .returning({ id: judgingProjects.id }); - - // Unscored slots go too. Every queue read (next table, vote, complete and - // next) otherwise kept sending judges to a table scan-to-start refuses. - // Completed slots stay: their votes are the record. - if (pulled.length > 0) { - await db.delete(judgeQueue).where( - and( - inArray( - judgeQueue.projectId, - pulled.map((row) => row.id), + await db.transaction(async (tx) => { + const now = new Date(); + await tx + .update(hackathonProjects) + .set({ + status: "draft", + submittedAt: null, + withdrawnByAdminAt: now, + updatedAt: now, + }) + .where(eq(hackathonProjects.id, input.projectId)); + + // The judging entry goes with it, or judges keep seeing the project and + // it can still be published as a placing. Soft delete: evaluations + // already written keep pointing at it. + await tx + .update(judgingEntries) + .set({ deletedAt: now, deletedById: ctx.userId, updatedAt: now }) + .where( + and( + eq(judgingEntries.sourceProjectId, input.projectId), + isNull(judgingEntries.deletedAt), ), - eq(judgeQueue.isCompleted, false), - ), - ); - } + ); + }); await recordAdminAction(db, { userId: ctx.userId, action: "hackathon.adminWithdrawProject", resourceId: input.projectId, - // Pulling a project judges are actively scoring changes the results. - severity: existing.status === "judging" ? "critical" : "warn", + // Pulling a project that is in judging changes the results. + severity: inJudging ? "critical" : "warn", metadata: { hackathonId: existing.hackathonId, previousStatus: existing.status, @@ -152,22 +174,28 @@ export const hackathonContentRouter = createTRPCRouter({ ctx.cache.deletePattern( `hackathon:${existing.hackathonId}:public-projects*`, ); + ctx.cache.delete(`hackathon:${existing.hackathonId}:results`); return { success: true }; }), - // The published placings, for everyone. Reads only rows with publishedAt set, - // so a computed-but-unreviewed draft stays invisible until an organiser - // releases it, and unpublishing takes it back down. + // The published placings, for everyone: frozen rows from the retired + // pool-dispatch judging (past editions) and placements from room judging. + // Reads only published rows, so a computed-but-unreviewed draft stays + // invisible until an organiser releases it, and unpublishing takes it back + // down. Overall first, then each scope by label, then by placing. getResults: publicProcedure .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") })) .query(async ({ ctx, input }) => { await assertHackathonVisible(ctx, input.hackathonId); const cacheKey = `hackathon:${input.hackathonId}:results`; + const cached = ctx.cache.get(cacheKey); + if (cached !== null) return cached; - const fetchResults = () => - (ctx.db as DrizzleDB).query.hackathonResults.findMany({ + const db = ctx.db as DrizzleDB; + const [legacy, placements] = await Promise.all([ + db.query.hackathonResults.findMany({ where: and( eq(hackathonResults.hackathonId, input.hackathonId), isNotNull(hackathonResults.publishedAt), @@ -181,14 +209,82 @@ export const hackathonContentRouter = createTRPCRouter({ with: { team: { columns: { id: true, name: true } } }, }, }, - orderBy: (results, { asc }) => [asc(results.placement)], - }); - - const cached = - ctx.cache.get>>(cacheKey); - if (cached !== null) return cached; + }), + db.query.judgingPlacements.findMany({ + where: and( + eq(judgingPlacements.hackathonId, input.hackathonId), + isNotNull(judgingPlacements.publishedAt), + ), + with: { + entry: { + columns: { + id: true, + title: true, + submissionUrl: true, + demoLinks: true, + deletedAt: true, + prizeIneligibleAt: true, + }, + with: { + sourceProject: { + columns: { id: true, githubUrl: true, demoUrl: true }, + with: { team: { columns: { id: true, name: true } } }, + }, + }, + }, + }, + }), + ]); + + const results: PublicResult[] = [ + ...legacy.map((row) => ({ + id: row.id, + scope: row.track, + scopeLabel: row.track === "overall" ? "Overall" : row.track, + placement: row.placement, + projectName: + row.sourceProject?.name ?? row.project?.name ?? "Unknown project", + teamName: + row.sourceProject?.team?.name ?? row.project?.teamMembers ?? null, + githubUrl: row.sourceProject?.githubUrl ?? null, + demoUrl: row.sourceProject?.demoUrl ?? null, + submissionUrl: null, + score: toScore(row.weightedScore), + })), + // A project an organiser pulled after placings went out leaves the + // public list at once, not at the next recompute. So does a team + // pulled from prizes. Only the timestamp is read, to filter on; why it + // was pulled never reaches this cached, public payload. + ...placements + .filter( + (row) => + row.entry && !row.entry.deletedAt && !row.entry.prizeIneligibleAt, + ) + .map((row) => ({ + id: row.id, + scope: row.scope, + scopeLabel: row.scopeLabel, + placement: row.placement, + projectName: row.entry?.title ?? "Unknown project", + teamName: row.entry?.sourceProject?.team?.name ?? null, + githubUrl: row.entry?.sourceProject?.githubUrl ?? null, + demoUrl: + row.entry?.sourceProject?.demoUrl ?? + row.entry?.demoLinks[0] ?? + null, + submissionUrl: row.entry?.submissionUrl ?? null, + score: toScore(row.score), + })), + ]; + + results.sort( + (a, b) => + Number(b.scope === "overall") - Number(a.scope === "overall") || + a.scopeLabel.localeCompare(b.scopeLabel) || + a.placement - b.placement || + a.projectName.localeCompare(b.projectName), + ); - const results = await fetchResults(); ctx.cache.set(cacheKey, results, 60); return results; }), diff --git a/packages/api/src/routers/hackathon/crud.ts b/packages/api/src/routers/hackathon/crud.ts index 63f7124f..5b5c02e1 100644 --- a/packages/api/src/routers/hackathon/crud.ts +++ b/packages/api/src/routers/hackathon/crud.ts @@ -330,9 +330,17 @@ export const hackathonCrudRouter = createTRPCRouter({ location: z.string().max(500).optional(), startDate: z.date().optional(), endDate: z.date().optional(), - registrationDeadline: z.date().optional(), + // Nullable as well as optional, like hackingStartTime: `null` clears the + // deadline or the cap, so an organiser can go back to "no limit". + registrationDeadline: z.date().nullable().optional(), hackingStartTime: z.date().nullable().optional(), - maxParticipants: z.number().int().positive().max(10000).optional(), + maxParticipants: z + .number() + .int() + .positive() + .max(10000) + .nullable() + .optional(), status: z .enum([ "draft", diff --git a/packages/api/src/routers/hackathon/events.ts b/packages/api/src/routers/hackathon/events.ts index f540b715..612c5920 100644 --- a/packages/api/src/routers/hackathon/events.ts +++ b/packages/api/src/routers/hackathon/events.ts @@ -6,12 +6,46 @@ import { hackathonEvents, hackathonEventAttendees, } from "@query/db"; -import { eq, inArray, sql } from "drizzle-orm"; -import { isAdmin } from "../../middleware/procedures"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { isAdmin, isScanner } from "../../middleware/procedures"; import { recordAdminAction } from "../../middleware/audit"; +import { assignCheckInCode } from "../../services/points"; import { assertHackathonVisible } from "./visibility"; import type { DrizzleDB } from "@query/db"; +// What attending is worth. Copied onto each check-in as it happens, so a +// change here only affects check-ins from then on. +const eventPoints = z + .number() + .int("Points must be a whole number") + .min(0, "Points cannot be negative") + .max(10_000, "Points are capped at 10,000 per event"); + +const eventScope = z.object({ + hackathonId: z.string().uuid("Invalid hackathon ID"), + eventId: z.string().uuid("Invalid event ID"), +}); + +// Scoped through the event's own hackathonId, so an eventId from another +// edition answers like one that does not exist. +const findScopedEvent = async ( + db: DrizzleDB, + input: z.infer, +) => { + const event = await db.query.hackathonEvents.findFirst({ + where: and( + eq(hackathonEvents.id, input.eventId), + eq(hackathonEvents.hackathonId, input.hackathonId), + ), + }); + + if (!event) { + throw new TRPCError({ code: "NOT_FOUND", message: "Event not found." }); + } + + return event; +}; + export const hackathonEventsRouter = createTRPCRouter({ createEvent: isAdmin .input( @@ -30,6 +64,8 @@ export const hackathonEventsRouter = createTRPCRouter({ location: z.string().min(1).max(500), startTime: z.date(), endTime: z.date(), + points: eventPoints.default(0), + selfCheckIn: z.boolean().default(false), }) .refine((data) => data.endTime > data.startTime, { message: "End time must be after start time", @@ -57,11 +93,23 @@ export const hackathonEventsRouter = createTRPCRouter({ location: input.location, startTime: input.startTime, endTime: input.endTime, + points: input.points, + selfCheckIn: input.selfCheckIn, }) .returning(); ctx.cache.delete(`hackathon:${input.hackathonId}:events`); + // A self check-in event with no code admits nobody, and the toggle used to + // leave that for someone to notice at the door. + if (newEvent?.selfCheckIn) { + const checkInCode = await assignCheckInCode( + ctx.db as DrizzleDB, + newEvent, + ); + return { ...newEvent, checkInCode }; + } + return newEvent; }), @@ -78,6 +126,9 @@ export const hackathonEventsRouter = createTRPCRouter({ location: z.string().min(1).max(500).optional(), startTime: z.date().optional(), endTime: z.date().optional(), + // Past check-ins keep the points they were given. + points: eventPoints.optional(), + selfCheckIn: z.boolean().optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -117,6 +168,22 @@ export const hackathonEventsRouter = createTRPCRouter({ // also matched every attendee's cached registrations. ctx.cache.delete(`hackathon:${existing.hackathonId}:events`); + // Switching self check-in on issues the first code. Only the off-to-on + // change: an existing code is never replaced here (rotating is its own + // action), and a code an admin cleared on purpose stays cleared. + if ( + updatedEvent && + input.selfCheckIn && + !existing.selfCheckIn && + !updatedEvent.checkInCode + ) { + const checkInCode = await assignCheckInCode( + ctx.db as DrizzleDB, + updatedEvent, + ); + return { ...updatedEvent, checkInCode }; + } + return updatedEvent; }), @@ -153,7 +220,7 @@ export const hackathonEventsRouter = createTRPCRouter({ if (checkIns > 0 && !input.force) { throw new TRPCError({ code: "CONFLICT", - message: `${checkIns} person(s) have already checked into "${existing.name}". Deleting the event erases those check-ins permanently.`, + message: `${checkIns} person(s) have already checked into "${existing.name}". Deleting the event erases those check-ins and the points they earned, permanently.`, }); } @@ -192,8 +259,11 @@ export const hackathonEventsRouter = createTRPCRouter({ const fetchEvents = async () => { const db = ctx.db as DrizzleDB; + // Never the check-in code: this is public, and a code anyone can read + // is points for anyone who never came. const eventsData = await db.query.hackathonEvents.findMany({ where: eq(hackathonEvents.hackathonId, input.hackathonId), + columns: { checkInCode: false }, orderBy: (events, { asc }) => [asc(events.startTime)], }); @@ -238,4 +308,104 @@ export const hackathonEventsRouter = createTRPCRouter({ return events; }), + // The schedule as staff see it: codes included, read fresh rather than from + // the public cache, since a rotated code has to show up straight away. + adminGetEvents: isAdmin + .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const [eventsData, counts] = await Promise.all([ + db.query.hackathonEvents.findMany({ + where: eq(hackathonEvents.hackathonId, input.hackathonId), + orderBy: (events, { asc }) => [asc(events.startTime)], + }), + db + .select({ + eventId: hackathonEventAttendees.eventId, + count: sql`count(*)::int`, + }) + .from(hackathonEventAttendees) + .innerJoin( + hackathonEvents, + eq(hackathonEvents.id, hackathonEventAttendees.eventId), + ) + .where(eq(hackathonEvents.hackathonId, input.hackathonId)) + .groupBy(hackathonEventAttendees.eventId), + ]); + + const countByEvent = new Map( + counts.map((row) => [row.eventId, row.count]), + ); + + return eventsData.map((event) => ({ + ...event, + attendeeCount: countByEvent.get(event.id) ?? 0, + })); + }), + + // What the screen at the door shows. Volunteers run doors, so any event + // staff can read it; they can already check anyone in by name. + getEventCheckInCode: isScanner + .input(eventScope) + .query(async ({ ctx, input }) => { + const event = await findScopedEvent(ctx.db as DrizzleDB, input); + + return { + id: event.id, + hackathonId: event.hackathonId, + name: event.name, + location: event.location, + startTime: event.startTime, + endTime: event.endTime, + points: event.points, + selfCheckIn: event.selfCheckIn, + checkInCode: event.checkInCode, + }; + }), + + // Issues a code, or replaces the current one. Rotating is the answer to a + // code that got posted in a group chat: the old one stops working at once. + generateEventCheckInCode: isAdmin + .input(eventScope) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const event = await findScopedEvent(db, input); + + const checkInCode = await assignCheckInCode(db, event); + + await recordAdminAction(db, { + userId: ctx.userId, + action: event.checkInCode + ? "hackathon.rotateEventCheckInCode" + : "hackathon.generateEventCheckInCode", + resourceId: event.id, + metadata: { hackathonId: event.hackathonId, name: event.name }, + }); + + return { checkInCode }; + }), + + // Takes the code away. Self check-in stays switched however it was; with no + // code there is nothing to type, so it simply stops admitting anyone. + clearEventCheckInCode: isAdmin + .input(eventScope) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const event = await findScopedEvent(db, input); + + await db + .update(hackathonEvents) + .set({ checkInCode: null, updatedAt: new Date() }) + .where(eq(hackathonEvents.id, event.id)); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "hackathon.clearEventCheckInCode", + resourceId: event.id, + metadata: { hackathonId: event.hackathonId, name: event.name }, + }); + + return { success: true }; + }), }); diff --git a/packages/api/src/routers/hackathon/interest.ts b/packages/api/src/routers/hackathon/interest.ts index f8c91bbd..53d181aa 100644 --- a/packages/api/src/routers/hackathon/interest.ts +++ b/packages/api/src/routers/hackathon/interest.ts @@ -59,12 +59,19 @@ const CLAIM_TIMEOUT_MS = 15 * 60 * 1000; // meant that the moment registration opened, the one page telling the world // about the hackathon said "Nothing announced yet". Soonest first, so // announcing the year after next does not displace the one being promoted. +// `completed` is last: once the event is over the page still has to resolve, +// and point registered hackers at their results, but a finished edition only +// stands in when nothing is ahead of it (see findAnnounced). const PUBLIC_FUNNEL_STATUSES = [ "announced", "open", "closed", "in_progress", + "completed", ] as const; +const AHEAD_STATUSES = PUBLIC_FUNNEL_STATUSES.filter( + (status) => status !== "completed", +); /** What the landing page reads; also the shape held in the cache. */ type UpcomingEdition = { @@ -82,13 +89,23 @@ type UpcomingEdition = { }; async function findAnnounced(db: DrizzleDB) { - return db.query.hackathons.findFirst({ + const ahead = await db.query.hackathons.findFirst({ where: and( - inArray(hackathons.status, [...PUBLIC_FUNNEL_STATUSES]), + inArray(hackathons.status, AHEAD_STATUSES), eq(hackathons.isPublic, true), ), orderBy: asc(hackathons.startDate), }); + if (ahead) return ahead; + + // Between editions: the one that just finished, not the oldest on record. + return db.query.hackathons.findFirst({ + where: and( + eq(hackathons.status, "completed"), + eq(hackathons.isPublic, true), + ), + orderBy: desc(hackathons.startDate), + }); } export const hackathonInterestRouter = createTRPCRouter({ diff --git a/packages/api/src/routers/hackathon/registration.ts b/packages/api/src/routers/hackathon/registration.ts index aa85b860..b0ff2cc6 100644 --- a/packages/api/src/routers/hackathon/registration.ts +++ b/packages/api/src/routers/hackathon/registration.ts @@ -9,7 +9,7 @@ import { hackathonProjects, members, } from "@query/db"; -import { eq, and, isNull, ne } from "drizzle-orm"; +import { eq, and, inArray, isNull, ne } from "drizzle-orm"; import type { DrizzleDB } from "@query/db"; import { assertHackathonVisible } from "./visibility"; import { evictParticipantCaches, syncCurrentParticipants } from "./admin"; @@ -37,68 +37,74 @@ const isDuplicateRegistration = (error: unknown) => { return false; }; +// The application form. Shared by register and updateMyRegistration so an +// edit is held to exactly the rules the first submission was. +const registrationForm = z.object({ + hackathonId: z.string().uuid("Invalid hackathon ID"), + // Personal info + firstName: z.string().min(1).max(100), + lastName: z.string().min(1).max(100), + phone: z.string().min(1).max(30), + age: z.number().int().min(13).max(120), + gender: z.string().max(50).optional(), + pronouns: z.string().max(50).optional(), + race: z.string().max(100).optional(), + underrepresented: z.boolean().optional(), + // Academic info + school: z.string().min(1).max(300), + major: z.string().min(1).max(300), + // Relative to now: a fixed 2020-2035 accepted years already past and + // would start refusing real students in 2036. + graduationYear: z + .number() + .int() + .min(new Date().getFullYear() - 1) + .max(new Date().getFullYear() + 8), + levelOfStudy: z.enum([ + "Freshman", + "Sophomore", + "Junior", + "Senior", + "Graduate", + "PhD", + "Other", + ]), + country: z.string().min(1).max(100), + firstGeneration: z.boolean().optional(), + // Experience + hackathonsAttended: z.number().int().min(0).max(100).optional(), + resumeUrl: z.string().url().max(500).optional().or(z.literal("")), + linkedinUrl: z.string().url().max(500).optional().or(z.literal("")), + githubUrl: z.string().url().max(500).optional().or(z.literal("")), + whyAttend: z + .string() + .trim() + .min(1, "Tell us why you want to attend") + .max(2000), + // Logistics + shirtSize: z.enum(["XS", "S", "M", "L", "XL", "XXL"]).optional(), + dietaryRestrictions: z.array(z.string().max(100)).max(10).optional(), + emergencyContact: z.string().max(200).optional(), + emergencyPhone: z.string().max(20).optional(), + needsHardware: z.boolean().optional(), + // Consent + agreeToCodeOfConduct: z + .boolean() + .refine((v) => v === true, { + message: "You must agree to the Code of Conduct", + }), + mlhCodeOfConduct: z.boolean().refine((v) => v === true, { + message: "You must agree to the MLH Code of Conduct", + }), + mlhDataSharing: z.boolean().refine((v) => v === true, { + message: "You must agree to share your registration with MLH", + }), + mlhInformationalEmails: z.boolean().optional(), +}); + export const hackathonRegistrationRouter = createTRPCRouter({ register: notHackathonBanned - .input( - z.object({ - hackathonId: z.string().uuid("Invalid hackathon ID"), - // Personal info - firstName: z.string().min(1).max(100), - lastName: z.string().min(1).max(100), - phone: z.string().min(1).max(30), - age: z.number().int().min(13).max(120), - gender: z.string().max(50).optional(), - pronouns: z.string().max(50).optional(), - race: z.string().max(100).optional(), - underrepresented: z.boolean().optional(), - // Academic info - school: z.string().min(1).max(300), - major: z.string().min(1).max(300), - // Relative to now: a fixed 2020-2035 accepted years already past and - // would start refusing real students in 2036. - graduationYear: z - .number() - .int() - .min(new Date().getFullYear() - 1) - .max(new Date().getFullYear() + 8), - levelOfStudy: z.enum([ - "Freshman", - "Sophomore", - "Junior", - "Senior", - "Graduate", - "PhD", - "Other", - ]), - country: z.string().min(1).max(100), - firstGeneration: z.boolean().optional(), - // Experience - hackathonsAttended: z.number().int().min(0).max(100).optional(), - resumeUrl: z.string().url().max(500).optional().or(z.literal("")), - linkedinUrl: z.string().url().max(500).optional().or(z.literal("")), - githubUrl: z.string().url().max(500).optional().or(z.literal("")), - whyAttend: z - .string() - .trim() - .min(1, "Tell us why you want to attend") - .max(2000), - // Logistics - shirtSize: z.enum(["XS", "S", "M", "L", "XL", "XXL"]).optional(), - dietaryRestrictions: z.array(z.string().max(100)).max(10).optional(), - emergencyContact: z.string().max(200).optional(), - emergencyPhone: z.string().max(20).optional(), - needsHardware: z.boolean().optional(), - // Consent - agreeToCodeOfConduct: z - .boolean() - .refine((v) => v === true, { - message: "You must agree to the Code of Conduct", - }), - mlhCodeOfConduct: z.boolean().optional(), - mlhDataSharing: z.boolean().optional(), - mlhInformationalEmails: z.boolean().optional(), - }), - ) + .input(registrationForm) .mutation(async ({ ctx, input }) => { try { const { participant, hackathonName } = await ( @@ -328,6 +334,193 @@ export const hackathonRegistrationRouter = createTRPCRouter({ return { success: true }; }), + // Fixes an application nobody has reviewed yet. Once an organiser decides, the + // answers are what they decided on, so the edit closes with the decision, and + // with registration itself. The three required consents are not asked again: + // they were needed to submit and stay as given. + updateMyRegistration: notHackathonBanned + .input( + registrationForm.omit({ + agreeToCodeOfConduct: true, + mlhCodeOfConduct: true, + mlhDataSharing: true, + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const userId = ctx.userId as string; + + const hackathon = await db.query.hackathons.findFirst({ + where: eq(hackathons.id, input.hackathonId), + columns: { status: true, registrationDeadline: true }, + }); + if (!hackathon) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hackathon not found", + }); + } + if ( + hackathon.status !== "open" || + (hackathon.registrationDeadline && + new Date() > hackathon.registrationDeadline) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Registration has closed, so applications can't be edited.", + }); + } + + const participant = await db.query.hackathonParticipants.findFirst({ + where: and( + eq(hackathonParticipants.hackathonId, input.hackathonId), + eq(hackathonParticipants.userId, userId), + ), + columns: { id: true, registrationStatus: true }, + }); + if (!participant) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "You are not registered for this hackathon.", + }); + } + if (participant.registrationStatus !== "pending") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Your application has been reviewed, so it can't be edited.", + }); + } + + // The answers are replaced whole, as the form resubmits every field, so an + // optional one left blank clears what was there. The status is in the + // WHERE too: a decision landing after the read above must not be edited + // under. + const updated = await db + .update(hackathonParticipants) + .set({ + firstName: input.firstName, + lastName: input.lastName, + phone: input.phone, + age: input.age, + gender: input.gender ?? null, + pronouns: input.pronouns ?? null, + race: input.race ?? null, + underrepresented: input.underrepresented ?? false, + school: input.school, + major: input.major, + graduationYear: input.graduationYear, + levelOfStudy: input.levelOfStudy, + country: input.country, + firstGeneration: input.firstGeneration ?? false, + hackathonsAttended: input.hackathonsAttended ?? null, + resumeUrl: input.resumeUrl || null, + linkedinUrl: input.linkedinUrl || null, + githubUrl: input.githubUrl || null, + whyAttend: input.whyAttend, + shirtSize: input.shirtSize ?? null, + dietaryRestrictions: input.dietaryRestrictions || [], + emergencyContact: input.emergencyContact ?? null, + emergencyPhone: input.emergencyPhone ?? null, + needsHardware: input.needsHardware ?? false, + mlhInformationalEmails: input.mlhInformationalEmails ?? false, + updatedAt: new Date(), + }) + .where( + and( + eq(hackathonParticipants.id, participant.id), + eq(hackathonParticipants.registrationStatus, "pending"), + ), + ) + .returning({ id: hackathonParticipants.id }); + if (updated.length === 0) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Your application was reviewed just now, so it can't be edited. Refresh the page.", + }); + } + + // Only this person's own list carries these answers; the public roster + // and the seat count are untouched. + ctx.cache.delete(`hackathon:registrations:${userId}`); + + return { success: true }; + }), + + // An accepted hacker saying they are coming. A seat is held on the strength of + // an acceptance email, so organisers need to see who has not answered and free + // those seats for the waitlist. Repeating it keeps the first answer. Taking + // part, not a way out, so a ban refuses it like registering. + confirmAttendance: notHackathonBanned + .input(z.object({ hackathonId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const userId = ctx.userId as string; + + const participant = await db.query.hackathonParticipants.findFirst({ + where: and( + eq(hackathonParticipants.hackathonId, input.hackathonId), + eq(hackathonParticipants.userId, userId), + ), + columns: { + id: true, + registrationStatus: true, + attendanceConfirmedAt: true, + }, + }); + if (!participant) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "You are not registered for this hackathon.", + }); + } + + // checked_in is accepted too: a door scan landing before a stale tab's + // click should not read as an error. + if ( + participant.registrationStatus !== "approved" && + participant.registrationStatus !== "checked_in" + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only accepted hackers can confirm they are coming.", + }); + } + if (participant.attendanceConfirmedAt) { + return { confirmedAt: participant.attendanceConfirmedAt }; + } + + const confirmedAt = new Date(); + const updated = await db + .update(hackathonParticipants) + .set({ attendanceConfirmedAt: confirmedAt, updatedAt: confirmedAt }) + .where( + and( + eq(hackathonParticipants.id, participant.id), + inArray(hackathonParticipants.registrationStatus, [ + "approved", + "checked_in", + ]), + isNull(hackathonParticipants.attendanceConfirmedAt), + ), + ) + .returning({ id: hackathonParticipants.id }); + if (updated.length === 0) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Your registration changed just now. Refresh the page and try again.", + }); + } + + // The stamp shows on this person's own registration list and on the + // organisers' roster, which is not cached. The seat count, the public + // roster and the analytics did not move. + ctx.cache.delete(`hackathon:registrations:${userId}`); + + return { confirmedAt }; + }), + myRegistrations: protectedProcedure.query(async ({ ctx }) => { const cacheKey = `hackathon:registrations:${ctx.userId}`; const cached = ctx.cache.get(cacheKey); diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index bfa2e79d..50e80b0c 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -5,90 +5,20 @@ import { judges, judgeAssignments, judgeVotes, - judgingProjects, - judgeQueue, hackathons, - hackathonProjects, users, - hackathonParticipants, } from "@query/db"; -import { eq, and, asc, sql, inArray, isNull } from "drizzle-orm"; -import { - isAdmin, - isSuperAdmin, - notHackathonBanned, -} from "../../middleware/procedures"; +import { eq, and, sql } from "drizzle-orm"; +import { isAdmin, isSuperAdmin } from "../../middleware/procedures"; import { CacheKeys, invalidatePortalContext } from "../../middleware/cache"; +import { portalOrigin } from "../../services/judging/claims"; import type { DrizzleDB } from "@query/db"; -import { isLive, loadGroups, loadPool } from "./dispatch"; - -// 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 -// never started, which would otherwise read as visits, and returns how many -// projects the judge can be sent to. -async function poolSizeForJudge( - db: DrizzleDB, - opts: { judgeId: string; hackathonId: string }, -) { - await db - .delete(judgeQueue) - .where( - and( - eq(judgeQueue.judgeId, opts.judgeId), - eq(judgeQueue.hackathonId, opts.hackathonId), - eq(judgeQueue.isCompleted, false), - isNull(judgeQueue.startedAt), - isNull(judgeQueue.arrivedAt), - ), - ); - const { pool } = await loadPool(db, opts.judgeId, opts.hackathonId); - return pool.length; -} - -// The labels a judge's track may take, for one edition. Routing matches these -// exactly, so anything else classifies the judge as sponsor/special and -// filters their pool to zero with nothing on screen explaining why. "createX" -// is here because dispatch routes it on the project flag. -async function assertTrackExists( - db: DrizzleDB, - hackathonId: string, - track: string | null | undefined, -): Promise { - if (!track) return null; - - const hackathon = await db.query.hackathons.findFirst({ - where: eq(hackathons.id, hackathonId), - columns: { tracks: true, challenges: true }, - }); - - const allowed = [ - ...(hackathon?.tracks ?? []), - ...(hackathon?.challenges ?? []), - "createX", - ]; - - // Case-insensitive match, but the stored value is the edition's own spelling: - // routing compares exactly, so "ai" must become "AI" here. - const match = allowed.find( - (t) => t.toLowerCase() === track.trim().toLowerCase(), - ); - - if (!match) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: allowed.length === 1 - ? "This hackathon has no tracks or challenges configured yet — set them on the edition before assigning a judge to one." - : `"${track}" is not a track or challenge on this hackathon. Choose one of: ${allowed.join(", ")}.`, - }); - } - - return match; -} +// The organiser's side of the judge roster: who applied, who is approved, and +// adding or removing someone. Judging itself lives in routers/judging. export const judgeAdminRouter = createTRPCRouter({ // Judges, optionally scoped to one hackathon. A judges row belongs to exactly - // one edition and assignToHackathon refuses any judge from another, so an - // unscoped list offered only judges the server was guaranteed to reject. + // one edition. list: isAdmin .input( z @@ -96,32 +26,32 @@ export const judgeAdminRouter = createTRPCRouter({ .optional(), ) .query(async ({ ctx, input }) => { - const allJudges = await (ctx.db as DrizzleDB).query.judges.findMany({ - where: input?.hackathonId - ? eq(judges.hackathonId, input.hackathonId) - : undefined, - with: { - user: { - columns: { - id: true, - name: true, - email: true, - image: true, + const allJudges = await (ctx.db as DrizzleDB).query.judges.findMany({ + where: input?.hackathonId + ? eq(judges.hackathonId, input.hackathonId) + : undefined, + with: { + user: { + columns: { + id: true, + name: true, + email: true, + image: true, + }, }, - }, - assignments: { - with: { - hackathon: { - columns: { - id: true, - name: true, + assignments: { + with: { + hackathon: { + columns: { + id: true, + name: true, + }, }, }, }, }, - }, - orderBy: (judges, { desc }) => [desc(judges.createdAt)], - }); + orderBy: (judges, { desc }) => [desc(judges.createdAt)], + }); return allJudges; }), @@ -184,172 +114,41 @@ export const judgeAdminRouter = createTRPCRouter({ }) .returning(); - // Same as approval: the role gate and the sidebar both cache, so the new - // judge would otherwise wait out a 5-minute TTL for the Judge tab. + // Same as approval: the role lookup and the sidebar both cache, so the + // new judge would otherwise wait out a 5-minute TTL for the Judge tab. ctx.cache.deletePattern(`${CacheKeys.judge(user.id)}*`); invalidatePortalContext(user.id); - return result[0]; - }), - - assignToHackathon: isAdmin - .input( - z.object({ - judgeId: z.string().uuid(), - hackathonId: z.string().uuid(), - isLead: z.boolean().optional(), - track: z.string().optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - // A judges row belongs to one hackathon and isJudge authorizes against that. - // Assigning across editions builds a queue nobody can ever open. - const judge = await (ctx.db as DrizzleDB).query.judges.findFirst({ - where: eq(judges.id, input.judgeId), - columns: { hackathonId: true }, - }); - - if (!judge) { - throw new TRPCError({ code: "NOT_FOUND", message: "Judge not found" }); - } - - if (judge.hackathonId !== input.hackathonId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This judge belongs to a different hackathon", - }); - } - - const existing = await ( - ctx.db as DrizzleDB - ).query.judgeAssignments.findFirst({ - where: and( - eq(judgeAssignments.judgeId, input.judgeId), - eq(judgeAssignments.hackathonId, input.hackathonId), - ), - }); - - if (existing) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Judge already assigned", - }); - } - - const track = await assertTrackExists( - ctx.db as DrizzleDB, - input.hackathonId, - input.track, - ); - - const result = await (ctx.db as DrizzleDB) - .insert(judgeAssignments) - .values({ - judgeId: input.judgeId, - hackathonId: input.hackathonId, - isLead: input.isLead || false, - track, - }) - .returning(); - - // No queue to build: the judge draws tables from the shared pool as soon - // as they are active. See dispatch.ts. - return result[0]; - }), - - // Turns submitted projects into judgeable ones — the only way a judging entry - // is created. Idempotent: judging_project_source_unique pins one judgeable - // row per submission, so re-running only adds late submissions. - promoteSubmissions: isAdmin - .input(z.object({ hackathonId: z.string().uuid() })) - .mutation(async ({ ctx, input }) => { - return 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 - .select({ id: hackathons.id }) - .from(hackathons) - .where(eq(hackathons.id, input.hackathonId)) - .for("update"); - - const submissions = await tx.query.hackathonProjects.findMany({ - where: and( - eq(hackathonProjects.hackathonId, input.hackathonId), - inArray(hackathonProjects.status, ["submitted", "judging"]), - ), - with: { team: { columns: { name: true } } }, - orderBy: [asc(hackathonProjects.submittedAt)], - }); - - if (submissions.length === 0) { - return { - created: 0, - alreadyPresent: 0, - total: 0, - }; - } - - const existing = await tx.query.judgingProjects.findMany({ - where: eq(judgingProjects.hackathonId, input.hackathonId), - columns: { id: true, sourceProjectId: true, tableNumber: true }, - }); - - const promoted = new Set( - existing - .map((row) => row.sourceProjectId) - .filter((id): id is string => !!id), - ); - - const fresh = submissions.filter((s) => !promoted.has(s.id)); - - let nextTable = existing.reduce( - (max, row) => Math.max(max, row.tableNumber), - 0, - ); + // There was no application, so nothing else tells them they are a judge. + // Its own copy rather than the approval's. Best-effort: a mail failure + // must not undo a judge already in the database. + if (user.email) { + try { + const hackathon = await ( + ctx.db as DrizzleDB + ).query.hackathons.findFirst({ + where: eq(hackathons.id, input.hackathonId), + columns: { name: true }, + }); - if (fresh.length > 0) { - await tx.insert(judgingProjects).values( - fresh.map((submission) => ({ - hackathonId: input.hackathonId, - sourceProjectId: submission.id, - name: submission.name, - description: submission.description, - tableNumber: ++nextTable, - // hackathon_project.teamMembers is text[]; this column is a single text - // field. Joined, not assigned — an array here is a type error at best and - // "[object Object]" on a judge's screen at worst. - teamMembers: - submission.team?.name ?? - (submission.teamMembers?.length - ? submission.teamMembers.join(", ") - : null), - projectUrl: submission.demoUrl, - repoUrl: submission.githubUrl, - tracks: submission.tracks?.length ? submission.tracks : null, - challenges: submission.challenges?.length - ? submission.challenges - : null, - isCreateX: submission.isCreateX ?? false, - })), + const { sendJudgeAddedEmail } = await import("@query/auth/email"); + await sendJudgeAddedEmail({ + email: user.email, + hackathonName: hackathon?.name ?? "the hackathon", + host: portalOrigin(), + }); + } catch (error) { + // Deliberate operational logging: the judge stands and this is the only + // record that the notice did not go out. + // eslint-disable-next-line no-console + console.error( + `[Email Service] Judge added notice failed for user ${user.id}:`, + error, ); - - await tx - .update(hackathonProjects) - .set({ status: "judging", updatedAt: new Date() }) - .where( - inArray( - hackathonProjects.id, - fresh.map((submission) => submission.id), - ), - ); } + } - return { - created: fresh.length, - alreadyPresent: submissions.length - fresh.length, - total: submissions.length, - }; - }); + return result[0]; }), setActive: isAdmin @@ -381,9 +180,8 @@ export const judgeAdminRouter = createTRPCRouter({ throw new TRPCError({ code: "NOT_FOUND", message: "Judge not found" }); } - // Keeps the assignment's own status column honest. judges.isActive is what - // every judging gate reads; this column was written once as "pending" and - // never touched, so the table showed approved judges as pending. Mirror only. + // Keeps the application's own status column honest. judges.isActive is + // what every judging gate reads; this column only mirrors it. await (ctx.db as DrizzleDB) .update(judgeAssignments) .set({ status: input.isActive ? "approved" : "pending" }) @@ -394,53 +192,15 @@ export const judgeAdminRouter = createTRPCRouter({ ), ); - // isJudge and judge.isJudge both cache the role for 60s per user per - // hackathon; approval has to take effect now, not a minute from now. + // judge.isJudge caches the role for 60s per user per hackathon; approval + // has to take effect now, not a minute from now. ctx.cache.deletePattern(`${CacheKeys.judge(updated.userId)}*`); // The sidebar reads portal context, which lives 5 minutes — without this the // approved judge waits out that TTL before the Judge tab appears. invalidatePortalContext(updated.userId); - // Approval builds the queue, because nothing else will: judge.register always - // writes an assignment row and assignToHackathon refuses anyone who has one, - // so the documented path produced an active judge whose portal said "All - // Done". Only when the queue is empty — a judge suspended and reinstated must - // come back to the one they were part-way through. - let queuedProjects: number | null = null; - - if (input.isActive) { - // Check and build in one transaction, behind a lock on the judge row. - // "Empty? then build" is a read then a write, and judge_queue has no unique - // on (judge, project), so two approvals arriving together both saw an empty - // queue and gave the judge every project twice. - queuedProjects = await (ctx.db as DrizzleDB).transaction(async (tx) => { - await tx - .select({ id: judges.id }) - .from(judges) - .where(eq(judges.id, input.judgeId)) - .for("update"); - - const assignment = await tx.query.judgeAssignments.findFirst({ - where: and( - eq(judgeAssignments.judgeId, input.judgeId), - eq(judgeAssignments.hackathonId, updated.hackathonId), - ), - columns: { id: true }, - }); - - if (!assignment) return null; - - // Nothing to build: an active judge draws from the shared pool. - return await poolSizeForJudge(tx as unknown as DrizzleDB, { - judgeId: input.judgeId, - hackathonId: updated.hackathonId, - }); - }); - } - - // Tells the judge they were approved — the register success screen promises - // this and nothing sent it. Only on the transition, and after the queue - // exists so "your queue is ready" is true. Best-effort: a mail failure must + // Tells the judge they were approved — the register success screen + // promises this. Only on the transition. Best-effort: a mail failure must // not undo an approval already in the database. if (input.isActive && before?.isActive !== true && updated.email) { try { @@ -455,6 +215,7 @@ export const judgeAdminRouter = createTRPCRouter({ await sendJudgeApprovedEmail({ email: updated.email, hackathonName: hackathon?.name ?? "the hackathon", + host: portalOrigin(), }); } catch (error) { // Deliberate operational logging: the approval stands and this is the only @@ -467,101 +228,14 @@ export const judgeAdminRouter = createTRPCRouter({ } } - return { success: true, isActive: input.isActive, queuedProjects }; - }), - - // Corrects a judge's track after the fact. Without it a wrong track was - // permanent: it routes their whole pool, assignToHackathon refuses anyone who - // already has an assignment row, and no screen could edit it. - updateAssignmentTrack: isAdmin - .input( - z.object({ - judgeId: z.string().uuid(), - hackathonId: z.string().uuid(), - /** Null clears the track, giving the judge the whole project pool. */ - track: z.string().max(200).nullable(), - // Change the track even though this judge has scored. Completed slots are - // kept; only the unjudged remainder is rebuilt. - force: z.boolean().default(false), - }), - ) - .mutation(async ({ ctx, input }) => { - const assignment = await ( - ctx.db as DrizzleDB - ).query.judgeAssignments.findFirst({ - where: and( - eq(judgeAssignments.judgeId, input.judgeId), - eq(judgeAssignments.hackathonId, input.hackathonId), - ), - columns: { id: true }, - }); - - if (!assignment) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "This judge is not assigned to this hackathon", - }); - } - - const track = await assertTrackExists( - ctx.db as DrizzleDB, - input.hackathonId, - input.track, - ); - - // Counted before anything is written, so the refusal is decided on the state - // the organiser is looking at. - const [completed] = await (ctx.db as DrizzleDB) - .select({ count: sql`count(*)::int` }) - .from(judgeQueue) - .where( - and( - eq(judgeQueue.judgeId, input.judgeId), - eq(judgeQueue.hackathonId, input.hackathonId), - eq(judgeQueue.isCompleted, true), - ), - ); - - // Refuse first, then offer the override. Writing the new track while leaving - // the old queue makes assignment and queue disagree — the judge carries on - // scoring the old pool and nothing says so. The organiser sees the cost. - if ((completed?.count ?? 0) > 0 && !input.force) { - throw new TRPCError({ - code: "CONFLICT", - message: `This judge has already scored ${completed?.count} project(s). Changing their track keeps those scores and sends them to the new track from their next table — confirm to continue.`, - }); - } - - await (ctx.db as DrizzleDB) - .update(judgeAssignments) - .set({ track }) - .where(eq(judgeAssignments.id, assignment.id)); - - // Scores already given stay. The judge's next tables come from the new - // track's pool, since dispatch reads the assignment on every hand-out. - const projectCount = await poolSizeForJudge(ctx.db as DrizzleDB, { - judgeId: input.judgeId, - hackathonId: input.hackathonId, - }); - - return { - success: true, - track, - queueRebuilt: true, - projectCount, - keptCompleted: completed?.count ?? 0, - message: - (completed?.count ?? 0) > 0 - ? `Track updated. ${completed?.count} scored project(s) kept; their next tables come from the new track.` - : null, - }; + return { success: true, isActive: input.isActive }; }), remove: isSuperAdmin .input(z.object({ judgeId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { - // judgeVotes.judgeId cascades on delete, so removing a judge who has scored - // erases those scores and shifts the normalization behind every ranking. + // judge_vote.judge_id cascades on delete, so removing a judge who scored + // in a past edition would erase those scores. const existingVote = await ( ctx.db as DrizzleDB ).query.judgeVotes.findFirst({ @@ -588,478 +262,4 @@ export const judgeAdminRouter = createTRPCRouter({ } return { success: true }; }), - - // Bulk-assign projects to all judges for a hackathon. Main-track judges get - // 3–9 randomly-selected projects; special-label judges (createX, sponsor - // challenges) get ALL matching projects. - // The table cards to print, one per judgeable project, each carrying the code - // a judge scans on arrival — the physical half of scan-to-start. - tableCards: isAdmin - .input(z.object({ hackathonId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - return await (ctx.db as DrizzleDB).query.judgingProjects.findMany({ - where: and( - eq(judgingProjects.hackathonId, input.hackathonId), - isNull(judgingProjects.withdrawnAt), - ), - columns: { - id: true, - name: true, - tableNumber: true, - zone: true, - qrCode: true, - teamMembers: true, - }, - orderBy: [asc(judgingProjects.tableNumber)], - }); - }), - - /** Per-judge scoring analytics for bias detection and performance review. */ - // The judging floor for the organiser: where every judge is, how evenly the - // projects have been seen, and whether the judges who actually turned up can - // finish in time. Nothing here assumes a head count; pace is measured. - liveProgress: isAdmin - .input(z.object({ hackathonId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - const db = ctx.db as DrizzleDB; - const now = new Date(); - - const [judgeRows, visitRows, voteRows, projectRows] = await Promise.all([ - db.query.judges.findMany({ - where: eq(judges.hackathonId, input.hackathonId), - columns: { id: true, name: true, email: true, isActive: true }, - }), - db - .select({ - judgeId: judgeQueue.judgeId, - projectId: judgeQueue.projectId, - isCompleted: judgeQueue.isCompleted, - startedAt: judgeQueue.startedAt, - arrivedAt: judgeQueue.arrivedAt, - tableNumber: judgingProjects.tableNumber, - projectName: judgingProjects.name, - }) - .from(judgeQueue) - .leftJoin(judgingProjects, eq(judgingProjects.id, judgeQueue.projectId)) - .where(eq(judgeQueue.hackathonId, input.hackathonId)), - db - .select({ - judgeId: judgeVotes.judgeId, - projectId: judgeVotes.projectId, - votedAt: judgeVotes.votedAt, - durationSeconds: judgeVotes.durationSeconds, - }) - .from(judgeVotes) - .innerJoin( - judgingProjects, - and( - eq(judgingProjects.id, judgeVotes.projectId), - eq(judgingProjects.hackathonId, input.hackathonId), - ), - ), - db.query.judgingProjects.findMany({ - where: and( - eq(judgingProjects.hackathonId, input.hackathonId), - isNull(judgingProjects.withdrawnAt), - ), - columns: { id: true }, - }), - ]); - - const voted = new Set(voteRows.map((v) => `${v.judgeId}:${v.projectId}`)); - const secondsSince = (d: Date) => - Math.max(0, Math.floor((now.getTime() - d.getTime()) / 1000)); - const minutesSince = (d: Date | null) => - d ? Math.floor(secondsSince(d) / 60) : null; - - const judgesOut = judgeRows.map((judge) => { - const visits = visitRows.filter( - (r) => r.judgeId === judge.id && (r.startedAt || r.arrivedAt), - ); - const votes = voteRows.filter((v) => v.judgeId === judge.id); - const current = visits.find((r) => isLive(r, now)) ?? null; - - // Visits that closed without a score: the cutoff passed, or the judge - // passed on the table. - const voided = visits.filter( - (r) => - (r.isCompleted || !isLive(r, now)) && - !voted.has(`${judge.id}:${r.projectId}`), - ).length; - - const lastActivity = [ - ...votes.map((v) => v.votedAt), - ...visits.map((r) => r.arrivedAt ?? r.startedAt), - ].reduce( - (acc, d) => (d && (!acc || d > acc) ? d : acc), - null, - ); - - const durations = votes - .map((v) => v.durationSeconds) - .filter((d): d is number => typeof d === "number" && d > 0) - .sort((a, b) => a - b); - - const status = !judge.isActive - ? ("suspended" as const) - : current - ? ("judging" as const) - : visits.length === 0 - ? ("not_started" as const) - : ("between" as const); - - return { - judgeId: judge.id, - name: judge.name, - email: judge.email, - status, - scored: votes.length, - voided, - medianSeconds: durations.length - ? durations[Math.floor(durations.length / 2)]! - : null, - idleMinutes: current ? 0 : minutesSince(lastActivity), - lastActiveAt: lastActivity, - current: current - ? { - tableNumber: current.tableNumber, - projectName: current.projectName, - // Walking until the tap or scan, then the judging clock. - phase: current.arrivedAt ? ("judging" as const) : ("walking" as const), - seconds: secondsSince((current.arrivedAt ?? current.startedAt)!), - } - : null, - }; - }); - - // Worst first: a judge who has stopped is the reason to open this screen. - const rank = { not_started: 0, between: 1, judging: 2, suspended: 3 }; - judgesOut.sort( - (a, b) => - rank[a.status] - rank[b.status] || - (b.idleMinutes ?? 0) - (a.idleMinutes ?? 0), - ); - - // How evenly the main panel has seen the projects. Two looks each is the - // floor the pool works towards before anyone gets a third. Sponsor and - // special-label scores cover their own prize, as in dispatch, so they - // count towards neither the floor nor the pace that estimates it. - const { groupByJudge } = await loadGroups(db, input.hackathonId); - const mainVotes = voteRows.filter((v) => groupByJudge(v.judgeId) === "main"); - const TARGET_LOOKS = 2; - const looks = new Map(projectRows.map((p) => [p.id, 0])); - for (const v of mainVotes) { - if (looks.has(v.projectId)) looks.set(v.projectId, looks.get(v.projectId)! + 1); - } - const counts = [...looks.values()]; - const shortfall = counts.reduce((sum, n) => sum + Math.max(0, TARGET_LOOKS - n), 0); - - // Measured pace over the last 30 minutes, so the estimate tracks the - // judges who are actually on the floor, not the number who signed up. - const WINDOW_MIN = 30; - const windowStart = now.getTime() - WINDOW_MIN * 60_000; - const recentScores = mainVotes.filter( - (v) => v.votedAt && v.votedAt.getTime() >= windowStart, - ).length; - const activeJudges = judgesOut.filter( - (j) => - j.status !== "suspended" && - j.lastActiveAt && - j.lastActiveAt.getTime() >= now.getTime() - 15 * 60_000, - ).length; - const perMinute = recentScores / WINDOW_MIN; - - return { - judges: judgesOut.map(({ lastActiveAt: _, ...j }) => j), - coverage: { - projects: counts.length, - unseen: counts.filter((n) => n === 0).length, - minLooks: counts.length ? Math.min(...counts) : 0, - atTarget: counts.filter((n) => n >= TARGET_LOOKS).length, - targetLooks: TARGET_LOOKS, - }, - pace: { - activeJudges, - scoredLast30Min: recentScores, - // Null until there is a pace to extrapolate from. - minutesToTarget: - shortfall === 0 ? 0 : perMinute > 0 ? Math.ceil(shortfall / perMinute) : null, - }, - totals: { - judges: judgesOut.length, - scored: voteRows.length, - voided: judgesOut.reduce((sum, j) => sum + j.voided, 0), - }, - }; - }), - - getJudgeAnalytics: isAdmin - .input(z.object({ hackathonId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - const cacheKey = `hackathon:${input.hackathonId}:judge-analytics`; - const cached = ctx.cache.get(cacheKey); - if (cached) return cached; - - // Fetch votes for this hackathon via an explicit join. - const allVotes = await (ctx.db as DrizzleDB) - .select({ - judgeId: judgeVotes.judgeId, - projectId: judgeVotes.projectId, - score: judgeVotes.score, - durationSeconds: judgeVotes.durationSeconds, - judgeName: judges.name, - judgeUserId: judges.userId, - }) - .from(judgeVotes) - .innerJoin(judges, eq(judges.id, judgeVotes.judgeId)) - .innerJoin( - judgingProjects, - and( - eq(judgingProjects.id, judgeVotes.projectId), - eq(judgingProjects.hackathonId, input.hackathonId), - ), - ); - - const queueStats = await (ctx.db as DrizzleDB) - .select({ - judgeId: judgeQueue.judgeId, - judgeName: judges.name, - total: sql`count(*)`, - completed: sql`sum(case when ${judgeQueue.isCompleted} then 1 else 0 end)`, - }) - .from(judgeQueue) - .innerJoin(judges, eq(judges.id, judgeQueue.judgeId)) - .where(eq(judgeQueue.hackathonId, input.hackathonId)) - .groupBy(judgeQueue.judgeId, judges.name); - - const queueMap = new Map(queueStats.map((q) => [q.judgeId, q])); - - // Group votes by judgeId - const byJudge = new Map(); - for (const vote of allVotes) { - const list = byJudge.get(vote.judgeId) ?? []; - list.push(vote); - byJudge.set(vote.judgeId, list); - } - - // Global mean across all votes - const allScores = allVotes.map((v) => v.score); - const globalMean = - allScores.length > 0 - ? allScores.reduce((a, b) => a + b, 0) / allScores.length - : 0; - - const round2 = (n: number) => Math.round(n * 100) / 100; - - // A judge with a queue who has scored nothing is exactly who an organizer - // needs to find mid-event, so drive the list from queues as well as votes. - const judgeIds = new Set([...byJudge.keys(), ...queueMap.keys()]); - - const analytics = [...judgeIds].map((judgeId) => { - const votes = byJudge.get(judgeId) ?? ([] as typeof allVotes); - const scores = votes.map((v) => v.score); - const mean = - scores.length > 0 - ? scores.reduce((a, b) => a + b, 0) / scores.length - : 0; - const variance = - scores.length > 0 - ? scores.reduce((s, v) => s + (v - mean) ** 2, 0) / scores.length - : 0; - const std = Math.sqrt(variance); - - // Bias score: how far this judge's mean is from the global mean, in std units - const biasScore = - scores.length > 0 ? round2((mean - globalMean) / (std || 1)) : 0; - const biasLabel: "strict" | "neutral" | "lenient" = - biasScore < -0.5 ? "strict" : biasScore > 0.5 ? "lenient" : "neutral"; - - const avgDuration = votes - .filter((v) => v.durationSeconds != null) - .reduce((s, v, _, a) => s + (v.durationSeconds ?? 0) / a.length, 0); - - const qs = queueMap.get(judgeId); - const completionRate = - qs && Number(qs.total) > 0 - ? round2(Number(qs.completed) / Number(qs.total)) - : null; - - const firstVote = votes[0]; - - return { - judgeId, - name: firstVote?.judgeName ?? qs?.judgeName ?? "Unknown", - votesSubmitted: scores.length, - mean: round2(mean), - std: round2(std), - min: scores.length > 0 ? Math.min(...scores) : 0, - max: scores.length > 0 ? Math.max(...scores) : 0, - biasScore, - biasLabel, - avgDurationSeconds: avgDuration > 0 ? round2(avgDuration) : null, - completionRate, - queueTotal: qs ? Number(qs.total) : null, - queueCompleted: qs ? Number(qs.completed) : null, - }; - }); - - // Sort: most votes first - analytics.sort((a, b) => b.votesSubmitted - a.votesSubmitted); - - const result = { - analytics, - globalMean: round2(globalMean), - totalVotes: allVotes.length, - }; - ctx.cache.set(cacheKey, result, 30); - return result; - }), - - register: notHackathonBanned - .input( - z.object({ - hackathonId: z.string().uuid(), - name: z.string().min(1).max(200), - email: z.string().email().max(200), - phone: z.string().max(20).optional(), - company: z.string().max(200).optional(), - title: z.string().max(200).optional(), - specialty: z.string().max(200).optional(), - linkedinUrl: z.string().url().max(500).optional().or(z.literal("")), - githubUrl: z.string().url().max(500).optional().or(z.literal("")), - previousExperience: z.string().max(2000).optional(), - dietaryRestrictions: z.array(z.string()).optional(), - shirtSize: z.string().optional(), - whyJudge: z.string().max(2000).optional(), - preferredTrack: z.string().optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - return await (ctx.db as DrizzleDB).transaction(async (tx) => { - // Only an edition people can see and that is not over takes judges. Any - // uuid used to pass, and one that did not exist hit the foreign key as - // a 500. Announced counts: judges are recruited before registration. - const hackathon = await tx.query.hackathons.findFirst({ - where: eq(hackathons.id, input.hackathonId), - columns: { isPublic: true, status: true }, - }); - if ( - !hackathon || - !hackathon.isPublic || - !["announced", "open", "closed", "in_progress"].includes( - hackathon.status, - ) - ) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "That hackathon is not taking judge applications.", - }); - } - - // Check if user is registered as a participant for this hackathon - const participant = await tx.query.hackathonParticipants.findFirst({ - where: and( - eq(hackathonParticipants.hackathonId, input.hackathonId), - eq(hackathonParticipants.userId, ctx.userId as string), - ), - }); - - if (participant) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: - "You cannot apply to be a judge because you are registered as a participant for this hackathon.", - }); - } - - // Find existing judge profile or create one for this hackathon - let judge = await tx.query.judges.findFirst({ - where: and( - eq(judges.userId, ctx.userId), - eq(judges.hackathonId, input.hackathonId), - ), - }); - - if (judge) { - await tx - .update(judges) - .set({ - name: input.name, - email: input.email, - phone: input.phone, - company: input.company, - title: input.title, - specialty: input.specialty, - linkedinUrl: input.linkedinUrl, - githubUrl: input.githubUrl, - previousExperience: input.previousExperience, - dietaryRestrictions: input.dietaryRestrictions || [], - shirtSize: input.shirtSize, - whyJudge: input.whyJudge, - }) - .where(eq(judges.id, judge.id)); - } else { - const inserted = await tx - .insert(judges) - .values({ - userId: ctx.userId, - hackathonId: input.hackathonId, - name: input.name, - email: input.email, - phone: input.phone, - company: input.company, - title: input.title, - specialty: input.specialty, - linkedinUrl: input.linkedinUrl || null, - githubUrl: input.githubUrl || null, - previousExperience: input.previousExperience, - dietaryRestrictions: input.dietaryRestrictions || [], - shirtSize: input.shirtSize, - whyJudge: input.whyJudge, - isActive: false, // Must be approved by admin - }) - .returning(); - judge = inserted[0]; - } - - // Create the hackathon assignment request - if (!judge) - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to create judge profile", - }); - - const existingAssignment = await tx.query.judgeAssignments.findFirst({ - where: and( - eq(judgeAssignments.judgeId, judge.id), - eq(judgeAssignments.hackathonId, input.hackathonId), - ), - }); - - if (existingAssignment) { - throw new TRPCError({ - code: "CONFLICT", - message: "You have already applied to judge this hackathon.", - }); - } - - // Free text used to land straight in the routing column, so a judge who typed - // "ai" instead of "AI" was classified sponsor/special and given an empty - // pool — invisible to them and to the organiser. - const track = await assertTrackExists( - tx as unknown as DrizzleDB, - input.hackathonId, - input.preferredTrack, - ); - - await tx.insert(judgeAssignments).values({ - judgeId: judge.id, - hackathonId: input.hackathonId, - track, - status: "pending", - }); - - return { success: true }; - }); - }), }); diff --git a/packages/api/src/routers/judge/dispatch.db.test.ts b/packages/api/src/routers/judge/dispatch.db.test.ts deleted file mode 100644 index de11abce..00000000 --- a/packages/api/src/routers/judge/dispatch.db.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * The judging pool against a real Postgres, at Hacklytics scale. - * - * Mocks cannot show what matters here: that the dispatch lock keeps two - * judges off one table under real concurrency, and that coverage evens out - * over hundreds of projects whatever the head count. Runs only when - * JUDGING_TEST_DATABASE_URL points at a disposable database with the schema - * pushed (drizzle-kit push). Every test seeds its own hackathon. - * - * docker run -d --name judging-test -e POSTGRES_PASSWORD=pw -p 5498:5432 postgres:18 - * DATABASE_URL=postgresql://postgres:pw@localhost:5498/postgres pnpm --filter @query/db exec drizzle-kit push - * JUDGING_TEST_DATABASE_URL=postgresql://postgres:pw@localhost:5498/postgres pnpm --filter @query/api test dispatch.db - */ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { randomUUID } from "node:crypto"; -import { drizzle } from "drizzle-orm/node-postgres"; -import { and, eq, inArray, sql } from "drizzle-orm"; -import * as schema from "@query/db"; -import type { DrizzleDB } from "@query/db"; -import { - JUDGE_HARD_LIMIT_SECONDS, - JUDGE_WALK_LIMIT_SECONDS, - dispatchNext, -} from "./dispatch"; - -const URL = process.env.JUDGING_TEST_DATABASE_URL; - -const { - users, - hackathons, - judges, - judgeAssignments, - judgingProjects, - judgeQueue, - judgeVotes, -} = schema; - -let db: DrizzleDB; - -const at = (base: Date, seconds: number) => new Date(base.getTime() + seconds * 1000); - -async function seed(opts: { - projects: number; - judges: number; - tracks?: string[]; - projectTracks?: (i: number) => { tracks?: string[]; challenges?: string[] }; - judgeTrack?: (i: number) => string | null; -}) { - const hackathonId = randomUUID(); - await db.insert(hackathons).values({ - id: hackathonId, - name: `pool-test-${hackathonId.slice(0, 8)}`, - startDate: new Date(), - endDate: new Date(), - judgingActive: true, - tracks: opts.tracks ?? [], - } as typeof hackathons.$inferInsert); - - const projectIds: string[] = []; - const projectRows = Array.from({ length: opts.projects }, (_, i) => { - const id = randomUUID(); - projectIds.push(id); - const t = opts.projectTracks?.(i) ?? {}; - return { - id, - hackathonId, - name: `Project ${i + 1}`, - tableNumber: i + 1, - tracks: t.tracks ?? null, - challenges: t.challenges ?? null, - } as typeof judgingProjects.$inferInsert; - }); - for (let i = 0; i < projectRows.length; i += 200) { - await db.insert(judgingProjects).values(projectRows.slice(i, i + 200)); - } - - const judgeIds: string[] = []; - for (let i = 0; i < opts.judges; i++) { - const userId = randomUUID(); - const judgeId = randomUUID(); - judgeIds.push(judgeId); - await db.insert(users).values({ - id: userId, - email: `judge-${userId}@test.local`, - } as typeof users.$inferInsert); - await db.insert(judges).values({ - id: judgeId, - userId, - hackathonId, - isActive: true, - } as typeof judges.$inferInsert); - await db.insert(judgeAssignments).values({ - judgeId, - hackathonId, - track: opts.judgeTrack?.(i) ?? null, - } as typeof judgeAssignments.$inferInsert); - } - - return { hackathonId, projectIds, judgeIds }; -} - -const dispatch = (judgeId: string, hackathonId: string, now: Date) => - db.transaction((tx) => dispatchNext(tx as unknown as DrizzleDB, judgeId, hackathonId, now)); - -/** What completeAndNext does for a score inside the window. */ -async function score(judgeId: string, queueId: string, projectId: string, now: Date) { - await db.insert(judgeVotes).values({ - judgeId, - projectId, - score: 30, - scoreCreativity: 6, - scoreImpact: 6, - scoreScope: 6, - scoreClarity: 6, - scoreSoundness: 6, - }); - await db - .update(judgeQueue) - .set({ isCompleted: true, completedAt: now }) - .where(eq(judgeQueue.id, queueId)); -} - -async function arrive(queueId: string, now: Date) { - await db.update(judgeQueue).set({ arrivedAt: now }).where(eq(judgeQueue.id, queueId)); -} - -async function looksPerProject(projectIds: string[]) { - const rows = await db - .select({ projectId: judgeVotes.projectId, n: sql`count(*)::int` }) - .from(judgeVotes) - .where(inArray(judgeVotes.projectId, projectIds)) - .groupBy(judgeVotes.projectId); - const map = new Map(projectIds.map((id) => [id, 0])); - for (const r of rows) map.set(r.projectId, r.n); - return [...map.values()]; -} - -describe.skipIf(!URL)("judging pool on Postgres", () => { - beforeAll(() => { - db = drizzle({ connection: { connectionString: URL!, max: 50 }, schema }) as unknown as DrizzleDB; - }); - afterAll(async () => { - await (db as unknown as { $client: { end: () => Promise } } | undefined)?.$client.end(); - }); - - it("never sends two judges to the same table when 60 ask at once", async () => { - const { hackathonId, judgeIds } = await seed({ projects: 500, judges: 60 }); - const now = new Date(); - - const handed = await Promise.all(judgeIds.map((j) => dispatch(j, hackathonId, now))); - - const projects = handed.map((h) => (h.done ? null : h.project.id)); - expect(projects.every((p) => p !== null)).toBe(true); - expect(new Set(projects).size).toBe(60); - }, 30_000); - - it("gives every one of 500 projects two looks before any gets a third, with judges joining late", async () => { - const { hackathonId, projectIds, judgeIds } = await seed({ projects: 500, judges: 40 }); - const start = new Date("2027-02-28T13:00:00Z"); - - // 25 judges start; the other 15 turn up 40 minutes in. Each look is a - // minute's walk and two and a half minutes at the table. - let clock = 0; - const onFloor = new Set(judgeIds.slice(0, 25)); - let scored = 0; - while (scored < 1000) { - if (clock >= 40 * 60) judgeIds.slice(25).forEach((j) => onFloor.add(j)); - for (const judgeId of onFloor) { - if (scored >= 1000) break; - const t = at(start, clock); - const next = await dispatch(judgeId, hackathonId, t); - if (next.done) continue; - await arrive(next.queueId, at(t, 60)); - await score(judgeId, next.queueId, next.project.id, at(t, 210)); - scored++; - } - clock += 210; - } - - const looks = await looksPerProject(projectIds); - expect(Math.min(...looks)).toBe(2); - expect(Math.max(...looks)).toBe(2); - }, 120_000); - - it("hands a no-show's table to someone else once the walk limit passes", async () => { - const { hackathonId, judgeIds } = await seed({ projects: 2, judges: 2 }); - const [absent, present] = judgeIds as [string, string]; - const t0 = new Date("2027-02-28T13:00:00Z"); - - const held = await dispatch(absent, hackathonId, t0); - if (held.done) throw new Error("expected a table"); - - // While the absent judge could still be walking over, their table is held. - const other = await dispatch(present, hackathonId, at(t0, 30)); - if (other.done) throw new Error("expected a table"); - expect(other.project.id).not.toBe(held.project.id); - await arrive(other.queueId, at(t0, 60)); - await score(present, other.queueId, other.project.id, at(t0, 200)); - - // They never arrive. Past the walk limit the table is free again. - const late = await dispatch(present, hackathonId, at(t0, JUDGE_WALK_LIMIT_SECONDS + 1)); - if (late.done) throw new Error("expected the abandoned table"); - expect(late.project.id).toBe(held.project.id); - }); - - it("voids a look that passes the hard cutoff and puts the project back in the pool", async () => { - const { hackathonId, judgeIds } = await seed({ projects: 2, judges: 2 }); - const [slow, other] = judgeIds as [string, string]; - const t0 = new Date("2027-02-28T13:00:00Z"); - - const mine = await dispatch(slow, hackathonId, t0); - if (mine.done) throw new Error("expected a table"); - await arrive(mine.queueId, at(t0, 30)); - - // Past 4:00 (plus grace) without a score: asking again closes the visit - // and moves the judge on rather than handing back the same table. - const after = at(t0, 30 + JUDGE_HARD_LIMIT_SECONDS + 60); - const next = await dispatch(slow, hackathonId, after); - if (next.done) throw new Error("expected a second table"); - expect(next.project.id).not.toBe(mine.project.id); - - const [closed] = await db - .select() - .from(judgeQueue) - .where(eq(judgeQueue.id, mine.queueId)); - expect(closed?.isCompleted).toBe(true); - - // The voided project has no score, so it is the least-covered for others. - const theirs = await dispatch(other, hackathonId, after); - if (theirs.done) throw new Error("expected a table"); - expect(theirs.project.id).toBe(mine.project.id); - }); - - it("returns the same live table on a repeat request instead of claiming a second", async () => { - const { hackathonId, judgeIds } = await seed({ projects: 5, judges: 1 }); - const t0 = new Date("2027-02-28T13:00:00Z"); - const a = await dispatch(judgeIds[0]!, hackathonId, t0); - const b = await dispatch(judgeIds[0]!, hackathonId, at(t0, 20)); - if (a.done || b.done) throw new Error("expected tables"); - expect(b.queueId).toBe(a.queueId); - const rows = await db - .select() - .from(judgeQueue) - .where(and(eq(judgeQueue.hackathonId, hackathonId), eq(judgeQueue.judgeId, judgeIds[0]!))); - expect(rows).toHaveLength(1); - }); - - it("closes a live visit to a withdrawn project instead of holding it open beside new ones", async () => { - const { hackathonId, judgeIds } = await seed({ projects: 5, judges: 1 }); - const t0 = new Date("2027-02-28T13:00:00Z"); - const first = await dispatch(judgeIds[0]!, hackathonId, t0); - if (first.done) throw new Error("expected a table"); - await db - .update(judgingProjects) - .set({ withdrawnAt: at(t0, 10) }) - .where(eq(judgingProjects.id, first.project.id)); - - const second = await dispatch(judgeIds[0]!, hackathonId, at(t0, 20)); - const third = await dispatch(judgeIds[0]!, hackathonId, at(t0, 30)); - if (second.done || third.done) throw new Error("expected tables"); - expect(second.project.id).not.toBe(first.project.id); - expect(third.queueId).toBe(second.queueId); - - const open = await db - .select() - .from(judgeQueue) - .where(and(eq(judgeQueue.judgeId, judgeIds[0]!), eq(judgeQueue.isCompleted, false))); - expect(open.map((r) => r.id)).toEqual([second.queueId]); - }); - - it("clears a list built in advance by the old assignment instead of treating it as visits", async () => { - const { hackathonId, projectIds, judgeIds } = await seed({ projects: 3, judges: 1 }); - await db.insert(judgeQueue).values( - projectIds.map((projectId, i) => ({ - judgeId: judgeIds[0]!, - hackathonId, - projectId, - order: i + 1, - })), - ); - const next = await dispatch(judgeIds[0]!, hackathonId, new Date()); - expect(next.done).toBe(false); - const rows = await db - .select() - .from(judgeQueue) - .where(eq(judgeQueue.judgeId, judgeIds[0]!)); - expect(rows).toHaveLength(1); - }); - - it("keeps a sponsor judge to their challenge and counts their looks separately", async () => { - const { hackathonId, projectIds, judgeIds } = await seed({ - projects: 6, - judges: 2, - tracks: ["Finance"], - // Projects 1-3 entered the sponsor challenge. - projectTracks: (i) => ({ tracks: ["Finance"], challenges: i < 3 ? ["Sponsor"] : [] }), - judgeTrack: (i) => (i === 0 ? "Sponsor" : "Finance"), - }); - const [sponsor, main] = judgeIds as [string, string]; - const t0 = new Date("2027-02-28T13:00:00Z"); - - const seen: string[] = []; - for (let i = 0; i < 4; i++) { - const next = await dispatch(sponsor, hackathonId, at(t0, i * 300)); - if (next.done) break; - seen.push(next.project.id); - await arrive(next.queueId, at(t0, i * 300 + 10)); - await score(sponsor, next.queueId, next.project.id, at(t0, i * 300 + 100)); - } - expect(new Set(seen)).toEqual(new Set(projectIds.slice(0, 3))); - - // The sponsor's looks do not count as main coverage: the main judge still - // starts on a project nobody on the main panel has seen, which may be one - // of the three the sponsor already scored. - const next = await dispatch(main, hackathonId, at(t0, 2000)); - expect(next.done).toBe(false); - }); -}); diff --git a/packages/api/src/routers/judge/dispatch.test.ts b/packages/api/src/routers/judge/dispatch.test.ts deleted file mode 100644 index b029c2ac..00000000 --- a/packages/api/src/routers/judge/dispatch.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - JUDGE_HARD_LIMIT_SECONDS, - JUDGE_SUBMIT_GRACE_SECONDS, - JUDGE_WALK_LIMIT_SECONDS, - isLive, - isPastCutoff, - pickNext, - projectMatchesTrack, -} from "./dispatch"; -import type { Candidate } 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 c = (id: string, tableNumber: number, coverage = 0, claimedAt: number | null = null): Candidate => ({ - id, - tableNumber, - coverage, - claimedAt, -}); - -describe("pickNext", () => { - it("returns nothing when there is nothing left", () => { - expect(pickNext([], null)).toBeNull(); - }); - - it("takes the least-covered project", () => { - expect(pickNext([c("a", 1, 2), c("b", 2, 0), c("c", 3, 1)], null, 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)], 10, first); - expect(pick?.id).toBe("near"); - }); - - it("never trades coverage for distance", () => { - expect(pickNext([c("next-door", 11, 2), c("across", 200, 1)], 10, 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)], null, 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())], - null, - 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, 10, () => 0)?.id).toBe("a"); - expect(pickNext(tier, 10, () => 1)?.id).toBe("b"); - }); -}); - -describe("isLive", () => { - const walking = { startedAt: t0, arrivedAt: null, isCompleted: false }; - const atTable = { startedAt: t0, arrivedAt: at(60), isCompleted: false }; - - it("holds a table for the walk limit after hand-out", () => { - expect(isLive(walking, at(JUDGE_WALK_LIMIT_SECONDS))).toBe(true); - expect(isLive(walking, at(JUDGE_WALK_LIMIT_SECONDS + 1))).toBe(false); - }); - - it("holds it through the hard limit and grace after arrival", () => { - const end = 60 + JUDGE_HARD_LIMIT_SECONDS + JUDGE_SUBMIT_GRACE_SECONDS; - expect(isLive(atTable, at(end))).toBe(true); - expect(isLive(atTable, at(end + 1))).toBe(false); - }); - - it("measures from arrival, not hand-out, once the judge has tapped in", () => { - // Past the walk limit from hand-out, still well inside the judging window. - expect(isLive(atTable, at(JUDGE_WALK_LIMIT_SECONDS + 30))).toBe(true); - }); - - it("is never live once completed, or when never handed out", () => { - expect(isLive({ ...atTable, isCompleted: true }, at(61))).toBe(false); - expect(isLive({ startedAt: null, arrivedAt: null, isCompleted: false }, t0)).toBe(false); - }); -}); - -describe("isPastCutoff", () => { - it("allows a score at 4:00 and within the grace after it", () => { - expect(isPastCutoff(t0, at(JUDGE_HARD_LIMIT_SECONDS))).toBe(false); - expect(isPastCutoff(t0, at(JUDGE_HARD_LIMIT_SECONDS + JUDGE_SUBMIT_GRACE_SECONDS))).toBe(false); - }); - - it("refuses one after the grace", () => { - expect(isPastCutoff(t0, at(JUDGE_HARD_LIMIT_SECONDS + JUDGE_SUBMIT_GRACE_SECONDS + 1))).toBe(true); - }); -}); - -describe("projectMatchesTrack", () => { - const p = { tracks: ["Finance"], challenges: ["Sponsor"], isCreateX: true }; - it("matches every project when the judge has no track", () => { - expect(projectMatchesTrack(p, null)).toBe(true); - }); - it("matches by track, challenge and the createX flag", () => { - expect(projectMatchesTrack(p, "Finance")).toBe(true); - expect(projectMatchesTrack(p, "Sponsor")).toBe(true); - expect(projectMatchesTrack(p, "createX")).toBe(true); - expect(projectMatchesTrack(p, "Health")).toBe(false); - }); -}); diff --git a/packages/api/src/routers/judge/dispatch.ts b/packages/api/src/routers/judge/dispatch.ts deleted file mode 100644 index 810536ea..00000000 --- a/packages/api/src/routers/judge/dispatch.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { randomInt } from "node:crypto"; -import { and, eq, inArray, isNull, sql } from "drizzle-orm"; -import { - hackathons, - judgeAssignments, - judgeQueue, - judgeVotes, - judgingProjects, -} from "@query/db"; -import type { DrizzleDB } from "@query/db"; - -/** - * Judging runs from a shared pool, not per-judge lists built in advance. - * - * A judge asks for a table; the server hands them the eligible project with - * the fewest looks so far that they have not had and nobody else is at. A - * judge_queue row is written at that moment, so the table holds visits, not - * plans. Judges who arrive late start contributing immediately, judges who - * never show strand nothing, and coverage evens out on its own whatever the - * head count turns out to be. - */ - -/** Target time at a table; the judge page turns amber here. */ -export const JUDGE_TARGET_SECONDS = 180; -/** Hard cutoff from the tap or scan. No score by then and the look is void. */ -export const JUDGE_HARD_LIMIT_SECONDS = 240; -/** How long a handed-out table stays held while the judge walks to it. */ -export const JUDGE_WALK_LIMIT_SECONDS = 240; -/** Slack on the cutoff for a submit that left the phone just before 4:00. */ -export const JUDGE_SUBMIT_GRACE_SECONDS = 15; - -type Slot = { - startedAt: Date | null; - arrivedAt: Date | null; - isCompleted: boolean; -}; - -/** - * 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: Slot, now: Date): boolean { - if (slot.isCompleted) return false; - const t = now.getTime(); - if (slot.arrivedAt) { - return ( - t <= - slot.arrivedAt.getTime() + - (JUDGE_HARD_LIMIT_SECONDS + JUDGE_SUBMIT_GRACE_SECONDS) * 1000 - ); - } - if (slot.startedAt) { - return t <= slot.startedAt.getTime() + JUDGE_WALK_LIMIT_SECONDS * 1000; - } - return false; -} - -/** Past the hard cutoff, with grace: a score now arrives too late to count. */ -export function isPastCutoff(arrivedAt: Date, now: Date): boolean { - return ( - now.getTime() > - arrivedAt.getTime() + - (JUDGE_HARD_LIMIT_SECONDS + JUDGE_SUBMIT_GRACE_SECONDS) * 1000 - ); -} - -export type Candidate = { - id: string; - tableNumber: number; - /** 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; -}; - -/** - * The next project for one judge, from the projects they are eligible for and - * have not had. Fewest looks first; among equals the nearest table to where - * the judge just was, since at three minutes a table the walk is real time; - * 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. - */ -export function pickNext( - candidates: Candidate[], - lastTable: number | null, - random: (n: number) => number = (n) => randomInt(0, n), -): Candidate | null { - if (candidates.length === 0) return null; - - const free = candidates.filter((c) => c.claimedAt === null); - if (free.length === 0) { - return [...candidates].sort( - (a, b) => (a.claimedAt ?? 0) - (b.claimedAt ?? 0), - )[0]!; - } - - const fewest = Math.min(...free.map((c) => c.coverage)); - const tier = free.filter((c) => c.coverage === fewest); - - if (lastTable === null) return tier[random(tier.length)]!; - - const distance = (c: Candidate) => Math.abs(c.tableNumber - lastTable); - const nearest = Math.min(...tier.map(distance)); - const closest = tier.filter((c) => distance(c) === nearest); - return closest[random(closest.length)]!; -} - -/** Does this project fall inside a judge's track/challenge label? */ -export const projectMatchesTrack = ( - project: { - tracks: string[] | null; - challenges: string[] | null; - isCreateX?: boolean | null; - }, - track: string | null, -) => { - 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; -}; - -/** - * Serialises dispatch within one hackathon for the rest of the transaction. - * Choosing a table reads coverage and claims and then writes one; without - * this two judges asking at once could both be sent to the same table. - * Dispatch is a few dozen milliseconds and runs a handful of times a minute, - * so a single lock per hackathon costs nothing. - */ -export async function lockDispatch(tx: DrizzleDB, hackathonId: string) { - await tx.execute( - sql`select pg_advisory_xact_lock(hashtext(${`judging:${hackathonId}`}))`, - ); -} - -/** - * Coverage is counted within a judge's group. Sponsor/special-label judges - * cover their own pool for their own prize; looks from them do not stand in - * for the main panel and vice versa. With no main tracks configured every - * label counts as main, the same fallback the old assignment used. - */ -function groupOf(track: string | null, mainTracks: Set) { - return track && !mainTracks.has(track) ? `special:${track}` : "main"; -} - -export type DispatchResult = - | { done: true } - | { - done: false; - queueId: string; - project: typeof judgingProjects.$inferSelect; - startedAt: Date | null; - arrivedAt: Date | null; - }; - -/** - * Each judge's track label and the coverage group it puts them in. Queries - * run one after another: inside a transaction they share one connection, - * which cannot run two at once. - */ -export async function loadGroups(db: DrizzleDB, hackathonId: string) { - const hackathon = await db.query.hackathons.findFirst({ - where: eq(hackathons.id, hackathonId), - columns: { tracks: true }, - }); - const assignments = await db - .select({ judgeId: judgeAssignments.judgeId, track: judgeAssignments.track }) - .from(judgeAssignments) - .where(eq(judgeAssignments.hackathonId, hackathonId)); - - const mainTracks = new Set( - hackathon?.tracks?.length - ? hackathon.tracks - : assignments.map((a) => a.track).filter((t): t is string => !!t), - ); - const trackByJudge = new Map(assignments.map((a) => [a.judgeId, a.track])); - const groupByJudge = (id: string) => - groupOf(trackByJudge.get(id) ?? null, mainTracks); - - return { trackByJudge, groupByJudge }; -} - -/** - * Projects this judge is eligible for (only the columns routing needs), and - * the group they judge in. - */ -export async function loadPool( - db: DrizzleDB, - judgeId: string, - hackathonId: string, -) { - const { trackByJudge, groupByJudge } = await loadGroups(db, hackathonId); - const projects = await db - .select({ - id: judgingProjects.id, - tableNumber: judgingProjects.tableNumber, - tracks: judgingProjects.tracks, - challenges: judgingProjects.challenges, - isCreateX: judgingProjects.isCreateX, - }) - .from(judgingProjects) - .where( - and( - eq(judgingProjects.hackathonId, hackathonId), - isNull(judgingProjects.withdrawnAt), - ), - ); - - const myTrack = trackByJudge.get(judgeId) ?? null; - - return { - pool: projects.filter((p) => projectMatchesTrack(p, myTrack)), - myGroup: groupByJudge(judgeId), - groupByJudge, - }; -} - -const fullProject = async (tx: DrizzleDB, id: string) => - (await tx.query.judgingProjects.findFirst({ - where: eq(judgingProjects.id, id), - }))!; - -/** - * Hands the judge their next table. Must run inside a transaction. The pool - * (projects and tracks, which barely move during judging) is read first; then - * the hackathon's dispatch lock is taken for the part that has to be atomic: - * void the judge's own visits that ran out, return a visit that is still live - * (so a reload or a second tab gets the same table), and otherwise pick and - * claim a new one from current coverage and claims. - */ -export async function dispatchNext( - tx: DrizzleDB, - judgeId: string, - hackathonId: string, - now: Date = new Date(), -): Promise { - const { pool, myGroup, groupByJudge } = await loadPool(tx, judgeId, hackathonId); - - await lockDispatch(tx, hackathonId); - - const mine = await tx - .select({ - id: judgeQueue.id, - projectId: judgeQueue.projectId, - isCompleted: judgeQueue.isCompleted, - startedAt: judgeQueue.startedAt, - arrivedAt: judgeQueue.arrivedAt, - completedAt: judgeQueue.completedAt, - }) - .from(judgeQueue) - .where( - and( - eq(judgeQueue.judgeId, judgeId), - eq(judgeQueue.hackathonId, hackathonId), - ), - ); - - // A visit that ran out without a score is over; the project goes back to - // the pool for someone else, and not to this judge again. A row that was - // never handed out at all is a list an earlier version built in advance: - // it is not a visit, so it goes rather than blocking that project. - // A live visit to a project withdrawn since closes the same way. Left - // open, it would keep matching as this judge's live visit and send every - // later dispatch past it to claim yet another table. - const poolById = new Map(pool.map((p) => [p.id, p])); - const unopened = mine.filter( - (r) => !r.isCompleted && !r.startedAt && !r.arrivedAt, - ); - const lapsed = mine.filter( - (r) => - !r.isCompleted && - (r.startedAt || r.arrivedAt) && - (!isLive(r, now) || !poolById.has(r.projectId)), - ); - if (unopened.length > 0) { - await tx.delete(judgeQueue).where( - inArray( - judgeQueue.id, - unopened.map((r) => r.id), - ), - ); - } - if (lapsed.length > 0) { - await tx - .update(judgeQueue) - .set({ isCompleted: true, completedAt: now }) - .where( - inArray( - judgeQueue.id, - lapsed.map((r) => r.id), - ), - ); - } - const visits = mine.filter((r) => !unopened.includes(r)); - - const live = visits.find((r) => !lapsed.includes(r) && isLive(r, now)); - if (live) { - return { - done: false, - queueId: live.id, - project: await fullProject(tx, live.projectId), - startedAt: live.startedAt, - arrivedAt: live.arrivedAt, - }; - } - - const seen = new Set(visits.map((r) => r.projectId)); - const unseen = pool.filter((p) => !seen.has(p.id)); - if (unseen.length === 0) return { done: true }; - - // Scores and open visits for the whole hackathon: a few thousand rows at - // most, and one indexed read each rather than a 500-id parameter list. - const votes = await tx - .select({ judgeId: judgeVotes.judgeId, projectId: judgeVotes.projectId }) - .from(judgeVotes) - .innerJoin( - judgingProjects, - and( - eq(judgingProjects.id, judgeVotes.projectId), - eq(judgingProjects.hackathonId, hackathonId), - ), - ); - const open = await tx - .select({ - judgeId: judgeQueue.judgeId, - projectId: judgeQueue.projectId, - startedAt: judgeQueue.startedAt, - arrivedAt: judgeQueue.arrivedAt, - isCompleted: judgeQueue.isCompleted, - }) - .from(judgeQueue) - .where( - and( - eq(judgeQueue.hackathonId, hackathonId), - eq(judgeQueue.isCompleted, false), - ), - ); - - const coverage = new Map(); - const bump = (projectId: string) => - coverage.set(projectId, (coverage.get(projectId) ?? 0) + 1); - for (const v of votes) if (groupByJudge(v.judgeId) === myGroup) bump(v.projectId); - - const claimedAt = new Map(); - for (const row of open) { - if (row.judgeId === judgeId || !isLive(row, now)) continue; - if (groupByJudge(row.judgeId) === myGroup) bump(row.projectId); - const at = (row.arrivedAt ?? row.startedAt)!.getTime(); - claimedAt.set(row.projectId, Math.max(claimedAt.get(row.projectId) ?? 0, at)); - } - - const stamp = (r: (typeof visits)[number]) => - (r.completedAt ?? r.arrivedAt ?? r.startedAt ?? new Date(0)).getTime(); - const lastVisit = [...visits].sort((a, b) => stamp(b) - stamp(a))[0]; - - const choice = pickNext( - unseen.map((p) => ({ - id: p.id, - tableNumber: p.tableNumber, - coverage: coverage.get(p.id) ?? 0, - claimedAt: claimedAt.get(p.id) ?? null, - })), - lastVisit ? (poolById.get(lastVisit.projectId)?.tableNumber ?? null) : null, - ); - if (!choice) return { done: true }; - - const [row] = await tx - .insert(judgeQueue) - .values({ - judgeId, - hackathonId, - projectId: choice.id, - order: visits.length + 1, - startedAt: now, - }) - .returning({ id: judgeQueue.id }); - - return { - done: false, - queueId: row!.id, - project: await fullProject(tx, choice.id), - startedAt: now, - arrivedAt: null, - }; -} diff --git a/packages/api/src/routers/judge/helpers-edge.test.ts b/packages/api/src/routers/judge/helpers-edge.test.ts deleted file mode 100644 index 3b4753d4..00000000 --- a/packages/api/src/routers/judge/helpers-edge.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { zNormalize } from "./helpers"; - -/** - * The maths that decides who wins. - * - * These are the invariants a result has to satisfy to be defensible: a judge's - * own ordering must survive normalization, two judges who rank the same order - * must agree after it whatever their personal harshness. - */ - -const mean = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length; -const stddev = (xs: number[]) => { - const m = mean(xs); - return Math.sqrt(mean(xs.map((x) => (x - m) ** 2))); -}; - -describe("zNormalize — properties", () => { - it("preserves a judge's own ordering", () => { - const normalized = zNormalize([1, 4, 9, 10], 5, 2); - - for (let i = 1; i < normalized.length; i++) { - expect(normalized[i]!).toBeGreaterThan(normalized[i - 1]!); - } - }); - - /** - * The entire point of normalizing: a harsh judge and a lenient one who put - * the same projects in the same order must produce the same numbers, so - * neither one's temperament decides the result. - */ - 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]).toBeCloseTo(lenient[0]!, 10); - expect(harsh[1]).toBeCloseTo(lenient[1]!, 10); - expect(harsh[2]).toBeCloseTo(lenient[2]!, 10); - }); - - it("makes a wide-spread judge and a narrow-spread judge agree on ordering", () => { - const wide = zNormalize([0, 5, 10], 5, 2); - const narrow = zNormalize([4, 5, 6], 5, 2); - - expect(wide[0]).toBeCloseTo(narrow[0]!, 10); - expect(wide[2]).toBeCloseTo(narrow[2]!, 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("returns the global mean when a judge scored everything the same", () => { - expect(zNormalize([7, 7, 7], 5, 2)).toEqual([5, 5, 5]); - }); - - it("returns the global mean for a judge with a single score", () => { - expect(zNormalize([9], 5, 2)).toEqual([5]); - }); - - it("returns nothing for a judge with no scores", () => { - expect(zNormalize([], 5, 2)).toEqual([]); - }); - - 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 and zero scores", () => { - const normalized = zNormalize([-5, 0, 5], 5, 2); - expect(normalized.every((v) => Number.isFinite(v))).toBe(true); - expect(normalized[0]!).toBeLessThan(normalized[2]!); - }); - - it("collapses to the global mean when the global spread is zero", () => { - expect(zNormalize([1, 5, 9], 6, 0)).toEqual([6, 6, 6]); - }); - - it("keeps two equal scores equal after normalizing", () => { - const normalized = zNormalize([3, 3, 9], 5, 2); - expect(normalized[0]).toBeCloseTo(normalized[1]!, 10); - }); -}); diff --git a/packages/api/src/routers/judge/helpers.test.ts b/packages/api/src/routers/judge/helpers.test.ts deleted file mode 100644 index 96598aba..00000000 --- a/packages/api/src/routers/judge/helpers.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { zNormalize } from "./helpers"; - -describe("zNormalize", () => { - it("returns global mean when fewer than two scores", () => { - expect(zNormalize([8], 5, 2)).toEqual([5]); - expect(zNormalize([], 5, 2)).toEqual([]); - }); - - it("returns global mean when judge gave identical scores", () => { - 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]).toBeLessThan(5); - expect(normalized[1]).toBeCloseTo(5, 1); - expect(normalized[2]).toBeGreaterThan(5); - }); -}); diff --git a/packages/api/src/routers/judge/helpers.ts b/packages/api/src/routers/judge/helpers.ts deleted file mode 100644 index 2319cfd6..00000000 --- a/packages/api/src/routers/judge/helpers.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Shared judge scoring utilities - -// Z-score normalizes a judge's scores against their own mean and stddev, -// removing per-judge harshness before aggregation. Null under 2 data points. -export function zNormalize( - scores: number[], - globalMean: number, - globalStd: number, -): number[] { - if (scores.length < 2) return scores.map(() => globalMean); - const mean = scores.reduce((a, b) => a + b, 0) / scores.length; - const variance = - scores.reduce((s, v) => s + (v - mean) ** 2, 0) / scores.length; - const std = Math.sqrt(variance); - if (std === 0) return scores.map(() => globalMean); // judge gave same score to everything - return scores.map((v) => globalMean + ((v - mean) / std) * globalStd); -} diff --git a/packages/api/src/routers/judge/index.ts b/packages/api/src/routers/judge/index.ts index 674634b0..44bdf2ec 100644 --- a/packages/api/src/routers/judge/index.ts +++ b/packages/api/src/routers/judge/index.ts @@ -1,10 +1,7 @@ import { mergeRouters } from "../../trpc"; import { judgePortalRouter } from "./portal"; -import { judgeRankingsRouter } from "./rankings"; import { judgeAdminRouter } from "./admin"; -export const judgeRouter = mergeRouters( - judgePortalRouter, - judgeRankingsRouter, - judgeAdminRouter, -); +// The judge roster: applications, approval and the "am I a judge" lookup. +// Judging itself (rooms, schedule, desk, results) is routers/judging. +export const judgeRouter = mergeRouters(judgePortalRouter, judgeAdminRouter); diff --git a/packages/api/src/routers/judge/portal.ts b/packages/api/src/routers/judge/portal.ts index b0afda0a..24ab98ff 100644 --- a/packages/api/src/routers/judge/portal.ts +++ b/packages/api/src/routers/judge/portal.ts @@ -4,31 +4,62 @@ import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { judges, judgeAssignments, - judgeVotes, - judgingProjects, - judgeQueue, hackathons, + hackathonParticipants, } from "@query/db"; -import { eq, and, asc, inArray, sql, isNull } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import { CacheKeys } from "../../middleware/cache"; -import { isAdmin, isJudge } from "../../middleware/procedures"; +import { notHackathonBanned } from "../../middleware/procedures"; import { resolveHackathonId } from "../../services/portal-context"; -import { - dispatchNext, - isLive, - isPastCutoff, - loadPool, - lockDispatch, -} from "./dispatch"; import type { DrizzleDB } from "@query/db"; +// The track a judge would like, as the edition spells it. Organisers read it +// when seating judges, so a free-text value that matches none of the edition's +// labels would only be noise. "createX" is a label the submit form offers. +async function assertTrackExists( + db: DrizzleDB, + hackathonId: string, + track: string | null | undefined, +): Promise { + if (!track) return null; + + const hackathon = await db.query.hackathons.findFirst({ + where: eq(hackathons.id, hackathonId), + columns: { tracks: true, challenges: true }, + }); + + const allowed = [ + ...(hackathon?.tracks ?? []), + ...(hackathon?.challenges ?? []), + "createX", + ]; + + // Case-insensitive match, but the stored value is the edition's own spelling. + const match = allowed.find( + (t) => t.toLowerCase() === track.trim().toLowerCase(), + ); + + if (!match) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + allowed.length === 1 + ? "This hackathon has no tracks or challenges configured yet. Leave the track empty and an organiser will place you." + : `"${track}" is not a track or challenge on this hackathon. Choose one of: ${allowed.join(", ")}.`, + }); + } + + return match; +} +// The judge's own side of the roster: am I a judge, what have I applied to, +// and the application itself. export const judgePortalRouter = createTRPCRouter({ isJudge: protectedProcedure .input(z.object({ hackathonId: z.string().uuid().optional() }).optional()) .query(async ({ ctx, input }) => { - // Same rule as the isJudge middleware; ordering by start date alone picks - // next year's draft. + // The platform's single definition of the current edition; ordering by + // start date alone picks next year's draft. const hackathonId = await resolveHackathonId( ctx.db as DrizzleDB, input?.hackathonId, @@ -39,6 +70,7 @@ export const judgePortalRouter = createTRPCRouter({ isJudge: false, judgeId: null, name: null, + hackathonId: null, }; } @@ -47,6 +79,7 @@ export const judgePortalRouter = createTRPCRouter({ isJudge: boolean; judgeId: string | null; name: string | null; + hackathonId: string | null; }>(cacheKey); if (cached) return cached; @@ -62,70 +95,22 @@ export const judgePortalRouter = createTRPCRouter({ isJudge: !!judge, judgeId: judge?.id || null, name: judge?.name || null, + // Which edition the answer is for, since the caller may not have named one. + hackathonId, }; ctx.cache.set(cacheKey, result, 60); return result; }), - getMyAssignments: protectedProcedure.query(async ({ ctx }) => { - const db = ctx.db as DrizzleDB; - - // Both reads answer independent questions, so they go together. In - // sequence they put two Neon round trips in front of the judge landing - // page for no reason — the judge rows do not depend on the existence - // check, they only outlive it. - const [anyHackathon, myJudges] = await Promise.all([ - // A platform with no hackathon at all is a missing judging context, not an - // empty assignment list. - db.query.hackathons.findFirst({ - columns: { id: true }, - }), - // This listing spans every hackathon the caller judges, so it resolves judge - // rows from the user rather than one hackathon context — pinning it to the - // newest hides the assignments of everyone judging an earlier one. - db.query.judges.findMany({ - where: and( - eq(judges.userId, ctx.userId as string), - // Every other judging entry point requires an active row; an applicant never - // activated should not see an assignment list and then hit FORBIDDEN. - eq(judges.isActive, true), - ), - columns: { id: true }, - }), - ]); - - if (!anyHackathon) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "No hackathon context found for judging", - }); - } - - const assignments = await db.query.judgeAssignments.findMany({ - where: inArray( - judgeAssignments.judgeId, - myJudges.map((j) => j.id), - ), - with: { - hackathon: true, - }, - orderBy: (assignments, { desc }) => [desc(assignments.assignedAt)], - }); - - return assignments; - }), - - // Every judging application this user has made, approved or not. - // getMyAssignments shows only approved rows, so between applying and approval - // a judge had no entry point, no status and no email — while the success - // screen promised one and the apply button threw "already applied". + // Every judging application this user has made, approved or not, so an + // applicant waiting on approval still has a status to look at. myApplications: protectedProcedure.query(async ({ ctx }) => { const db = ctx.db as DrizzleDB; const myJudges = await db.query.judges.findMany({ where: eq(judges.userId, ctx.userId as string), - columns: { id: true, hackathonId: true, isActive: true }, + columns: { id: true, hackathonId: true, isActive: true, createdAt: true }, }); if (myJudges.length === 0) return []; @@ -139,647 +124,174 @@ export const judgePortalRouter = createTRPCRouter({ }); const activeById = new Map(myJudges.map((j) => [j.id, j.isActive])); - - return rows.map((row) => ({ - hackathonId: row.hackathonId, - track: row.track, - appliedAt: row.assignedAt, - // The judges row is what every judging gate reads, so it — not the - // assignment's own status column, which nothing reads — decides this. - approved: activeById.get(row.judgeId) === true, - })); + const applied = new Set(rows.map((row) => row.judgeId)); + + return [ + ...rows.map((row) => ({ + hackathonId: row.hackathonId, + track: row.track, + appliedAt: row.assignedAt, + // The judges row is what every judging gate reads, so it — not the + // assignment's own status column — decides this. + approved: activeById.get(row.judgeId) === true, + })), + // An organiser who adds a judge directly writes the judges row and no + // assignment. Without these the register page offered that judge the + // form again, and submitting it overwrote their row. + ...myJudges + .filter((judge) => !applied.has(judge.id)) + .map((judge) => ({ + hackathonId: judge.hackathonId, + track: null, + appliedAt: judge.createdAt, + approved: judge.isActive, + })), + ]; }), - // Starts the clock at the table: the tap on its NFC tag or the scan of its - // card. Scoring time runs from here, not from hand-out, since walking across - // a ballroom is not judging, and the hard cutoff counts from here too. It - // also confirms the judge is at the table they were sent to. Idempotent: - // tapping twice cannot restart the clock. - startByQrCode: isJudge - .input(z.object({ qrCode: z.string().uuid("Invalid table code") })) - .mutation(async ({ ctx, input }) => { - const db = ctx.db as DrizzleDB; - const now = new Date(); - - const project = await db.query.judgingProjects.findFirst({ - where: eq(judgingProjects.qrCode, input.qrCode), - }); - - if (!project || project.withdrawnAt) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "That code does not match a project in this event.", - }); - } - - return await db.transaction(async (rawTx) => { - const tx = rawTx as unknown as DrizzleDB; - await lockDispatch(tx, project.hackathonId); - - const slot = await tx.query.judgeQueue.findFirst({ - where: and( - eq(judgeQueue.judgeId, ctx.judge.id), - eq(judgeQueue.projectId, project.id), - ), - }); - - // Only the table the pool sent this judge to. Starting any other would - // let judges pick their favourites and skew coverage. - if (!slot) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `Table ${project.tableNumber} is not the table you were sent to. Check the number on your screen.`, - }); - } - - if (slot.isCompleted) { - const voted = await tx.query.judgeVotes.findFirst({ - where: and( - eq(judgeVotes.judgeId, ctx.judge.id), - eq(judgeVotes.projectId, project.id), - ), - columns: { id: true }, - }); - throw new TRPCError({ - code: "CONFLICT", - message: voted - ? `You already scored table ${project.tableNumber}.` - : `Time ran out on table ${project.tableNumber}, so it has gone back to be judged by someone else. Get your next table.`, - }); - } - - if (slot.arrivedAt) { - // Already started. Past the cutoff the look is void; the next - // dispatch closes the row (a write here would roll back with the throw). - if (isPastCutoff(slot.arrivedAt, now)) { - throw new TRPCError({ - code: "CONFLICT", - message: `Time ran out on table ${project.tableNumber}, so it has gone back to be judged by someone else. Get your next table.`, - }); - } - return { - project, - queueId: slot.id, - alreadyStarted: true, - arrivedAt: slot.arrivedAt, - serverNow: now, - }; - } - - // The walk ran long and the hold lapsed. Still theirs unless another - // judge has since been sent there. - if (!isLive(slot, now)) { - const others = await tx.query.judgeQueue.findMany({ - where: and( - eq(judgeQueue.projectId, project.id), - eq(judgeQueue.isCompleted, false), - ), - columns: { - judgeId: true, - startedAt: true, - arrivedAt: true, - isCompleted: true, - }, - }); - if (others.some((o) => o.judgeId !== ctx.judge.id && isLive(o, now))) { - throw new TRPCError({ - code: "CONFLICT", - message: `Another judge has table ${project.tableNumber} now. Get your next table.`, - }); - } - } - - await tx - .update(judgeQueue) - .set({ arrivedAt: now, startedAt: slot.startedAt ?? now }) - .where(eq(judgeQueue.id, slot.id)); - - return { - project, - queueId: slot.id, - alreadyStarted: false, - arrivedAt: now, - serverNow: now, - }; - }); - }), - - // The judge's current table: the live one they hold, or a fresh pick from - // the pool. See dispatch.ts. - getNextTable: isJudge - .input(z.object({ hackathonId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - try { - const now = new Date(); - const next = await (ctx.db as DrizzleDB).transaction((tx) => - dispatchNext(tx as unknown as DrizzleDB, ctx.judge.id, input.hackathonId, now), - ); - if (next.done) return { done: true as const, project: null, serverNow: now }; - return { - done: false as const, - project: next.project, - queueId: next.queueId, - arrivedAt: next.arrivedAt, - serverNow: now, - }; - } catch (error) { - // A NOT_FOUND or FORBIDDEN from inside is the judge's answer, not a 500. - if (error instanceof TRPCError) throw error; - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch next project.", - cause: error, - }); - } - }), - - getProjects: isJudge - .input(z.object({ hackathonId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - const projects = await ( - ctx.db as DrizzleDB - ).query.judgingProjects.findMany({ - // A withdrawn entry is no longer part of the event. - where: and( - eq(judgingProjects.hackathonId, input.hackathonId), - isNull(judgingProjects.withdrawnAt), - ), - orderBy: [asc(judgingProjects.tableNumber)], - }); - - const myVotes = await (ctx.db as DrizzleDB).query.judgeVotes.findMany({ - where: eq(judgeVotes.judgeId, ctx.judge.id), - }); - - const votesMap = new Map(myVotes.map((v) => [v.projectId, v])); - - return projects.map((p) => ({ - ...p, - myVote: votesMap.get(p.id) || null, - hasVoted: votesMap.has(p.id), - })); - }), - - getJudgingStatus: protectedProcedure - .input(z.object({ hackathonId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - const hackathon = await (ctx.db as DrizzleDB).query.hackathons.findFirst({ - where: eq(hackathons.id, input.hackathonId), - columns: { judgingActive: true }, - }); - return { active: hackathon?.judgingActive ?? false }; - }), - - toggleJudging: isAdmin + register: notHackathonBanned .input( z.object({ hackathonId: z.string().uuid(), - active: z.boolean(), + name: z.string().min(1).max(200), + email: z.string().email().max(200), + phone: z.string().max(20).optional(), + company: z.string().max(200).optional(), + title: z.string().max(200).optional(), + specialty: z.string().max(200).optional(), + linkedinUrl: z.string().url().max(500).optional().or(z.literal("")), + githubUrl: z.string().url().max(500).optional().or(z.literal("")), + previousExperience: z.string().max(2000).optional(), + dietaryRestrictions: z.array(z.string()).optional(), + shirtSize: z.string().optional(), + whyJudge: z.string().max(2000).optional(), + preferredTrack: z.string().optional(), }), ) .mutation(async ({ ctx, input }) => { - // Opening judging with nothing to judge sent every judge straight to - // "All done". Judges draw tables from the pool of judgeable projects, so - // that pool is what has to exist: promote submissions first. - if (input.active) { - const [judgeable] = await (ctx.db as DrizzleDB) - .select({ n: sql`count(*)::int` }) - .from(judgingProjects) - .where( - and( - eq(judgingProjects.hackathonId, input.hackathonId), - isNull(judgingProjects.withdrawnAt), - ), - ); - if ((judgeable?.n ?? 0) === 0) { + return await (ctx.db as DrizzleDB).transaction(async (tx) => { + // Only an edition people can see and that is not over takes judges. Any + // uuid used to pass, and one that did not exist hit the foreign key as + // a 500. Announced counts: judges are recruited before registration. + const hackathon = await tx.query.hackathons.findFirst({ + where: eq(hackathons.id, input.hackathonId), + columns: { isPublic: true, status: true }, + }); + if ( + !hackathon || + !hackathon.isPublic || + !["announced", "open", "closed", "in_progress"].includes( + hackathon.status, + ) + ) { throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: - "There are no projects to judge yet. Prepare judging first: it promotes submissions into judging on this page.", + code: "NOT_FOUND", + message: "That hackathon is not taking judge applications.", }); } - } - - const [updated] = await (ctx.db as DrizzleDB) - .update(hackathons) - .set({ judgingActive: input.active, updatedAt: new Date() }) - .where(eq(hackathons.id, input.hackathonId)) - .returning(); - return { success: true, judgingActive: updated?.judgingActive }; - }), - - // Writes or revises a score without moving on. A first score is held to the - // same clock as completeAndNext: the judge must have started the table and - // be inside the hard cutoff. Revising a score already on record is allowed - // while judging is open. - submitVote: isJudge - .input( - z.object({ - projectId: z.string().uuid(), - scoreCreativity: z.number().min(1).max(10), - scoreImpact: z.number().min(1).max(10), - scoreScope: z.number().min(1).max(10), - scoreClarity: z.number().min(1).max(10), - scoreSoundness: z.number().min(1).max(10), - durationSeconds: z.number().int().min(0).optional(), - comment: z.string().max(1000).optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - const db = ctx.db as DrizzleDB; - const now = new Date(); - const totalScore = - input.scoreCreativity + - input.scoreImpact + - input.scoreScope + - input.scoreClarity + - input.scoreSoundness; - // Closing judging has to stop scores being written, or the upsert keeps - // overwriting results after the organizers have called the winners. - const hackathon = await db.query.hackathons.findFirst({ - where: eq(hackathons.id, ctx.judge.hackathonId), - columns: { judgingActive: true }, - }); - if (hackathon?.judgingActive === false) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Judging is closed for this hackathon", - }); - } - - // A judge may only score a table the pool sent them to. - const ownSlot = await db.query.judgeQueue.findFirst({ - where: and( - eq(judgeQueue.judgeId, ctx.judge.id), - eq(judgeQueue.projectId, input.projectId), - ), - }); - if (!ownSlot) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "This project is not in your judging queue", + // Check if user is registered as a participant for this hackathon + const participant = await tx.query.hackathonParticipants.findFirst({ + where: and( + eq(hackathonParticipants.hackathonId, input.hackathonId), + eq(hackathonParticipants.userId, ctx.userId as string), + ), }); - } - const existing = await db.query.judgeVotes.findFirst({ - where: and( - eq(judgeVotes.judgeId, ctx.judge.id), - eq(judgeVotes.projectId, input.projectId), - ), - columns: { id: true }, - }); - if (!existing) { - if (ownSlot.isCompleted || !ownSlot.arrivedAt) { - throw new TRPCError({ - code: ownSlot.isCompleted ? "CONFLICT" : "BAD_REQUEST", - message: ownSlot.isCompleted - ? "Time ran out on this table, so it is being judged again by someone else." - : "Tap the table's NFC tag or scan its card before scoring.", - }); - } - if (isPastCutoff(ownSlot.arrivedAt, now)) { + if (participant) { throw new TRPCError({ - code: "CONFLICT", + code: "BAD_REQUEST", message: - "Time ran out on this table, so it is being judged again by someone else.", + "You cannot apply to be a judge because you are registered as a participant for this hackathon.", }); } - } - - const durationSeconds = ownSlot.arrivedAt - ? Math.max(0, Math.round((now.getTime() - ownSlot.arrivedAt.getTime()) / 1000)) - : input.durationSeconds; - - const result = await db - .insert(judgeVotes) - .values({ - judgeId: ctx.judge.id, - projectId: input.projectId, - score: totalScore, - scoreCreativity: input.scoreCreativity, - scoreImpact: input.scoreImpact, - scoreScope: input.scoreScope, - scoreClarity: input.scoreClarity, - scoreSoundness: input.scoreSoundness, - durationSeconds: existing ? undefined : durationSeconds, - comment: input.comment, - }) - .onConflictDoUpdate({ - target: [judgeVotes.judgeId, judgeVotes.projectId], - set: { - score: sql`excluded.score`, - scoreCreativity: sql`excluded.score_creativity`, - scoreImpact: sql`excluded.score_impact`, - scoreScope: sql`excluded.score_scope`, - scoreClarity: sql`excluded.score_clarity`, - scoreSoundness: sql`excluded.score_soundness`, - comment: sql`excluded.comment`, - updatedAt: now, - }, - }) - .returning(); - - return result[0]; - }), - // Scores the current table and hands over the next one, in one transaction. - // Past the hard cutoff the score is refused and the look is void: the table - // goes back into the pool to be judged again, and the judge gets their next - // table with timedOut set so the page can say why. - completeAndNext: isJudge - .input( - z.object({ - queueId: z.string().uuid(), - projectId: z.string().uuid(), - scoreCreativity: z.number().min(1).max(10), - scoreImpact: z.number().min(1).max(10), - scoreScope: z.number().min(1).max(10), - scoreClarity: z.number().min(1).max(10), - scoreSoundness: z.number().min(1).max(10), - durationSeconds: z.number().int().min(0).optional(), - comment: z.string().max(1000).optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - const now = new Date(); - const totalScore = - input.scoreCreativity + - input.scoreImpact + - input.scoreScope + - input.scoreClarity + - input.scoreSoundness; - - const hackathon = await (ctx.db as DrizzleDB).query.hackathons.findFirst({ - where: eq(hackathons.id, ctx.judge.hackathonId), - columns: { judgingActive: true }, - }); - if (hackathon?.judgingActive === false) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Judging is closed for this hackathon", - }); - } - - return await (ctx.db as DrizzleDB).transaction(async (rawTx) => { - const tx = rawTx as unknown as DrizzleDB; - await lockDispatch(tx, ctx.judge.hackathonId); - - // 1. The slot is addressed by id alone, so it has to be read back and - // vetted before any score is written against it. - const queueItem = await tx.query.judgeQueue.findFirst({ - where: eq(judgeQueue.id, input.queueId), + // Find existing judge profile or create one for this hackathon + let judge = await tx.query.judges.findFirst({ + where: and( + eq(judges.userId, ctx.userId), + eq(judges.hackathonId, input.hackathonId), + ), }); - if (queueItem) { - if (queueItem.judgeId && queueItem.judgeId !== ctx.judge.id) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Queue item not found", - }); - } - if (queueItem.hackathonId !== ctx.judge.hackathonId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Queue item does not belong to this hackathon", - }); - } - // The slot being closed and the project being scored must be the same - // one, or a judge could score Y while X is stamped complete. - if (queueItem.projectId !== input.projectId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Queue item does not match the project being scored", - }); - } + if (judge) { + await tx + .update(judges) + .set({ + name: input.name, + email: input.email, + phone: input.phone, + company: input.company, + title: input.title, + specialty: input.specialty, + linkedinUrl: input.linkedinUrl, + githubUrl: input.githubUrl, + previousExperience: input.previousExperience, + dietaryRestrictions: input.dietaryRestrictions || [], + shirtSize: input.shirtSize, + whyJudge: input.whyJudge, + }) + .where(eq(judges.id, judge.id)); + } else { + const inserted = await tx + .insert(judges) + .values({ + userId: ctx.userId, + hackathonId: input.hackathonId, + name: input.name, + email: input.email, + phone: input.phone, + company: input.company, + title: input.title, + specialty: input.specialty, + linkedinUrl: input.linkedinUrl || null, + githubUrl: input.githubUrl || null, + previousExperience: input.previousExperience, + dietaryRestrictions: input.dietaryRestrictions || [], + shirtSize: input.shirtSize, + whyJudge: input.whyJudge, + isActive: false, // Must be approved by admin + }) + .returning(); + judge = inserted[0]; } - // A queue id that no longer resolves is tolerated (the judge may be - // retrying) but never drops the ownership test. - const slot = - queueItem ?? - (await tx.query.judgeQueue.findFirst({ - where: and( - eq(judgeQueue.judgeId, ctx.judge.id), - eq(judgeQueue.projectId, input.projectId), - ), - })); - if (!slot) { + if (!judge) throw new TRPCError({ - code: "FORBIDDEN", - message: "This project is not in your judging queue", + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create judge profile", }); - } - const existing = await tx.query.judgeVotes.findFirst({ + // The application itself: one per judge per edition, carrying the + // preferred track. + const existingAssignment = await tx.query.judgeAssignments.findFirst({ where: and( - eq(judgeVotes.judgeId, ctx.judge.id), - eq(judgeVotes.projectId, input.projectId), + eq(judgeAssignments.judgeId, judge.id), + eq(judgeAssignments.hackathonId, input.hackathonId), ), - columns: { id: true }, }); - const handOver = async (timedOut: boolean) => { - const next = await dispatchNext(tx, ctx.judge.id, ctx.judge.hackathonId, now); - return next.done - ? { done: true as const, nextProject: null, timedOut, serverNow: now } - : { - done: false as const, - nextProject: next.project, - nextQueueId: next.queueId, - nextArrivedAt: next.arrivedAt, - timedOut, - serverNow: now, - }; - }; - - // A retry of a completion that already landed: hand back the same next - // table rather than scoring twice. - if (slot.isCompleted) return handOver(!existing); - - if (!slot.arrivedAt) { + if (existingAssignment) { throw new TRPCError({ - code: "BAD_REQUEST", - message: "Tap the table's NFC tag or scan its card before scoring.", + code: "CONFLICT", + message: "You have already applied to judge this hackathon.", }); } - if (isPastCutoff(slot.arrivedAt, now)) { - await tx - .update(judgeQueue) - .set({ isCompleted: true, completedAt: now }) - .where(eq(judgeQueue.id, slot.id)); - return handOver(true); - } - - // Measured from the server-stamped arrival, which a stale tab or a - // skewed phone clock cannot shape. - const measuredSeconds = Math.max( - 0, - Math.round((now.getTime() - slot.arrivedAt.getTime()) / 1000), + const track = await assertTrackExists( + tx as unknown as DrizzleDB, + input.hackathonId, + input.preferredTrack, ); - await tx - .insert(judgeVotes) - .values({ - judgeId: ctx.judge.id, - projectId: input.projectId, - score: totalScore, - scoreCreativity: input.scoreCreativity, - scoreImpact: input.scoreImpact, - scoreScope: input.scoreScope, - scoreClarity: input.scoreClarity, - scoreSoundness: input.scoreSoundness, - durationSeconds: measuredSeconds, - comment: input.comment, - }) - .onConflictDoUpdate({ - target: [judgeVotes.judgeId, judgeVotes.projectId], - set: { - score: sql`excluded.score`, - scoreCreativity: sql`excluded.score_creativity`, - scoreImpact: sql`excluded.score_impact`, - scoreScope: sql`excluded.score_scope`, - scoreClarity: sql`excluded.score_clarity`, - scoreSoundness: sql`excluded.score_soundness`, - durationSeconds: sql`excluded.duration_seconds`, - comment: sql`excluded.comment`, - updatedAt: now, - }, - }); - - await tx - .update(judgeQueue) - .set({ isCompleted: true, completedAt: now }) - .where(eq(judgeQueue.id, slot.id)); - - return handOver(false); - }); - }), - - // Passes on the current table without scoring it. The project stays in the - // pool for other judges; this judge is not sent back to it. - skipProject: isJudge - .input(z.object({ queueId: z.string().uuid() })) - .mutation(async ({ ctx, input }) => { - const now = new Date(); - return await (ctx.db as DrizzleDB).transaction(async (rawTx) => { - const tx = rawTx as unknown as DrizzleDB; - - const queueItem = await tx.query.judgeQueue.findFirst({ - where: eq(judgeQueue.id, input.queueId), + await tx.insert(judgeAssignments).values({ + judgeId: judge.id, + hackathonId: input.hackathonId, + track, + status: "pending", }); - // The queue id addresses any judge's slot, so a row owned by someone - // else has to read as missing rather than as an actionable item. - if (!queueItem || queueItem.judgeId !== ctx.judge.id) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Queue item not found", - }); - } - - await lockDispatch(tx, queueItem.hackathonId); - - if (!queueItem.isCompleted) { - await tx - .update(judgeQueue) - .set({ isCompleted: true, completedAt: now }) - .where(eq(judgeQueue.id, queueItem.id)); - } - - const next = await dispatchNext(tx, ctx.judge.id, queueItem.hackathonId, now); - return next.done - ? { done: true as const, project: null, queueId: null, serverNow: now } - : { - done: false as const, - project: next.project, - queueId: next.queueId, - arrivedAt: next.arrivedAt, - serverNow: now, - }; + return { success: true }; }); }), - - // Kept for the judge page's "can't finish this one" path. With a shared pool - // nothing needs reassigning by hand: closing the slot without a score is - // enough for the next judge who asks to be sent there. - forceSkipOvertime: isJudge - .input(z.object({ queueId: z.string().uuid() })) - .mutation(async ({ ctx, input }) => { - const now = new Date(); - return await (ctx.db as DrizzleDB).transaction(async (rawTx) => { - const tx = rawTx as unknown as DrizzleDB; - - const queueItem = await tx.query.judgeQueue.findFirst({ - where: eq(judgeQueue.id, input.queueId), - }); - if (!queueItem || queueItem.judgeId !== ctx.judge.id) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Queue item not found", - }); - } - - // A finished slot has nothing to skip. - if (queueItem.isCompleted) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This project is already done.", - }); - } - - await lockDispatch(tx, queueItem.hackathonId); - await tx - .update(judgeQueue) - .set({ isCompleted: true, completedAt: now }) - .where(eq(judgeQueue.id, queueItem.id)); - - const next = await dispatchNext(tx, ctx.judge.id, queueItem.hackathonId, now); - return { - done: next.done, - project: next.done ? null : next.project, - queueId: next.done ? null : next.queueId, - // Back in the pool: the next judge who asks is sent there. - reassigned: true, - }; - }); - }), - - // Scored so far, out of the projects this judge is eligible for. - getProgress: isJudge - .input(z.object({ hackathonId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - try { - const db = ctx.db as DrizzleDB; - const { pool } = await loadPool(db, ctx.judge.id, input.hackathonId); - const [scored] = await db - .select({ n: sql`count(*)::int` }) - .from(judgeVotes) - .innerJoin( - judgingProjects, - and( - eq(judgingProjects.id, judgeVotes.projectId), - eq(judgingProjects.hackathonId, input.hackathonId), - ), - ) - .where(eq(judgeVotes.judgeId, ctx.judge.id)); - - const total = pool.length; - const completed = scored?.n ?? 0; - return { - total, - completed, - percentage: total > 0 ? Math.round((completed / total) * 100) : 0, - }; - } catch (error) { - // A NOT_FOUND or FORBIDDEN from inside is the judge's answer, not a 500. - if (error instanceof TRPCError) throw error; - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch progress.", - cause: error, - }); - } - }), }); diff --git a/packages/api/src/routers/judge/rankings.ts b/packages/api/src/routers/judge/rankings.ts deleted file mode 100644 index 69f72da8..00000000 --- a/packages/api/src/routers/judge/rankings.ts +++ /dev/null @@ -1,534 +0,0 @@ -import { z } from "zod"; -import { TRPCError } from "@trpc/server"; -import { createTRPCRouter } from "../../trpc"; -import { hackathonResults, hackathons, judgingProjects } from "@query/db"; -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"; - -// 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 -// answers to "who won", and only one gets announced. -async function computeRanking(db: DrizzleDB, hackathonId: string) { - const projects = await db.query.judgingProjects.findMany({ - // Withdrawn entries stop counting toward the ordering. - where: and( - eq(judgingProjects.hackathonId, hackathonId), - isNull(judgingProjects.withdrawnAt), - ), - with: { - votes: { - with: { - judge: { - with: { - user: { - columns: { name: true, email: true }, - }, - }, - }, - }, - }, - }, - }); - - 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 rawRankings = projects.map((project) => { - const voteCount = project.votes.length; - - // 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, - impact: 0, - scope: 0, - clarity: 0, - soundness: 0, - }; - project.votes.forEach((v) => { - sumCat.creativity += v.scoreCreativity ?? 0; - sumCat.impact += v.scoreImpact ?? 0; - sumCat.scope += v.scoreScope ?? 0; - sumCat.clarity += v.scoreClarity ?? 0; - sumCat.soundness += v.scoreSoundness ?? 0; - }); - - const categoryAvg = - voteCount > 0 - ? { - creativity: round2(sumCat.creativity / voteCount), - impact: round2(sumCat.impact / voteCount), - scope: round2(sumCat.scope / voteCount), - clarity: round2(sumCat.clarity / voteCount), - soundness: round2(sumCat.soundness / voteCount), - } - : { creativity: 0, impact: 0, scope: 0, clarity: 0, soundness: 0 }; - - return { - project: { - id: project.id, - // Carried through so a frozen placing can name the team that built it — - // without it a winner is a judging row and nothing more. - sourceProjectId: project.sourceProjectId, - name: project.name, - tableNumber: project.tableNumber, - zone: project.zone, - category: project.category, - teamMembers: project.teamMembers, - tracks: project.tracks, - challenges: project.challenges, - isCreateX: project.isCreateX, - }, - totalScore, - voteCount, - avgScore: round2(avgScore), - normalizedAvg, - categoryAvg, - votes: project.votes.map((v, i) => ({ - score: v.score, - normalizedScore: round2(normalizedScores[i] ?? v.score), - scoreCreativity: v.scoreCreativity, - scoreImpact: v.scoreImpact, - scoreScope: v.scoreScope, - scoreClarity: v.scoreClarity, - scoreSoundness: v.scoreSoundness, - comment: v.comment, - durationSeconds: v.durationSeconds, - judgeName: - ( - v as VoteWithJudge & { - judge: { - user?: { name?: string | null }; - name?: string | null; - }; - } - ).judge.user?.name || - ( - v as VoteWithJudge & { - judge: { - user?: { name?: string | null }; - name?: string | null; - }; - } - ).judge.name || - "Unknown", - })), - }; - }); - - // ─── 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 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 - - return { ...r, weightedScore, confidenceLevel, scoreShift }; - }); - - // Sort by weighted score desc - rankings.sort((a, b) => b.weightedScore - a.weightedScore); - - // Weighted-score ties - const ties: { - score: number; - projects: { - id: string; - name: string; - tableNumber: number; - zone: string | null; - }[]; - }[] = []; - const scoreGroups = new Map(); - - rankings.forEach((r) => { - const existing = scoreGroups.get(r.weightedScore); - if (existing) { - existing.push(r); - } else { - scoreGroups.set(r.weightedScore, [r]); - } - }); - - scoreGroups.forEach((group, score) => { - if (group.length > 1) { - ties.push({ - score, - projects: group.map((g) => ({ - id: g.project.id, - name: g.project.name, - tableNumber: g.project.tableNumber, - zone: g.project.zone ?? null, - })), - }); - } - }); - - // Per-category ties (only among projects with votes) - const categoryNames = [ - "creativity", - "impact", - "scope", - "clarity", - "soundness", - ] as const; - const categoryLabels: Record<(typeof categoryNames)[number], string> = { - creativity: "Creativity", - impact: "Impact", - scope: "Scope", - clarity: "Clarity", - soundness: "Soundness", - }; - - const categoryTies: { - category: string; - avgScore: number; - projects: { - id: string; - name: string; - tableNumber: number; - zone: string | null; - }[]; - }[] = []; - - for (const cat of categoryNames) { - const catGroups = new Map< - number, - { - id: string; - name: string; - tableNumber: number; - zone: string | null; - }[] - >(); - rankings.forEach((r) => { - if (r.voteCount === 0) return; - const avg = r.categoryAvg[cat]; - const existing = catGroups.get(avg); - const projectInfo = { - id: r.project.id, - name: r.project.name, - tableNumber: r.project.tableNumber, - zone: r.project.zone ?? null, - }; - if (existing) { - existing.push(projectInfo); - } else { - catGroups.set(avg, [projectInfo]); - } - }); - catGroups.forEach((group, avg) => { - if (group.length > 1) { - categoryTies.push({ - category: categoryLabels[cat], - avgScore: avg, - projects: group, - }); - } - }); - } - - const result = { - rankings, - globalAvg, - ties, - hasTies: ties.length > 0, - categoryTies, - hasCategoryTies: categoryTies.length > 0, - }; - - return result; -} - -export const judgeRankingsRouter = createTRPCRouter({ - getRankings: isAdmin - .input(z.object({ hackathonId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - const cacheKey = `hackathon:${input.hackathonId}:rankings`; - const cached = - ctx.cache.get>>(cacheKey); - if (cached) return cached; - - const result = await computeRanking( - ctx.db as DrizzleDB, - input.hackathonId, - ); - - ctx.cache.set(cacheKey, result, 30); // 30 second cache for live rankings - - return result; - }), - - // Freezes the current ordering into hackathon_result. Gated on judging being - // closed: the z-score normalisation runs over the whole vote set, so one late - // vote shifts every score and a snapshot taken while judging is live is - // already stale. Idempotent — recomputing upserts onto result_unique_placing. - // Published placings are left alone; unpublish first to change them. - computeResults: isAdmin - .input( - z.object({ - hackathonId: z.string().uuid(), - // Compute even though judging is still open. The result is a draft of an - // ordering that is still moving. - force: z.boolean().default(false), - }), - ) - .mutation(async ({ ctx, input }) => { - const db = ctx.db as DrizzleDB; - - const hackathon = await db.query.hackathons.findFirst({ - where: eq(hackathons.id, input.hackathonId), - columns: { id: true, judgingActive: true }, - }); - - if (!hackathon) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Hackathon not found", - }); - } - - if (hackathon.judgingActive && !input.force) { - throw new TRPCError({ - code: "CONFLICT", - message: - "Judging is still live, so scores are still moving. Stop judging first, or confirm to compute a draft anyway.", - }); - } - - const published = await db.query.hackathonResults.findFirst({ - where: and( - eq(hackathonResults.hackathonId, input.hackathonId), - isNotNull(hackathonResults.publishedAt), - ), - columns: { id: true }, - }); - - if (published) { - throw new TRPCError({ - code: "CONFLICT", - message: - "Results are already published. Unpublish them before recomputing.", - }); - } - - // Reuses the live ranking pipeline rather than duplicating the maths — two - // implementations is two answers to "who won". - const { rankings } = await computeRanking(db, input.hackathonId); - - // A project nobody scored is not a placing. computeRanking gives every - // unjudged entry a weightedScore of 0, so including them would publish - // hundreds of rows tied at zero below the real results — and "47th place" is - // a worse thing to tell a team than nothing at all. - const placed = rankings.filter((row) => row.voteCount > 0); - - // Replace, not merge: an upsert alone leaves the placing of a project - // withdrawn since the last compute, and it gets published with the rest. - // Nothing here is published — checked above. - await db.transaction(async (tx) => { - await tx - .delete(hackathonResults) - .where( - and( - eq(hackathonResults.hackathonId, input.hackathonId), - eq(hackathonResults.track, "overall"), - isNull(hackathonResults.publishedAt), - ), - ); - - if (placed.length === 0) return; - - await tx - .insert(hackathonResults) - .values( - placed.map((row, index) => ({ - hackathonId: input.hackathonId, - projectId: row.project.id, - sourceProjectId: row.project.sourceProjectId ?? null, - // Never null — see the column comment. A NULL silently defeats - // result_unique_placing and duplicates the ordering. - track: "overall", - placement: index + 1, - weightedScore: row.weightedScore.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`, - computedAt: sql`now()`, - }, - }); - }); - - ctx.cache.delete(`hackathon:${input.hackathonId}:results`); - - if (placed.length === 0) { - return { computed: 0, unjudged: rankings.length }; - } - - // Reported separately so an organiser can see that, say, 40 of 300 projects - // were never reached before they publish. - return { - computed: placed.length, - unjudged: rankings.length - placed.length, - }; - }), - - /** What has been computed, published or not. Admin review before release. */ - getResultsDraft: isAdmin - .input(z.object({ hackathonId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - return await (ctx.db as DrizzleDB).query.hackathonResults.findMany({ - where: eq(hackathonResults.hackathonId, input.hackathonId), - with: { - project: { columns: { id: true, name: true, tableNumber: true } }, - }, - orderBy: (results, { asc }) => [asc(results.placement)], - }); - }), - - publishResults: isAdmin - .input(z.object({ hackathonId: z.string().uuid() })) - .mutation(async ({ ctx, input }) => { - const rows = await (ctx.db as DrizzleDB) - .update(hackathonResults) - .set({ publishedAt: new Date() }) - .where(eq(hackathonResults.hackathonId, input.hackathonId)) - .returning({ id: hackathonResults.id }); - - if (rows.length === 0) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Nothing to publish — compute the results first.", - }); - } - - await recordAdminAction(ctx.db as DrizzleDB, { - userId: ctx.userId, - action: "judge.publishResults", - resourceId: input.hackathonId, - severity: "warn", - metadata: { placings: rows.length }, - }); - - ctx.cache.delete(`hackathon:${input.hackathonId}:results`); - - return { published: rows.length }; - }), - - // Takes results back down. The rows survive, so publishing is reversible - // rather than a one-way door on a wrong ordering. - unpublishResults: isAdmin - .input(z.object({ hackathonId: z.string().uuid() })) - .mutation(async ({ ctx, input }) => { - const rows = await (ctx.db as DrizzleDB) - .update(hackathonResults) - .set({ publishedAt: null }) - .where(eq(hackathonResults.hackathonId, input.hackathonId)) - .returning({ id: hackathonResults.id }); - - // Taking results back down after people have seen them. - await recordAdminAction(ctx.db as DrizzleDB, { - userId: ctx.userId, - action: "judge.unpublishResults", - resourceId: input.hackathonId, - severity: "critical", - metadata: { placings: rows.length }, - }); - - ctx.cache.delete(`hackathon:${input.hackathonId}:results`); - - return { unpublished: rows.length }; - }), -}); diff --git a/packages/api/src/routers/judging/admin.ts b/packages/api/src/routers/judging/admin.ts new file mode 100644 index 00000000..408911f0 --- /dev/null +++ b/packages/api/src/routers/judging/admin.ts @@ -0,0 +1,2753 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { + and, + asc, + count, + eq, + exists, + gte, + ilike, + inArray, + isNotNull, + isNull, + max, + notExists, + or, + sql, +} from "drizzle-orm"; +import { + hackathonCheckInFlags, + hackathonProjects, + judgingAnnouncements, + judgingAppointments, + judgingBuildings, + judgingChallenges, + judgingConfigs, + judgingEntries, + judgingEntryChallenges, + judgingEntryMembers, + judgingEvaluationDrafts, + judgingEvaluations, + judgingGuestSessions, + judgingPlacements, + judgingPresence, + judgingRoomLinks, + judgingRooms, + judgingRubricItems, + judgingSchedules, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { createTRPCRouter } from "../../trpc"; +import { isAdmin, isAdminUpload } from "../../middleware/procedures"; +import { recordAdminAction } from "../../middleware/audit"; +import { isUniqueViolation } from "../../middleware/db-errors"; +import { + assertHackathonExists, + ensureJudgingConfig, +} from "../../services/judging/config"; +import type { JudgingDb } from "../../services/judging/config"; +import { lockGrading } from "../../services/judging/evaluation-store"; +import { outerColumn } from "../../services/judging/outer-column"; +import { effectiveMode, sameLabel } from "../../services/judging/scope"; +import { + assertStructureEditable, + derivedMemberships, + groupingProblem, + initializeGroups, + lockEdition, + readChallenges, + readSetupLock, + rebuildDerivedMemberships, +} from "../../services/judging/challenges"; +import { + DEVPOST_MAX_BYTES, + importDevpost, + promoteSubmissions, +} from "../../services/judging/import"; +import { + emptyRoom, + issueRoomLink, + MAX_TABLE_NUMBER, + newTableNumbers, + nextTableNumber, + portalOrigin, + revokeRoomAccess, + roomAppointmentCount, + roomLinkUrl, + slotTables, + tableName, + tableNumber, +} from "../../services/judging/rooms"; +import { + dropEvaluations, + dropRooms, + resetAll, + resetLaunch, + resetProjects, + resetSetup, +} from "../../services/judging/resets"; +import { + buildChecklist, + readChecklistFacts, + rubricLockReason, + stateChangeProblem, +} from "../../services/judging/checklist"; +import { notifyItineraryPosted } from "../../services/judging/notify"; +import { holdsEveryRating } from "../../services/judging/scoring-sql"; +import { + checkInFlagsForEntries, + flaggedEntryIds, + openCheckInFlagCount, +} from "../../services/check-in-flags"; +import type { CheckInFlagView } from "../../services/check-in-flags"; + +// Organiser setup and live control of room judging: getting projects in, +// shaping challenges, rooms and the rubric, the switches flipped on judging +// day, and the resets. Reads are open to bug testers (isAdmin lets queries +// through). A write that checks state before changing it takes the edition +// lock (lockEdition in services/judging/challenges) first. + +const uuid = z.string().uuid(); +const hackathonInput = z.object({ hackathonId: uuid }); +const resetInput = hackathonInput.extend({ + confirmation: z.string().trim().min(1).max(255), +}); +const label = z.string().trim().min(1).max(255); +const roomName = z.string().trim().min(1).max(120); +const roomKind = z.enum(["room", "table"]); +const challengeMode = z.enum(["scheduled", "unscheduled", "remote"]); + +// Line breaks are allowed in an announcement; other control characters are +// not, since they render as nothing or as boxes. +const announcementText = z + .string() + .trim() + .min(1) + .max(1000) + // eslint-disable-next-line no-control-regex + .refine((value) => !/[\u0000-\u0009\u000b-\u001f\u007f]/.test(value), { + message: "The announcement contains characters that cannot be shown.", + }); + +const rubricItem = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("rating"), + label: z.string().trim().min(1).max(120), + description: z.string().trim().max(500).optional(), + }), + z.object({ + kind: z.literal("response"), + label: z.string().trim().min(1).max(120), + description: z.string().trim().max(500).optional(), + required: z.boolean().default(true), + guestVisibility: z.enum(["public", "optional", "private"]), + }), +]); + +// Presence older than this is a closed laptop, not a judge in the room. +const SEATED_WINDOW_MS = 15 * 60 * 1000; + +const escapeLike = (value: string) => value.replace(/[\\%_]/g, "\\$&"); + +const notFound = (message: string) => + new TRPCError({ code: "NOT_FOUND", message }); + +const refuse = (message: string) => + new TRPCError({ code: "PRECONDITION_FAILED", message }); + +/** Public results are cached; anything that changes what they show evicts them. */ +const evictResults = ( + cache: { delete: (key: string) => void }, + hackathonId: string, +) => cache.delete(`hackathon:${hackathonId}:results`); + +/** Live projects of the edition that carry a check-in flag nobody dismissed. */ +async function entriesWithOpenFlags(db: JudgingDb, hackathonId: string) { + const live = await db + .select({ id: judgingEntries.id }) + .from(judgingEntries) + .where( + and( + eq(judgingEntries.hackathonId, hackathonId), + isNull(judgingEntries.deletedAt), + ), + ); + const flagged = await flaggedEntryIds( + db, + hackathonId, + live.map((row) => row.id), + ); + return [...flagged]; +} + +// Each of these finds the row's edition, takes the edition lock, then reads +// the row again so the checks see it as it stands under the lock. + +async function lockEntry(tx: JudgingDb, entryId: string) { + const [scope] = await tx + .select({ hackathonId: judgingEntries.hackathonId }) + .from(judgingEntries) + .where(eq(judgingEntries.id, entryId)) + .limit(1); + if (!scope) throw notFound("That project is gone. Refresh the list."); + await lockEdition(tx, scope.hackathonId); + const [entry] = await tx + .select() + .from(judgingEntries) + .where(eq(judgingEntries.id, entryId)) + .limit(1); + if (!entry) throw notFound("That project is gone. Refresh the list."); + return entry; +} + +async function lockChallenge(tx: JudgingDb, challengeId: string) { + const [scope] = await tx + .select({ hackathonId: judgingChallenges.hackathonId }) + .from(judgingChallenges) + .where(eq(judgingChallenges.id, challengeId)) + .limit(1); + if (!scope) throw notFound("That challenge is gone. Refresh the list."); + await lockEdition(tx, scope.hackathonId); + const challenges = await readChallenges(tx, scope.hackathonId); + const challenge = challenges.find((c) => c.id === challengeId); + if (!challenge) throw notFound("That challenge is gone. Refresh the list."); + return { hackathonId: scope.hackathonId, challenge, challenges }; +} + +async function lockRoom(tx: JudgingDb, roomId: string) { + const [scope] = await tx + .select({ hackathonId: judgingRooms.hackathonId }) + .from(judgingRooms) + .where(eq(judgingRooms.id, roomId)) + .limit(1); + if (!scope) throw notFound("That room is gone. Refresh the Rooms tab."); + await lockEdition(tx, scope.hackathonId); + const [room] = await tx + .select() + .from(judgingRooms) + .where(eq(judgingRooms.id, roomId)) + .limit(1); + if (!room) throw notFound("That room is gone. Refresh the Rooms tab."); + return room; +} + +/** A challenge a room can serve: top-level and in this edition. */ +async function assertRoomChallenge( + tx: JudgingDb, + hackathonId: string, + challengeId: string, +) { + const [challenge] = await tx + .select({ parentId: judgingChallenges.parentId }) + .from(judgingChallenges) + .where( + and( + eq(judgingChallenges.id, challengeId), + eq(judgingChallenges.hackathonId, hackathonId), + ), + ) + .limit(1); + if (!challenge) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "That challenge is not part of this hackathon.", + }); + } + if (challenge.parentId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Rooms judge a top-level challenge or group. Pick the group this challenge sits in.", + }); + } +} + +async function assertBuilding( + tx: JudgingDb, + hackathonId: string, + buildingId: string, +) { + const [building] = await tx + .select({ id: judgingBuildings.id }) + .from(judgingBuildings) + .where( + and( + eq(judgingBuildings.id, buildingId), + eq(judgingBuildings.hackathonId, hackathonId), + ), + ) + .limit(1); + if (!building) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "That building is not part of this hackathon.", + }); + } +} + +const roomNameTaken = (name: string) => + new TRPCError({ + code: "CONFLICT", + message: `An active room or table called ${name} is already in that building. Pick another name.`, + }); + +export const judgingAdminRouter = createTRPCRouter({ + // --------------------------------------------------------------------- + // Console + + /** The console header: the edition, its switches and how much is set up. */ + overview: isAdmin.input(hackathonInput).query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const id = input.hackathonId; + const hackathon = await assertHackathonExists(db, id); + const config = await ensureJudgingConfig(db, id); + const seatedSince = new Date(Date.now() - SEATED_WINDOW_MS); + + const [ + [entries], + [members], + [challenges], + [rooms], + [rubric], + [schedule], + [evaluations], + [drafts], + [announcements], + [seated], + openCheckInFlags, + ] = await Promise.all([ + db + .select({ + devpost: sql`count(*) filter (where ${judgingEntries.deletedAt} is null and ${judgingEntries.source} = 'devpost')::int`, + portal: sql`count(*) filter (where ${judgingEntries.deletedAt} is null and ${judgingEntries.source} = 'portal')::int`, + removed: sql`count(*) filter (where ${judgingEntries.deletedAt} is not null)::int`, + }) + .from(judgingEntries) + .where(eq(judgingEntries.hackathonId, id)), + db + .select({ + total: count(), + linked: sql`count(${judgingEntryMembers.userId})::int`, + }) + .from(judgingEntryMembers) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEntryMembers.entryId), + ) + .where( + and( + eq(judgingEntryMembers.hackathonId, id), + isNull(judgingEntries.deletedAt), + ), + ), + db + .select({ + total: count(), + groups: sql`count(*) filter (where ${judgingChallenges.isGroup})::int`, + }) + .from(judgingChallenges) + .where(eq(judgingChallenges.hackathonId, id)), + db + .select({ + active: sql`count(*) filter (where ${judgingRooms.archivedAt} is null)::int`, + tables: sql`count(*) filter (where ${judgingRooms.archivedAt} is null and ${judgingRooms.kind} = 'table')::int`, + archived: sql`count(*) filter (where ${judgingRooms.archivedAt} is not null)::int`, + linked: sql`count(*) filter (where ${judgingRooms.archivedAt} is null and exists (select 1 from ${judgingRoomLinks} where ${judgingRoomLinks.roomId} = ${outerColumn(judgingRooms, judgingRooms.id)} and ${judgingRoomLinks.revokedAt} is null))::int`, + }) + .from(judgingRooms) + .where(eq(judgingRooms.hackathonId, id)), + db + .select({ + ratings: sql`count(*) filter (where ${judgingRubricItems.kind} = 'rating')::int`, + responses: sql`count(*) filter (where ${judgingRubricItems.kind} = 'response')::int`, + }) + .from(judgingRubricItems) + .where(eq(judgingRubricItems.hackathonId, id)), + db + .select({ + id: judgingSchedules.id, + windowStart: judgingSchedules.windowStart, + windowEnd: judgingSchedules.windowEnd, + firstResultAt: judgingSchedules.firstResultAt, + createdAt: judgingSchedules.createdAt, + appointments: sql`(select count(*)::int from ${judgingAppointments} where ${judgingAppointments.scheduleId} = ${outerColumn(judgingSchedules, judgingSchedules.id)})`, + }) + .from(judgingSchedules) + .where(eq(judgingSchedules.hackathonId, id)) + .limit(1), + db + .select({ + total: count(), + // The ones placings count: every rating in, written response or not. + complete: sql`count(*) filter (where ${holdsEveryRating(id)})::int`, + }) + .from(judgingEvaluations) + .where(eq(judgingEvaluations.hackathonId, id)), + db + .select({ total: count() }) + .from(judgingEvaluationDrafts) + .where(eq(judgingEvaluationDrafts.hackathonId, id)), + db + .select({ total: count() }) + .from(judgingAnnouncements) + .where( + and( + eq(judgingAnnouncements.hackathonId, id), + isNull(judgingAnnouncements.clearedAt), + ), + ), + db + .select({ total: count() }) + .from(judgingPresence) + .where( + and( + eq(judgingPresence.hackathonId, id), + isNull(judgingPresence.leftAt), + gte(judgingPresence.lastSeenAt, seatedSince), + ), + ), + openCheckInFlagCount(db, id), + ]); + + const hasEvaluations = (evaluations?.total ?? 0) + (drafts?.total ?? 0) > 0; + return { + hackathon, + config: { + state: config.state, + openedAt: config.openedAt, + closedAt: config.closedAt, + showAllScores: config.showAllScores, + inventoryLockedAt: config.inventoryLockedAt, + groupsInitializedAt: config.groupsInitializedAt, + itineraryPublished: config.itineraryPublished, + emergencyMode: config.emergencyMode, + claimsStartedAt: config.claimsStartedAt, + }, + counts: { + entries: { + devpost: entries?.devpost ?? 0, + portal: entries?.portal ?? 0, + removed: entries?.removed ?? 0, + }, + members: { + total: members?.total ?? 0, + linked: members?.linked ?? 0, + }, + challenges: { + total: challenges?.total ?? 0, + groups: challenges?.groups ?? 0, + }, + rooms: { + active: rooms?.active ?? 0, + tables: rooms?.tables ?? 0, + archived: rooms?.archived ?? 0, + linked: rooms?.linked ?? 0, + }, + rubric: { + ratings: rubric?.ratings ?? 0, + responses: rubric?.responses ?? 0, + }, + evaluations: { + total: evaluations?.total ?? 0, + complete: evaluations?.complete ?? 0, + drafts: drafts?.total ?? 0, + }, + announcements: announcements?.total ?? 0, + judgesSeated: seated?.total ?? 0, + // Only how many wait for a decision. Who and why stay in the project + // list, which is organiser-only. + checkInFlags: { open: openCheckInFlags }, + }, + schedule: schedule ?? null, + locks: { + // The challenge tree; see assertStructureEditable. + structure: !!schedule || hasEvaluations, + rubric: rubricLockReason({ + state: config.state, + scheduleSaved: !!schedule, + hasEvaluations, + }), + }, + }; + }), + + /** What still stands between this edition and judging day. */ + checklist: isAdmin.input(hackathonInput).query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + const items = buildChecklist( + await readChecklistFacts(db, input.hackathonId), + ); + return { + items, + done: items.filter((item) => item.done).length, + total: items.length, + }; + }), + + // --------------------------------------------------------------------- + // Projects in + + /** + * Applies a Devpost submissions export. The client reads the file and + * sends its text. + */ + importDevpost: isAdminUpload + .input( + hackathonInput.extend({ + csv: z.string().min(1).max(DEVPOST_MAX_BYTES), + mode: z.enum(["automatic", "replace"]).default("automatic"), + confirmation: z.string().max(255).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const result = await importDevpost(db, { + hackathonId: input.hackathonId, + csv: input.csv, + mode: input.mode, + confirmation: input.confirmation, + userId: ctx.userId, + }); + + // Counts and a tag of the file only: the file holds hackers' emails. + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.importDevpost", + resourceId: input.hackathonId, + severity: result.mode === "replace" ? "critical" : "warn", + metadata: { + requestedMode: input.mode, + mode: result.mode, + imported: result.imported, + skipped: result.skipped, + removed: result.removed, + rejected: result.rejected.length, + drafts: result.drafts, + collapsedRows: result.collapsedRows, + newChallenges: result.newChallenges, + linksRevoked: result.linksRevoked, + guestsSignedOut: result.guestsSignedOut, + bytes: result.bytes, + fileTag: result.fileTag, + }, + }); + + return result; + }), + + /** Copies portal submissions into judging. Safe to run again. */ + promoteSubmissions: isAdmin + .input(hackathonInput) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const result = await promoteSubmissions(db, input); + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.promoteSubmissions", + resourceId: input.hackathonId, + severity: "warn", + metadata: result, + }); + return result; + }), + + /** Creates the General and MLH Challenges groups if they never were. */ + initializeGroups: isAdmin + .input(hackathonInput) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const created = await db.transaction(async (tx) => { + const { config } = await lockEdition(tx, input.hackathonId); + const made = await initializeGroups(tx, input.hackathonId, config); + if (made) await rebuildDerivedMemberships(tx, input.hackathonId); + return made; + }); + return { created }; + }), + + // --------------------------------------------------------------------- + // Projects + + listEntries: isAdmin + .input( + hackathonInput.extend({ + search: z.string().trim().max(200).optional(), + source: z.enum(["all", "devpost", "portal"]).default("all"), + status: z.enum(["live", "removed", "all"]).default("live"), + challengeId: uuid.optional(), + // Only projects with a check-in flag nobody has dismissed. + flagged: z.boolean().optional(), + page: z.number().int().min(1).default(1), + pageSize: z.number().int().min(1).max(200).default(50), + }), + ) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const pattern = input.search ? `%${escapeLike(input.search)}%` : null; + + // Settled before the query so the filter narrows the page and the total + // together instead of thinning out a page afterwards. + const flaggedIds = input.flagged + ? await entriesWithOpenFlags(db, input.hackathonId) + : null; + + const where = and( + eq(judgingEntries.hackathonId, input.hackathonId), + input.source === "all" + ? undefined + : eq(judgingEntries.source, input.source), + input.status === "live" + ? isNull(judgingEntries.deletedAt) + : input.status === "removed" + ? isNotNull(judgingEntries.deletedAt) + : undefined, + flaggedIds + ? flaggedIds.length > 0 + ? inArray(judgingEntries.id, flaggedIds) + : sql`false` + : undefined, + input.challengeId + ? exists( + db + .select({ one: sql`1` }) + .from(judgingEntryChallenges) + .where( + and( + eq(judgingEntryChallenges.entryId, judgingEntries.id), + eq(judgingEntryChallenges.challengeId, input.challengeId), + ), + ), + ) + : undefined, + pattern + ? or( + ilike(judgingEntries.title, pattern), + exists( + db + .select({ one: sql`1` }) + .from(judgingEntryMembers) + .where( + and( + eq(judgingEntryMembers.entryId, judgingEntries.id), + or( + ilike(judgingEntryMembers.name, pattern), + ilike(judgingEntryMembers.email, pattern), + ), + ), + ), + ), + ) + : undefined, + ); + + const [[total], rows] = await Promise.all([ + db.select({ value: count() }).from(judgingEntries).where(where), + db + .select({ + id: judgingEntries.id, + source: judgingEntries.source, + sourceProjectId: judgingEntries.sourceProjectId, + title: judgingEntries.title, + submissionUrl: judgingEntries.submissionUrl, + description: judgingEntries.description, + demoLinks: judgingEntries.demoLinks, + videoUrl: judgingEntries.videoUrl, + technologies: judgingEntries.technologies, + universities: judgingEntries.universities, + prizeCategories: judgingEntries.prizeCategories, + participantCount: judgingEntries.participantCount, + submittedAt: judgingEntries.submittedAt, + deletedAt: judgingEntries.deletedAt, + prizeIneligible: sql`${judgingEntries.prizeIneligibleAt} is not null`, + createdAt: judgingEntries.createdAt, + }) + .from(judgingEntries) + .where(where) + .orderBy(asc(judgingEntries.title), asc(judgingEntries.id)) + .limit(input.pageSize) + .offset((input.page - 1) * input.pageSize), + ]); + + const ids = rows.map((row) => row.id); + const [members, memberships, appointments, evaluations, flags] = + ids.length === 0 + ? [[], [], [], [], new Map()] + : await Promise.all([ + db + .select({ + id: judgingEntryMembers.id, + entryId: judgingEntryMembers.entryId, + name: judgingEntryMembers.name, + email: judgingEntryMembers.email, + linked: sql`${judgingEntryMembers.userId} is not null`, + claimedAt: judgingEntryMembers.claimedAt, + invited: sql`${judgingEntryMembers.invitedById} is not null`, + }) + .from(judgingEntryMembers) + .where(inArray(judgingEntryMembers.entryId, ids)) + .orderBy(asc(judgingEntryMembers.displayOrder)), + db + .select({ + entryId: judgingEntryChallenges.entryId, + challengeId: judgingEntryChallenges.challengeId, + isOptIn: judgingEntryChallenges.isOptIn, + label: judgingChallenges.label, + isGroup: judgingChallenges.isGroup, + isGeneral: judgingChallenges.isGeneral, + }) + .from(judgingEntryChallenges) + .innerJoin( + judgingChallenges, + eq(judgingChallenges.id, judgingEntryChallenges.challengeId), + ) + .where(inArray(judgingEntryChallenges.entryId, ids)) + .orderBy(asc(judgingChallenges.label)), + db + .select({ + entryId: judgingAppointments.entryId, + value: count(), + }) + .from(judgingAppointments) + .where(inArray(judgingAppointments.entryId, ids)) + .groupBy(judgingAppointments.entryId), + db + .select({ + entryId: judgingEvaluations.entryId, + value: count(), + }) + .from(judgingEvaluations) + .where( + and( + inArray(judgingEvaluations.entryId, ids), + holdsEveryRating(input.hackathonId), + ), + ) + .groupBy(judgingEvaluations.entryId), + // Names and events of other people: organiser-only, which this + // procedure is, and never part of a cached payload. + checkInFlagsForEntries(db, input.hackathonId, ids), + ]); + + const countFor = (list: { entryId: string; value: number }[], id: string) => + list.find((row) => row.entryId === id)?.value ?? 0; + + return { + total: total?.value ?? 0, + page: input.page, + pageSize: input.pageSize, + entries: rows.map((row) => ({ + ...row, + members: members + .filter((m) => m.entryId === row.id) + .map(({ entryId: _entryId, ...member }) => member), + challenges: memberships + .filter((m) => m.entryId === row.id) + .map(({ entryId: _entryId, ...membership }) => membership), + appointments: countFor(appointments, row.id), + completeEvaluations: countFor(evaluations, row.id), + checkInFlags: flags.get(row.id) ?? [], + })), + }; + }), + + /** + * Sets the challenges a project opted into. Group and general memberships + * follow. A challenge the project already presented or was scored in + * cannot be taken away. + */ + setEntryChallenges: isAdmin + .input( + z.object({ + entryId: uuid, + challengeIds: z.array(uuid).max(100), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const result = await db.transaction(async (tx) => { + const entry = await lockEntry(tx, input.entryId); + if (entry.deletedAt) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Restore the project before changing its challenges.", + }); + } + + const challenges = await readChallenges(tx, entry.hackathonId); + const chosen = [...new Set(input.challengeIds)]; + for (const id of chosen) { + const challenge = challenges.find((c) => c.id === id); + if (!challenge) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "One of those challenges is not part of this hackathon.", + }); + } + if (challenge.isGroup) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${challenge.label} is a group. Pick challenges; group membership follows from them.`, + }); + } + } + + const optIns = chosen.map((challengeId) => ({ + entryId: entry.id, + challengeId, + })); + const derived = derivedMemberships(optIns, [entry.id], challenges); + const keep = new Set([ + ...chosen, + ...derived.map((row) => row.challengeId), + ]); + + const current = await tx + .select({ challengeId: judgingEntryChallenges.challengeId }) + .from(judgingEntryChallenges) + .where(eq(judgingEntryChallenges.entryId, entry.id)); + const dropped = current + .map((row) => row.challengeId) + .filter((id) => !keep.has(id)); + + if (dropped.length > 0) { + const [[appointment], [evaluation], [draft]] = await Promise.all([ + tx + .select({ challengeId: judgingAppointments.challengeId }) + .from(judgingAppointments) + .where( + and( + eq(judgingAppointments.entryId, entry.id), + inArray(judgingAppointments.challengeId, dropped), + ), + ) + .limit(1), + tx + .select({ challengeId: judgingEvaluations.challengeId }) + .from(judgingEvaluations) + .where( + and( + eq(judgingEvaluations.entryId, entry.id), + inArray(judgingEvaluations.challengeId, dropped), + ), + ) + .limit(1), + tx + .select({ challengeId: judgingEvaluationDrafts.challengeId }) + .from(judgingEvaluationDrafts) + .where( + and( + eq(judgingEvaluationDrafts.entryId, entry.id), + inArray(judgingEvaluationDrafts.challengeId, dropped), + ), + ) + .limit(1), + ]); + const used = appointment ?? evaluation ?? draft; + if (used) { + const name = + challenges.find((c) => c.id === used.challengeId)?.label ?? + "One of those challenges"; + throw new TRPCError({ + code: "CONFLICT", + message: `${name} already has a presentation or a judge's scores for this project, so it stays.`, + }); + } + } + + await tx + .delete(judgingEntryChallenges) + .where(eq(judgingEntryChallenges.entryId, entry.id)); + const rows = [ + ...optIns.map((row) => ({ ...row, isOptIn: true })), + ...derived.map((row) => ({ ...row, isOptIn: false })), + ]; + if (rows.length > 0) { + await tx + .insert(judgingEntryChallenges) + .values( + rows.map((row) => ({ ...row, hackathonId: entry.hackathonId })), + ); + } + + const prizeCategories = challenges + .filter((c) => chosen.includes(c.id) && !c.isGeneral) + .map((c) => c.label) + .sort((a, b) => a.localeCompare(b)); + await tx + .update(judgingEntries) + .set({ prizeCategories, updatedAt: new Date() }) + .where(eq(judgingEntries.id, entry.id)); + + return { + hackathonId: entry.hackathonId, + challengeIds: [...keep], + dropped: dropped.length, + }; + }); + + if (result.dropped > 0) { + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.setEntryChallenges", + resourceId: input.entryId, + metadata: { + hackathonId: result.hackathonId, + challenges: result.challengeIds.length, + dropped: result.dropped, + }, + }); + } + return { challengeIds: result.challengeIds }; + }), + + /** + * Takes a project out of judging. Its evaluations stay, pointing at it. A + * portal submission removed here cannot be resubmitted by its team. + */ + removeEntry: isAdmin + .input(z.object({ entryId: uuid })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const entry = await db.transaction(async (tx) => { + const entry = await lockEntry(tx, input.entryId); + if (entry.deletedAt) return null; + + const [appointments] = await tx + .select({ value: count() }) + .from(judgingAppointments) + .where(eq(judgingAppointments.entryId, entry.id)); + if ((appointments?.value ?? 0) > 0) { + throw refuse( + `${entry.title} is on the saved schedule (${appointments!.value} presentation${appointments!.value === 1 ? "" : "s"}). Drop the schedule on the Schedule tab before removing it.`, + ); + } + + const now = new Date(); + await tx + .update(judgingEntries) + .set({ deletedAt: now, deletedById: ctx.userId, updatedAt: now }) + .where(eq(judgingEntries.id, entry.id)); + return entry; + }); + if (!entry) return { removed: false }; + + evictResults(ctx.cache, entry.hackathonId); + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.removeEntry", + resourceId: entry.id, + severity: "warn", + metadata: { hackathonId: entry.hackathonId, source: entry.source }, + }); + return { removed: true }; + }), + + restoreEntry: isAdmin + .input(z.object({ entryId: uuid })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const entry = await db.transaction(async (tx) => { + const entry = await lockEntry(tx, input.entryId); + if (!entry.deletedAt) return null; + + if (entry.source === "portal" && entry.sourceProjectId) { + const [project] = await tx + .select({ + status: hackathonProjects.status, + withdrawnByAdminAt: hackathonProjects.withdrawnByAdminAt, + }) + .from(hackathonProjects) + .where(eq(hackathonProjects.id, entry.sourceProjectId)) + .limit(1); + if ( + project && + (project.withdrawnByAdminAt || project.status === "draft") + ) { + throw refuse( + "This team's portal submission was withdrawn, so it cannot be judged. Ask the team to submit it again.", + ); + } + } + + await tx + .update(judgingEntries) + .set({ deletedAt: null, deletedById: null, updatedAt: new Date() }) + .where(eq(judgingEntries.id, entry.id)); + return entry; + }); + if (!entry) return { restored: false }; + + evictResults(ctx.cache, entry.hackathonId); + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.restoreEntry", + resourceId: entry.id, + metadata: { hackathonId: entry.hackathonId }, + }); + return { restored: true }; + }), + + /** + * Clears a check-in flag an organiser has looked into, or opens it again. + * The flag stays on record either way; judges stop seeing the marker while it + * is dismissed. The edition is part of the match so a flag id from another + * edition cannot be touched from this one. + */ + dismissCheckInFlag: isAdmin + .input(hackathonInput.extend({ flagId: uuid, dismissed: z.boolean() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const [flag] = await db + .update(hackathonCheckInFlags) + .set( + input.dismissed + ? { dismissedAt: new Date(), dismissedById: ctx.userId } + : { dismissedAt: null, dismissedById: null }, + ) + .where( + and( + eq(hackathonCheckInFlags.id, input.flagId), + eq(hackathonCheckInFlags.hackathonId, input.hackathonId), + ), + ) + .returning({ id: hackathonCheckInFlags.id }); + if (!flag) throw notFound("That flag is gone. Refresh the list."); + + await recordAdminAction(db, { + userId: ctx.userId, + action: input.dismissed + ? "judging.dismissCheckInFlag" + : "judging.reopenCheckInFlag", + resourceId: flag.id, + metadata: { hackathonId: input.hackathonId }, + }); + return { dismissed: input.dismissed }; + }), + + /** + * Pulls a project from prizes, or puts it back. Judges still score it and + * the room schedule is untouched; only placements and public results leave + * it out, so the cached results go at once. + */ + setPrizeEligibility: isAdmin + .input(hackathonInput.extend({ entryId: uuid, eligible: z.boolean() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const changed = await db.transaction(async (tx) => { + await lockEdition(tx, input.hackathonId); + const [entry] = await tx + .select({ prizeIneligibleAt: judgingEntries.prizeIneligibleAt }) + .from(judgingEntries) + .where( + and( + eq(judgingEntries.id, input.entryId), + eq(judgingEntries.hackathonId, input.hackathonId), + ), + ) + .limit(1); + if (!entry) throw notFound("That project is gone. Refresh the list."); + if ((entry.prizeIneligibleAt === null) === input.eligible) return false; + + const now = new Date(); + await tx + .update(judgingEntries) + .set( + input.eligible + ? { + prizeIneligibleAt: null, + prizeIneligibleById: null, + updatedAt: now, + } + : { + prizeIneligibleAt: now, + prizeIneligibleById: ctx.userId, + updatedAt: now, + }, + ) + .where(eq(judgingEntries.id, input.entryId)); + return true; + }); + if (!changed) return { changed: false }; + + evictResults(ctx.cache, input.hackathonId); + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.setPrizeEligibility", + resourceId: input.entryId, + severity: "warn", + metadata: { hackathonId: input.hackathonId, eligible: input.eligible }, + }); + return { changed: true }; + }), + + // --------------------------------------------------------------------- + // Challenges + + listChallenges: isAdmin + .input(hackathonInput) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const id = input.hackathonId; + const [challenges, entryCounts, roomCounts, lock] = await Promise.all([ + readChallenges(db, id), + db + .select({ + challengeId: judgingEntryChallenges.challengeId, + optIns: sql`count(*) filter (where ${judgingEntryChallenges.isOptIn})::int`, + total: count(), + }) + .from(judgingEntryChallenges) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEntryChallenges.entryId), + ) + .where( + and( + eq(judgingEntryChallenges.hackathonId, id), + isNull(judgingEntries.deletedAt), + ), + ) + .groupBy(judgingEntryChallenges.challengeId), + db + .select({ + challengeId: judgingRooms.challengeId, + value: count(), + tables: sql`count(*) filter (where ${judgingRooms.kind} = 'table')::int`, + }) + .from(judgingRooms) + .where( + and( + eq(judgingRooms.hackathonId, id), + isNull(judgingRooms.archivedAt), + ), + ) + .groupBy(judgingRooms.challengeId), + readSetupLock(db, id), + ]); + + return { + structureLocked: lock.scheduleSaved || lock.hasEvaluations, + challenges: challenges.map((challenge) => { + const entries = entryCounts.find( + (row) => row.challengeId === challenge.id, + ); + const stations = roomCounts.find( + (row) => row.challengeId === challenge.id, + ); + return { + ...challenge, + effectiveMode: effectiveMode(challenges, challenge.id), + optIns: entries?.optIns ?? 0, + entries: entries?.total ?? 0, + // Rooms and tables together; `tables` is how many are tables. + rooms: stations?.value ?? 0, + tables: stations?.tables ?? 0, + children: challenges.filter((c) => c.parentId === challenge.id) + .length, + }; + }), + }; + }), + + createChallenge: isAdmin + .input( + hackathonInput.extend({ + label, + isGroup: z.boolean().default(false), + parentId: uuid.nullable().default(null), + mode: challengeMode.default("scheduled"), + importLabelMatch: z.string().trim().max(100).nullable().default(null), + isGeneral: z.boolean().default(false), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const created = await db.transaction(async (tx) => { + await lockEdition(tx, input.hackathonId); + await assertStructureEditable(tx, input.hackathonId); + const challenges = await readChallenges(tx, input.hackathonId); + + if ( + challenges.some( + (c) => c.isGroup === input.isGroup && sameLabel(c.label, input.label), + ) + ) { + throw new TRPCError({ + code: "CONFLICT", + message: `A ${input.isGroup ? "group" : "challenge"} called ${input.label} already exists.`, + }); + } + if (input.importLabelMatch && !input.isGroup) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only a group collects imported labels.", + }); + } + const problem = groupingProblem( + challenges, + { id: null, isGroup: input.isGroup, isGeneral: input.isGeneral }, + input.parentId, + ); + if (problem) { + throw new TRPCError({ code: "BAD_REQUEST", message: problem }); + } + + const [order] = await tx + .select({ value: max(judgingChallenges.displayOrder) }) + .from(judgingChallenges) + .where(eq(judgingChallenges.hackathonId, input.hackathonId)); + const [row] = await tx + .insert(judgingChallenges) + .values({ + hackathonId: input.hackathonId, + label: input.label.replace(/\s+/g, " "), + isGroup: input.isGroup, + parentId: input.parentId, + mode: input.mode, + importLabelMatch: input.importLabelMatch || null, + isGeneral: input.isGeneral, + displayOrder: (order?.value ?? -1) + 1, + }) + .returning(); + if (input.isGeneral) { + await rebuildDerivedMemberships(tx, input.hackathonId); + } + return row!; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.createChallenge", + resourceId: created.id, + metadata: { + hackathonId: input.hackathonId, + isGroup: created.isGroup, + isGeneral: created.isGeneral, + }, + }); + return created; + }), + + /** + * Renames a challenge or changes its place in the tree. Renaming is always + * allowed; regrouping, mode and general are frozen once a schedule or + * scores depend on them. + */ + updateChallenge: isAdmin + .input( + z.object({ + challengeId: uuid, + label: label.optional(), + parentId: uuid.nullable().optional(), + mode: challengeMode.optional(), + importLabelMatch: z.string().trim().max(100).nullable().optional(), + isGeneral: z.boolean().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const updated = await db.transaction(async (tx) => { + const { hackathonId, challenge, challenges } = await lockChallenge( + tx, + input.challengeId, + ); + const next = { + label: input.label?.replace(/\s+/g, " ") ?? challenge.label, + parentId: + input.parentId === undefined ? challenge.parentId : input.parentId, + mode: input.mode ?? challenge.mode, + importLabelMatch: + input.importLabelMatch === undefined + ? challenge.importLabelMatch + : input.importLabelMatch || null, + isGeneral: input.isGeneral ?? challenge.isGeneral, + }; + const structural = + next.parentId !== challenge.parentId || + next.mode !== challenge.mode || + next.isGeneral !== challenge.isGeneral; + if (structural) await assertStructureEditable(tx, hackathonId); + + if ( + challenges.some( + (c) => + c.id !== challenge.id && + c.isGroup === challenge.isGroup && + sameLabel(c.label, next.label), + ) + ) { + throw new TRPCError({ + code: "CONFLICT", + message: `A ${challenge.isGroup ? "group" : "challenge"} called ${next.label} already exists.`, + }); + } + if (next.importLabelMatch && !challenge.isGroup) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only a group collects imported labels.", + }); + } + const problem = groupingProblem( + challenges, + { + id: challenge.id, + isGroup: challenge.isGroup, + isGeneral: next.isGeneral, + }, + next.parentId, + ); + if (problem) { + throw new TRPCError({ code: "BAD_REQUEST", message: problem }); + } + if (next.parentId && !challenge.parentId) { + const [room] = await tx + .select({ name: judgingRooms.name }) + .from(judgingRooms) + .where( + and( + eq(judgingRooms.challengeId, challenge.id), + isNull(judgingRooms.archivedAt), + ), + ) + .limit(1); + if (room) { + throw refuse( + `${room.name} judges ${challenge.label} directly. Point the room at the group or archive it before moving the challenge in.`, + ); + } + } + + const [row] = await tx + .update(judgingChallenges) + .set(next) + .where(eq(judgingChallenges.id, challenge.id)) + .returning(); + if (structural) await rebuildDerivedMemberships(tx, hackathonId); + return { row: row!, structural }; + }); + + if (updated.structural) { + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.updateChallenge", + resourceId: updated.row.id, + metadata: { + hackathonId: updated.row.hackathonId, + parentId: updated.row.parentId, + mode: updated.row.mode, + isGeneral: updated.row.isGeneral, + }, + }); + } + return updated.row; + }), + + /** Deletes a challenge nothing uses: no projects, rooms or scores. */ + deleteChallenge: isAdmin + .input(z.object({ challengeId: uuid })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const deleted = await db.transaction(async (tx) => { + const { hackathonId, challenge, challenges } = await lockChallenge( + tx, + input.challengeId, + ); + const id = challenge.id; + + const [[optIn], [room], [appointment], [evaluation], [draft]] = + await Promise.all([ + tx + .select({ value: count() }) + .from(judgingEntryChallenges) + .where( + and( + eq(judgingEntryChallenges.challengeId, id), + eq(judgingEntryChallenges.isOptIn, true), + ), + ), + tx + .select({ value: count() }) + .from(judgingRooms) + .where(eq(judgingRooms.challengeId, id)), + tx + .select({ value: count() }) + .from(judgingAppointments) + .where(eq(judgingAppointments.challengeId, id)), + tx + .select({ value: count() }) + .from(judgingEvaluations) + .where(eq(judgingEvaluations.challengeId, id)), + tx + .select({ value: count() }) + .from(judgingEvaluationDrafts) + .where(eq(judgingEvaluationDrafts.challengeId, id)), + ]); + const children = challenges.filter((c) => c.parentId === id).length; + const uses = [ + [optIn?.value ?? 0, "project opted in", "projects opted in"], + [children, "challenge in the group", "challenges in the group"], + [ + room?.value ?? 0, + "room or table", + "rooms and tables (archived ones too)", + ], + [appointment?.value ?? 0, "presentation", "presentations"], + [ + (evaluation?.value ?? 0) + (draft?.value ?? 0), + "evaluation", + "evaluations", + ], + ] as const; + const inUse = uses.filter(([n]) => n > 0); + if (inUse.length > 0) { + throw refuse( + `${challenge.label} is still in use: ${inUse + .map(([n, one, many]) => `${n} ${n === 1 ? one : many}`) + .join(", ")}.`, + ); + } + + await tx.delete(judgingChallenges).where(eq(judgingChallenges.id, id)); + await rebuildDerivedMemberships(tx, hackathonId); + return { id, hackathonId, label: challenge.label }; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.deleteChallenge", + resourceId: deleted.id, + severity: "warn", + metadata: { hackathonId: deleted.hackathonId, label: deleted.label }, + }); + return { deleted: true }; + }), + + // --------------------------------------------------------------------- + // Buildings and rooms + + /** Buildings and every room, archived ones included, with live state. */ + listRooms: isAdmin.input(hackathonInput).query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const id = input.hackathonId; + const seatedSince = new Date(Date.now() - SEATED_WINDOW_MS); + const now = new Date(); + + const [buildings, rooms, challenges, links, seated, guests, appointments, notices] = + await Promise.all([ + db + .select({ id: judgingBuildings.id, name: judgingBuildings.name }) + .from(judgingBuildings) + .where(eq(judgingBuildings.hackathonId, id)) + .orderBy(asc(judgingBuildings.name)), + db + .select({ + id: judgingRooms.id, + name: judgingRooms.name, + kind: judgingRooms.kind, + buildingId: judgingRooms.buildingId, + challengeId: judgingRooms.challengeId, + displayOrder: judgingRooms.displayOrder, + archivedAt: judgingRooms.archivedAt, + }) + .from(judgingRooms) + .where(eq(judgingRooms.hackathonId, id)) + .orderBy(asc(judgingRooms.displayOrder), asc(judgingRooms.name)), + readChallenges(db, id), + db + .select({ + id: judgingRoomLinks.id, + roomId: judgingRoomLinks.roomId, + createdAt: judgingRoomLinks.createdAt, + }) + .from(judgingRoomLinks) + .where( + and( + eq(judgingRoomLinks.hackathonId, id), + isNull(judgingRoomLinks.revokedAt), + ), + ), + db + .select({ roomId: judgingPresence.roomId, value: count() }) + .from(judgingPresence) + .where( + and( + eq(judgingPresence.hackathonId, id), + isNull(judgingPresence.leftAt), + gte(judgingPresence.lastSeenAt, seatedSince), + ), + ) + .groupBy(judgingPresence.roomId), + db + .select({ roomId: judgingGuestSessions.roomId, value: count() }) + .from(judgingGuestSessions) + .where( + and( + eq(judgingGuestSessions.hackathonId, id), + isNull(judgingGuestSessions.revokedAt), + gte(judgingGuestSessions.expiresAt, now), + ), + ) + .groupBy(judgingGuestSessions.roomId), + db + .select({ roomId: judgingAppointments.roomId, value: count() }) + .from(judgingAppointments) + .where(eq(judgingAppointments.hackathonId, id)) + .groupBy(judgingAppointments.roomId), + db + .select({ + id: judgingAnnouncements.id, + roomId: judgingAnnouncements.roomId, + message: judgingAnnouncements.message, + isUrgent: judgingAnnouncements.isUrgent, + includeGuests: judgingAnnouncements.includeGuests, + createdAt: judgingAnnouncements.createdAt, + }) + .from(judgingAnnouncements) + .where( + and( + eq(judgingAnnouncements.hackathonId, id), + isNull(judgingAnnouncements.clearedAt), + isNotNull(judgingAnnouncements.roomId), + ), + ), + ]); + + const origin = portalOrigin(ctx.req); + const byRoom = (list: { roomId: string; value: number }[], roomId: string) => + list.find((row) => row.roomId === roomId)?.value ?? 0; + + return { + buildings: buildings.map((building) => { + const inside = rooms.filter((room) => room.buildingId === building.id); + return { + ...building, + rooms: inside.filter((room) => !room.archivedAt).length, + // Where "Add tables" starts when no first number is given. + nextTable: nextTableNumber( + inside.map((room) => ({ + name: room.name, + archived: !!room.archivedAt, + })), + ), + }; + }), + rooms: rooms.map((room) => { + const link = links.find((l) => l.roomId === room.id); + const challenge = challenges.find((c) => c.id === room.challengeId); + return { + ...room, + buildingName: + buildings.find((b) => b.id === room.buildingId)?.name ?? null, + challengeLabel: challenge?.label ?? "", + challengeMode: challenge?.mode ?? "scheduled", + link: link + ? { + id: link.id, + createdAt: link.createdAt, + url: roomLinkUrl(link.id, origin), + } + : null, + judgesSeated: byRoom(seated, room.id), + guestSessions: byRoom(guests, room.id), + appointments: byRoom(appointments, room.id), + announcement: + notices.find((notice) => notice.roomId === room.id) ?? null, + }; + }), + }; + }), + + createBuilding: isAdmin + .input(hackathonInput.extend({ name: roomName })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + try { + const [building] = await db + .insert(judgingBuildings) + .values({ hackathonId: input.hackathonId, name: input.name }) + .returning(); + return building!; + } catch (error) { + if (isUniqueViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: `There is already a building called ${input.name}.`, + }); + } + throw error; + } + }), + + renameBuilding: isAdmin + .input(z.object({ buildingId: uuid, name: roomName })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + try { + const [building] = await db + .update(judgingBuildings) + .set({ name: input.name }) + .where(eq(judgingBuildings.id, input.buildingId)) + .returning(); + if (!building) throw notFound("That building is gone. Refresh the Rooms tab."); + return building; + } catch (error) { + if (isUniqueViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: `There is already a building called ${input.name}.`, + }); + } + throw error; + } + }), + + /** Deletes a building no active room is in. */ + deleteBuilding: isAdmin + .input(z.object({ buildingId: uuid })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await db.transaction(async (tx) => { + const [building] = await tx + .select() + .from(judgingBuildings) + .where(eq(judgingBuildings.id, input.buildingId)) + .limit(1); + if (!building) throw notFound("That building is gone. Refresh the Rooms tab."); + await lockEdition(tx, building.hackathonId); + + const [rooms] = await tx + .select({ value: count() }) + .from(judgingRooms) + .where( + and( + eq(judgingRooms.buildingId, building.id), + isNull(judgingRooms.archivedAt), + ), + ); + if ((rooms?.value ?? 0) > 0) { + throw refuse( + `${building.name} still has ${rooms!.value} active room${rooms!.value === 1 ? "" : "s"}. Move or archive them first.`, + ); + } + await tx + .delete(judgingBuildings) + .where(eq(judgingBuildings.id, building.id)); + }); + return { deleted: true }; + }), + + createRoom: isAdmin + .input( + hackathonInput.extend({ + name: roomName, + kind: roomKind.default("room"), + challengeId: uuid, + buildingId: uuid.nullable().default(null), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + try { + return await db.transaction(async (tx) => { + await lockEdition(tx, input.hackathonId); + await assertRoomChallenge(tx, input.hackathonId, input.challengeId); + if (input.buildingId) { + await assertBuilding(tx, input.hackathonId, input.buildingId); + } + const [order] = await tx + .select({ value: max(judgingRooms.displayOrder) }) + .from(judgingRooms) + .where(eq(judgingRooms.hackathonId, input.hackathonId)); + const [room] = await tx + .insert(judgingRooms) + .values({ + hackathonId: input.hackathonId, + name: input.name, + kind: input.kind, + challengeId: input.challengeId, + buildingId: input.buildingId, + displayOrder: (order?.value ?? -1) + 1, + }) + .returning(); + return room!; + }); + } catch (error) { + if (isUniqueViolation(error)) throw roomNameTaken(input.name); + throw error; + } + }), + + /** + * Adds a hall's numbered tables in one go, "Table 1" to "Table 30" say, + * listed in number order among the hall's tables. Numbers an active station + * in the hall already goes by are skipped, so re-running a range fills gaps. + */ + createTables: isAdmin + .input( + hackathonInput.extend({ + challengeId: uuid, + buildingId: uuid, + count: z.number().int().min(1).max(200), + // Left out: one past the hall's highest table number. + firstNumber: z.number().int().min(1).max(MAX_TABLE_NUMBER).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + try { + return await db.transaction(async (tx) => { + await lockEdition(tx, input.hackathonId); + await assertRoomChallenge(tx, input.hackathonId, input.challengeId); + await assertBuilding(tx, input.hackathonId, input.buildingId); + const listed = await tx + .select({ + id: judgingRooms.id, + name: judgingRooms.name, + buildingId: judgingRooms.buildingId, + displayOrder: judgingRooms.displayOrder, + archivedAt: judgingRooms.archivedAt, + }) + .from(judgingRooms) + .where(eq(judgingRooms.hackathonId, input.hackathonId)) + .orderBy(asc(judgingRooms.displayOrder), asc(judgingRooms.name)); + const inHall = (room: { buildingId: string | null }) => + room.buildingId === input.buildingId; + const stations = listed.filter(inHall).map((room) => ({ + name: room.name, + archived: !!room.archivedAt, + })); + const first = input.firstNumber ?? nextTableNumber(stations); + const last = first + input.count - 1; + if (last > MAX_TABLE_NUMBER) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Table numbers stop at ${MAX_TABLE_NUMBER}. Start lower or add fewer tables.`, + }); + } + const numbers = newTableNumbers(stations, input.count, first); + if (numbers.length === 0) { + throw new TRPCError({ + code: "CONFLICT", + message: + input.count === 1 + ? `${tableName(first)} is in that building already.` + : `${tableName(first)} to ${tableName(last)} are all in that building already.`, + }); + } + const order = slotTables( + listed.map((room) => ({ + id: room.id as string | null, + displayOrder: room.displayOrder, + number: inHall(room) ? tableNumber(room.name) : null, + })), + numbers.map((number) => ({ id: null, displayOrder: -1, number })), + ); + // Stations after a table slotted in ahead of them move down. + for (const [index, station] of order.entries()) { + if (station.id && station.displayOrder !== index) { + await tx + .update(judgingRooms) + .set({ displayOrder: index }) + .where(eq(judgingRooms.id, station.id)); + } + } + await tx.insert(judgingRooms).values( + order.flatMap((station, index) => + station.id || station.number === null + ? [] + : [ + { + hackathonId: input.hackathonId, + name: tableName(station.number), + kind: "table" as const, + challengeId: input.challengeId, + buildingId: input.buildingId, + displayOrder: index, + }, + ], + ), + ); + return { created: numbers.length, numbers }; + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: + "One of these table numbers was taken in that building just now. Refresh the Rooms tab and try again.", + }); + } + throw error; + } + }), + + /** + * Edits an active room. Moving it to another challenge or building is + * refused while presentations are scheduled in it. A new challenge also + * changes what its guests may judge, so a live link is retired, which + * takes the room's name typed out. + */ + updateRoom: isAdmin + .input( + z.object({ + roomId: uuid, + name: roomName.optional(), + kind: roomKind.optional(), + challengeId: uuid.optional(), + buildingId: uuid.nullable().optional(), + confirmation: z.string().trim().max(120).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const name = input.name; + try { + const result = await db.transaction(async (tx) => { + const room = await lockRoom(tx, input.roomId); + if (room.archivedAt) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Unarchive the room before editing it.", + }); + } + + const challengeChanges = + input.challengeId !== undefined && + input.challengeId !== room.challengeId; + const buildingChanges = + input.buildingId !== undefined && + input.buildingId !== room.buildingId; + + if (challengeChanges || buildingChanges) { + const scheduled = await roomAppointmentCount(tx, room.id); + if (scheduled > 0) { + throw refuse( + `${room.name} has ${scheduled} scheduled presentation${scheduled === 1 ? "" : "s"}. Drop the schedule before moving the room to another ${challengeChanges ? "challenge" : "building"}.`, + ); + } + } + if (challengeChanges) { + await assertRoomChallenge(tx, room.hackathonId, input.challengeId!); + } + if (buildingChanges && input.buildingId) { + await assertBuilding(tx, room.hackathonId, input.buildingId); + } + + let signedOut = 0; + if (challengeChanges) { + const [link] = await tx + .select({ id: judgingRoomLinks.id }) + .from(judgingRoomLinks) + .where( + and( + eq(judgingRoomLinks.roomId, room.id), + isNull(judgingRoomLinks.revokedAt), + ), + ) + .limit(1); + if (link) { + if (input.confirmation !== room.name) { + throw refuse( + `${room.name} has a live QR link. Changing its challenge retires the link and signs its guest judges out; type ${room.name} to confirm.`, + ); + } + signedOut = ( + await revokeRoomAccess( + tx, + { hackathonId: room.hackathonId, roomId: room.id }, + ctx.userId, + ) + ).sessions; + } + } + + if ( + input.name === undefined && + input.kind === undefined && + input.challengeId === undefined && + input.buildingId === undefined + ) { + return { room, signedOut, challengeChanges }; + } + const [updated] = await tx + .update(judgingRooms) + .set({ + name: input.name, + kind: input.kind, + challengeId: input.challengeId, + buildingId: input.buildingId, + }) + .where(eq(judgingRooms.id, room.id)) + .returning(); + return { room: updated!, signedOut, challengeChanges }; + }); + + if (result.challengeChanges) { + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.updateRoom", + resourceId: result.room.id, + severity: result.signedOut > 0 ? "warn" : "info", + metadata: { + hackathonId: result.room.hackathonId, + challengeId: result.room.challengeId, + guestsSignedOut: result.signedOut, + }, + }); + } + return result; + } catch (error) { + if (isUniqueViolation(error)) throw roomNameTaken(name ?? "that name"); + throw error; + } + }), + + /** + * Sets the order rooms are listed in, first to last. Only stations whose + * place changed are written, so swapping two rooms in an edition of + * hundreds of tables is two updates. + */ + reorderRooms: isAdmin + .input(hackathonInput.extend({ roomIds: z.array(uuid).max(2000) })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await db.transaction(async (tx) => { + await lockEdition(tx, input.hackathonId); + const current = await tx + .select({ + id: judgingRooms.id, + displayOrder: judgingRooms.displayOrder, + }) + .from(judgingRooms) + .where(eq(judgingRooms.hackathonId, input.hackathonId)); + const orderOf = new Map( + current.map((room) => [room.id, room.displayOrder]), + ); + for (const [index, roomId] of input.roomIds.entries()) { + if (orderOf.get(roomId) === index) continue; + await tx + .update(judgingRooms) + .set({ displayOrder: index }) + .where( + and( + eq(judgingRooms.id, roomId), + eq(judgingRooms.hackathonId, input.hackathonId), + ), + ); + } + }); + return { reordered: input.roomIds.length }; + }), + + /** + * Archives a room (its link is retired and everyone in it stands up) or + * brings it back. Refused while presentations are scheduled in it. + */ + setRoomArchived: isAdmin + .input(z.object({ roomId: uuid, archived: z.boolean() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + let roomName = "that room"; + try { + const result = await db.transaction(async (tx) => { + const room = await lockRoom(tx, input.roomId); + roomName = room.name; + if (!!room.archivedAt === input.archived) { + return { room, changed: false, signedOut: 0 }; + } + + let signedOut = 0; + if (input.archived) { + const scheduled = await roomAppointmentCount(tx, room.id); + if (scheduled > 0) { + throw refuse( + `${room.name} has ${scheduled} scheduled presentation${scheduled === 1 ? "" : "s"}. Move them or drop the schedule before archiving it.`, + ); + } + signedOut = ( + await revokeRoomAccess( + tx, + { hackathonId: room.hackathonId, roomId: room.id }, + ctx.userId, + ) + ).sessions; + await emptyRoom(tx, room.id); + // Nobody is left to read the room's message. + await tx + .update(judgingAnnouncements) + .set({ clearedAt: new Date() }) + .where( + and( + eq(judgingAnnouncements.roomId, room.id), + isNull(judgingAnnouncements.clearedAt), + ), + ); + } + + const [updated] = await tx + .update(judgingRooms) + .set({ archivedAt: input.archived ? new Date() : null }) + .where(eq(judgingRooms.id, room.id)) + .returning(); + return { room: updated!, changed: true, signedOut }; + }); + + if (result.changed) { + await recordAdminAction(db, { + userId: ctx.userId, + action: input.archived + ? "judging.archiveRoom" + : "judging.unarchiveRoom", + resourceId: result.room.id, + severity: input.archived ? "warn" : "info", + metadata: { + hackathonId: result.room.hackathonId, + guestsSignedOut: result.signedOut, + }, + }); + } + return { room: result.room, signedOut: result.signedOut }; + } catch (error) { + if (isUniqueViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: `Another active room in that building is called ${roomName}. Rename one of them first.`, + }); + } + throw error; + } + }), + + /** Deletes a room that hosts no scheduled presentation. */ + deleteRoom: isAdmin + .input(z.object({ roomId: uuid })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const room = await db.transaction(async (tx) => { + const room = await lockRoom(tx, input.roomId); + const scheduled = await roomAppointmentCount(tx, room.id); + if (scheduled > 0) { + throw refuse( + `${room.name} has ${scheduled} scheduled presentation${scheduled === 1 ? "" : "s"}. Drop the schedule before deleting it, or archive it after the presentations move.`, + ); + } + await tx.delete(judgingRooms).where(eq(judgingRooms.id, room.id)); + return room; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.deleteRoom", + resourceId: room.id, + severity: "warn", + metadata: { hackathonId: room.hackathonId, name: room.name }, + }); + return { deleted: true }; + }), + + /** + * Issues the room's QR link, retiring the old one and signing out the + * guests who came in through it. The first link locks the project list. + */ + generateRoomLink: isAdmin + .input(z.object({ roomId: uuid })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const result = await db.transaction(async (tx) => { + const room = await lockRoom(tx, input.roomId); + if (room.archivedAt) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Unarchive the room before giving it a QR link.", + }); + } + const issued = await issueRoomLink(tx, room, ctx.userId); + return { room, ...issued }; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.generateRoomLink", + resourceId: result.room.id, + severity: result.revoked.sessions > 0 ? "warn" : "info", + metadata: { + hackathonId: result.room.hackathonId, + replaced: result.revoked.links, + guestsSignedOut: result.revoked.sessions, + }, + }); + return { + linkId: result.link.id, + createdAt: result.link.createdAt, + url: roomLinkUrl(result.link.id, portalOrigin(ctx.req)), + replaced: result.revoked.links > 0, + guestsSignedOut: result.revoked.sessions, + }; + }), + + /** + * Issues a QR link to each listed room or table that has none, so a hall's + * tables need not be linked one at a time. One with a live link keeps it, + * so no printed code stops working. The first link locks the project list. + */ + generateRoomLinks: isAdmin + .input(hackathonInput.extend({ roomIds: z.array(uuid).min(1).max(500) })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const issued = await db.transaction(async (tx) => { + await lockEdition(tx, input.hackathonId); + const rooms = await tx + .select({ id: judgingRooms.id, hackathonId: judgingRooms.hackathonId }) + .from(judgingRooms) + .where( + and( + eq(judgingRooms.hackathonId, input.hackathonId), + inArray(judgingRooms.id, input.roomIds), + isNull(judgingRooms.archivedAt), + notExists( + tx + .select({ one: sql`1` }) + .from(judgingRoomLinks) + .where( + and( + eq(judgingRoomLinks.roomId, judgingRooms.id), + isNull(judgingRoomLinks.revokedAt), + ), + ), + ), + ), + ); + for (const room of rooms) { + await issueRoomLink(tx, room, ctx.userId); + } + return rooms.length; + }); + + if (issued > 0) { + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.generateRoomLinks", + resourceId: input.hackathonId, + metadata: { issued }, + }); + } + return { issued }; + }), + + /** Retires the room's QR link and signs out its guest judges. */ + revokeRoomLink: isAdmin + .input(z.object({ roomId: uuid })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const result = await db.transaction(async (tx) => { + const room = await lockRoom(tx, input.roomId); + const revoked = await revokeRoomAccess( + tx, + { hackathonId: room.hackathonId, roomId: room.id }, + ctx.userId, + ); + return { room, revoked }; + }); + + if (result.revoked.links + result.revoked.sessions > 0) { + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.revokeRoomLink", + resourceId: result.room.id, + severity: "warn", + metadata: { + hackathonId: result.room.hackathonId, + links: result.revoked.links, + guestsSignedOut: result.revoked.sessions, + }, + }); + } + return { + revoked: result.revoked.links > 0, + guestsSignedOut: result.revoked.sessions, + }; + }), + + // --------------------------------------------------------------------- + // Rubric and state + + getRubric: isAdmin.input(hackathonInput).query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + const config = await ensureJudgingConfig(db, input.hackathonId); + const [items, lock] = await Promise.all([ + db + .select({ + id: judgingRubricItems.id, + kind: judgingRubricItems.kind, + label: judgingRubricItems.label, + description: judgingRubricItems.description, + required: judgingRubricItems.required, + memberVisibility: judgingRubricItems.memberVisibility, + guestVisibility: judgingRubricItems.guestVisibility, + }) + .from(judgingRubricItems) + .where(eq(judgingRubricItems.hackathonId, input.hackathonId)) + .orderBy(asc(judgingRubricItems.displayOrder)), + readSetupLock(db, input.hackathonId), + ]); + return { + items, + lockedBecause: rubricLockReason({ state: config.state, ...lock }), + }; + }), + + /** + * Replaces the rubric. Ratings are 1–5 and always required; a response's + * visibility is chosen for guests only, since member judges' feedback is + * always shown to the team. + */ + saveRubric: isAdmin + .input(hackathonInput.extend({ items: z.array(rubricItem).max(30) })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const saved = await db.transaction(async (tx) => { + const { config } = await lockEdition(tx, input.hackathonId); + const lock = await readSetupLock(tx, input.hackathonId); + const reason = rubricLockReason({ state: config.state, ...lock }); + if (reason) throw refuse(reason); + + await tx + .delete(judgingRubricItems) + .where(eq(judgingRubricItems.hackathonId, input.hackathonId)); + if (input.items.length === 0) return []; + return tx + .insert(judgingRubricItems) + .values( + input.items.map((item, displayOrder) => ({ + hackathonId: input.hackathonId, + kind: item.kind, + label: item.label, + description: item.description || null, + displayOrder, + ...(item.kind === "rating" + ? { required: true, memberVisibility: null, guestVisibility: null } + : { + required: item.required, + memberVisibility: "public" as const, + guestVisibility: item.guestVisibility, + }), + })), + ) + .returning(); + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.saveRubric", + resourceId: input.hackathonId, + metadata: { + ratings: saved.filter((item) => item.kind === "rating").length, + responses: saved.filter((item) => item.kind === "response").length, + }, + }); + return { items: saved }; + }), + + /** draft → open, open ↔ closed, and back to draft before any scoring. */ + setState: isAdmin + .input(hackathonInput.extend({ state: z.enum(["draft", "open", "closed"]) })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const result = await db.transaction(async (tx) => { + const { config } = await lockEdition(tx, input.hackathonId); + if (config.state === input.state) { + return { config, from: config.state, changed: false }; + } + + const [[ratings], lock] = await Promise.all([ + tx + .select({ value: count() }) + .from(judgingRubricItems) + .where( + and( + eq(judgingRubricItems.hackathonId, input.hackathonId), + eq(judgingRubricItems.kind, "rating"), + ), + ), + readSetupLock(tx, input.hackathonId), + ]); + const problem = stateChangeProblem(config.state, input.state, { + ratingItems: ratings?.value ?? 0, + hasEvaluations: lock.hasEvaluations, + }); + if (problem) throw refuse(problem); + + const now = new Date(); + const [updated] = await tx + .update(judgingConfigs) + .set({ + state: input.state, + openedAt: + input.state === "draft" + ? null + : input.state === "open" + ? (config.openedAt ?? now) + : config.openedAt, + closedAt: input.state === "closed" ? now : null, + updatedAt: now, + }) + .where(eq(judgingConfigs.hackathonId, input.hackathonId)) + .returning(); + return { config: updated!, from: config.state, changed: true }; + }); + + if (result.changed) { + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.setState", + resourceId: input.hackathonId, + severity: "warn", + metadata: { from: result.from, to: input.state }, + }); + } + return { + state: result.config.state, + openedAt: result.config.openedAt, + closedAt: result.config.closedAt, + }; + }), + + /** Lets member judges see every project's scores, not only ones they judged. */ + setShowAllScores: isAdmin + .input(hackathonInput.extend({ showAllScores: z.boolean() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + await ensureJudgingConfig(db, input.hackathonId); + await db + .update(judgingConfigs) + .set({ showAllScores: input.showAllScores, updatedAt: new Date() }) + .where(eq(judgingConfigs.hackathonId, input.hackathonId)); + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.setShowAllScores", + resourceId: input.hackathonId, + metadata: { showAllScores: input.showAllScores }, + }); + return { showAllScores: input.showAllScores }; + }), + + /** + * The hacker-facing switches: whether the itinerary shows times and rooms, + * and emergency mode for when the schedule falls over. + */ + setLaunch: isAdmin + .input( + hackathonInput + .extend({ + itineraryPublished: z.boolean().optional(), + emergencyMode: z.boolean().optional(), + }) + .refine( + (value) => + value.itineraryPublished !== undefined || + value.emergencyMode !== undefined, + { message: "Say which switch to change." }, + ), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + await ensureJudgingConfig(db, input.hackathonId); + // Off to on is decided by the row, not a read before it: of two clicks + // racing to publish, only one still finds it off, so teams hear once. + const switchedOn = + input.itineraryPublished === true + ? await db + .update(judgingConfigs) + .set({ itineraryPublished: true }) + .where( + and( + eq(judgingConfigs.hackathonId, input.hackathonId), + eq(judgingConfigs.itineraryPublished, false), + ), + ) + .returning({ hackathonId: judgingConfigs.hackathonId }) + : []; + const [config] = await db + .update(judgingConfigs) + .set({ + itineraryPublished: input.itineraryPublished, + emergencyMode: input.emergencyMode, + updatedAt: new Date(), + }) + .where(eq(judgingConfigs.hackathonId, input.hackathonId)) + .returning(); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.setLaunch", + resourceId: input.hackathonId, + severity: "warn", + metadata: { + itineraryPublished: input.itineraryPublished, + emergencyMode: input.emergencyMode, + }, + }); + + // Teams learn their times the moment they appear, once per switch on. + // Not awaited: a few hundred messages must not hold the organiser's + // click, and it never rejects. + if (switchedOn.length > 0) { + void notifyItineraryPosted(db, input.hackathonId); + } + + return { + itineraryPublished: config!.itineraryPublished, + emergencyMode: config!.emergencyMode, + }; + }), + + // --------------------------------------------------------------------- + // Announcements + + /** The messages on judges' screens now: one for everyone, one per room. */ + listAnnouncements: isAdmin + .input(hackathonInput) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + return db + .select({ + id: judgingAnnouncements.id, + roomId: judgingAnnouncements.roomId, + roomName: judgingRooms.name, + message: judgingAnnouncements.message, + includeGuests: judgingAnnouncements.includeGuests, + isUrgent: judgingAnnouncements.isUrgent, + createdAt: judgingAnnouncements.createdAt, + }) + .from(judgingAnnouncements) + .leftJoin(judgingRooms, eq(judgingRooms.id, judgingAnnouncements.roomId)) + .where( + and( + eq(judgingAnnouncements.hackathonId, input.hackathonId), + isNull(judgingAnnouncements.clearedAt), + ), + ) + .orderBy( + sql`${judgingAnnouncements.roomId} is not null`, + asc(judgingRooms.name), + ); + }), + + /** Replaces the message for everyone (no room) or for one room. */ + setAnnouncement: isAdmin + .input( + hackathonInput.extend({ + roomId: uuid.nullable(), + message: announcementText, + includeGuests: z.boolean().default(true), + isUrgent: z.boolean().default(false), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const announcement = await db.transaction(async (tx) => { + await lockEdition(tx, input.hackathonId); + if (input.roomId) { + const [room] = await tx + .select({ id: judgingRooms.id }) + .from(judgingRooms) + .where( + and( + eq(judgingRooms.id, input.roomId), + eq(judgingRooms.hackathonId, input.hackathonId), + isNull(judgingRooms.archivedAt), + ), + ) + .limit(1); + if (!room) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "That room is not an active room of this hackathon.", + }); + } + } + + const now = new Date(); + await tx + .update(judgingAnnouncements) + .set({ clearedAt: now }) + .where( + and( + eq(judgingAnnouncements.hackathonId, input.hackathonId), + input.roomId + ? eq(judgingAnnouncements.roomId, input.roomId) + : isNull(judgingAnnouncements.roomId), + isNull(judgingAnnouncements.clearedAt), + ), + ); + const [row] = await tx + .insert(judgingAnnouncements) + .values({ + hackathonId: input.hackathonId, + roomId: input.roomId, + message: input.message, + includeGuests: input.includeGuests, + isUrgent: input.isUrgent, + createdById: ctx.userId, + }) + .returning(); + return row!; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.setAnnouncement", + resourceId: announcement.id, + metadata: { + hackathonId: input.hackathonId, + scope: input.roomId ? "room" : "everyone", + isUrgent: input.isUrgent, + includeGuests: input.includeGuests, + }, + }); + return announcement; + }), + + clearAnnouncement: isAdmin + .input(z.object({ announcementId: uuid })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const [cleared] = await db + .update(judgingAnnouncements) + .set({ clearedAt: new Date() }) + .where( + and( + eq(judgingAnnouncements.id, input.announcementId), + isNull(judgingAnnouncements.clearedAt), + ), + ) + .returning({ + id: judgingAnnouncements.id, + hackathonId: judgingAnnouncements.hackathonId, + }); + if (cleared) { + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.clearAnnouncement", + resourceId: cleared.id, + metadata: { hackathonId: cleared.hackathonId }, + }); + } + return { cleared: !!cleared }; + }), + + /** + * Deletes one evaluation and any draft for the same project and challenge, so + * the project is open for another judge to grade (grading-hold.ts). The + * ratings, responses and revisions go with the evaluation by cascade. Allowed + * while judging is closed, as setPrizeEligibility is: closing freezes judges' + * writes, not the organiser's decisions on a project. No judge can grade the + * project until judging is open again. + * + * The draft placings were ranked with the grade in them, in its challenge + * and overall, and a judge's calibration moved with it, so every draft + * placing is deleted in the same transaction. Publishing needs a compute + * after the project is graded again; until then there is nothing to publish. + * + * Refused while results are published: the public list was ranked on it. + */ + reopenGrade: isAdmin + .input(hackathonInput.extend({ evaluationId: uuid })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const reopened = await db.transaction(async (tx) => { + // Takes the config lock, which holds off every judge write and a + // publish while the grade is deleted. + await lockEdition(tx, input.hackathonId); + + const [published] = await tx + .select({ value: count() }) + .from(judgingPlacements) + .where( + and( + eq(judgingPlacements.hackathonId, input.hackathonId), + isNotNull(judgingPlacements.publishedAt), + ), + ); + if ((published?.value ?? 0) > 0) { + throw refuse( + "Results are published. Unpublish them first, then reopen the grade and compute results again.", + ); + } + + const [evaluation] = await tx + .select({ + entryId: judgingEvaluations.entryId, + challengeId: judgingEvaluations.challengeId, + judgeId: judgingEvaluations.judgeId, + revision: judgingEvaluations.revision, + isComplete: judgingEvaluations.isComplete, + }) + .from(judgingEvaluations) + .where( + and( + eq(judgingEvaluations.id, input.evaluationId), + eq(judgingEvaluations.hackathonId, input.hackathonId), + ), + ) + .limit(1); + if (!evaluation) { + throw notFound("That evaluation is gone. Refresh the list."); + } + + // The drafts first, then the project's grading lock: the order the + // deadline submission takes them in, so the two cannot deadlock. + const drafts = await tx + .select({ id: judgingEvaluationDrafts.id }) + .from(judgingEvaluationDrafts) + .where( + and( + eq(judgingEvaluationDrafts.entryId, evaluation.entryId), + eq(judgingEvaluationDrafts.challengeId, evaluation.challengeId), + ), + ) + .for("update"); + await lockGrading(tx, evaluation.entryId, evaluation.challengeId); + + if (drafts.length > 0) { + await tx.delete(judgingEvaluationDrafts).where( + inArray( + judgingEvaluationDrafts.id, + drafts.map((draft) => draft.id), + ), + ); + } + await tx + .delete(judgingEvaluations) + .where(eq(judgingEvaluations.id, input.evaluationId)); + const placings = await tx + .delete(judgingPlacements) + .where( + and( + eq(judgingPlacements.hackathonId, input.hackathonId), + isNull(judgingPlacements.publishedAt), + ), + ) + .returning({ id: judgingPlacements.id }); + + return { + ...evaluation, + drafts: drafts.length, + placings: placings.length, + }; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.reopenGrade", + resourceId: input.evaluationId, + severity: "critical", + metadata: { + hackathonId: input.hackathonId, + entryId: reopened.entryId, + challengeId: reopened.challengeId, + judgeId: reopened.judgeId, + revision: reopened.revision, + wasComplete: reopened.isComplete, + draftsDeleted: reopened.drafts, + placingsDeleted: reopened.placings, + }, + }); + return { + reopened: true, + drafts: reopened.drafts, + /** Draft placings cleared with it: compute again once it is regraded. */ + placings: reopened.placings, + }; + }), + + // --------------------------------------------------------------------- + // Resets. Each takes the hackathon's name typed out. + + dropEvaluations: isAdmin.input(resetInput).mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const result = await dropEvaluations(db, input); + evictResults(ctx.cache, input.hackathonId); + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.dropEvaluations", + resourceId: input.hackathonId, + severity: "critical", + metadata: result, + }); + return result; + }), + + resetProjects: isAdmin.input(resetInput).mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const result = await resetProjects(db, input); + evictResults(ctx.cache, input.hackathonId); + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.resetProjects", + resourceId: input.hackathonId, + severity: "critical", + metadata: result, + }); + return result; + }), + + dropRooms: isAdmin.input(resetInput).mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const result = await dropRooms(db, input); + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.dropRooms", + resourceId: input.hackathonId, + severity: "critical", + metadata: result, + }); + return result; + }), + + resetSetup: isAdmin.input(resetInput).mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const result = await resetSetup(db, input); + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.resetSetup", + resourceId: input.hackathonId, + severity: "critical", + metadata: result, + }); + return result; + }), + + resetLaunch: isAdmin.input(resetInput).mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const result = await resetLaunch(db, input); + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.resetLaunch", + resourceId: input.hackathonId, + severity: "critical", + metadata: result, + }); + return result; + }), + + resetAll: isAdmin.input(resetInput).mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const result = await resetAll(db, input); + evictResults(ctx.cache, input.hackathonId); + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.resetAll", + resourceId: input.hackathonId, + severity: "critical", + metadata: result, + }); + return result; + }), +}); diff --git a/packages/api/src/routers/judging/desk.ts b/packages/api/src/routers/judging/desk.ts new file mode 100644 index 00000000..dc0edf02 --- /dev/null +++ b/packages/api/src/routers/judging/desk.ts @@ -0,0 +1,2274 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { + and, + asc, + desc, + eq, + gt, + ilike, + inArray, + isNull, + or, +} from "drizzle-orm"; +import { + hackathons, + judgeAssignments, + judges, + judgingAnnouncements, + judgingAppointments, + judgingBuildings, + judgingChallenges, + judgingDeliberationEntries, + judgingDeliberationSections, + judgingEntries, + judgingEntryChallenges, + judgingEntryMembers, + judgingEvaluationDrafts, + judgingEvaluationRatings, + judgingEvaluationResponses, + judgingEvaluations, + judgingPresence, + judgingRooms, + judgingSchedules, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import type { JudgingTx as Tx } from "../../services/judging/config"; +import { flaggedEntryIds } from "../../services/check-in-flags"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; +import { isUniqueViolation } from "../../middleware/db-errors"; +import { + asJudge, + nameGuest, + resolveDeskPrincipal, + seatJudge, +} from "../../services/judging/principal"; +import type { Judge } from "../../services/judging/principal"; +import { + assertGuestSeated, + assertJudgingOpen, + claimGrading, + evaluatedChallenge, + evaluationAccess, + guestScope, + judgingSwitches, + loadChallenges, + loadEntryInChallenge, + loadRubric, + memberScope, + reconcileDueDrafts, + seatOf, + writeEvaluation, +} from "../../services/judging/evaluation-store"; +import { + DEADLINE_GRACE_MS, + reservedUntil as reservedUntilOf, +} from "../../services/judging/timing"; +import type { ChallengeRow } from "../../services/judging/evaluation-store"; +import { + gradingRefusal, + gradingStateOf, + gradingStates, +} from "../../services/judging/grading-hold"; +import type { GradingState } from "../../services/judging/grading-hold"; +import { + QUEUE_WINDOW, + nextToGrade, + queueOrder, + queueWindow, +} from "../../services/judging/queue"; +import { + assertQueueOffers, + loadQueue, +} from "../../services/judging/queue-store"; +import { + checkAnswers, + responseVisibility, +} from "../../services/judging/evaluation"; +import { + evaluationScore, + hasEveryRating, + overallScore, + scopedScore, +} from "../../services/judging/scoring"; +import { holdsEveryRating } from "../../services/judging/scoring-sql"; +import { effectiveMode, rootOf, sameLabel } from "../../services/judging/scope"; +import { + groupSections, + isReordering, + nextOrder, +} from "../../services/judging/deliberation"; + +// The judge desk, for member and guest judges: where they sit, what they +// judge, the evaluation editor, and their private deliberation shortlist. +// Every read is polled (desk 15s, announcements 30s, heartbeat 60s), so +// nothing here goes through ctx.cache, and reads start by submitting any +// drafts whose time ran out. + +const NOT_A_JUDGE = + "Judge access needed. Sign in with the account an organiser approved as a judge, or scan the QR code at your judging room or table."; + +// Every desk call names its edition. A member's judge access is per edition; +// a guest's cookie only opens the edition its room belongs to. +const deskProcedure = publicProcedure + .input(z.object({ hackathonId: z.string().uuid() })) + .use(async ({ ctx, input, next }) => { + const principal = await resolveDeskPrincipal(ctx.db, { + hackathonId: input.hackathonId, + userId: ctx.userId, + userName: ctx.session?.user?.name, + cookieHeader: ctx.req?.headers.get("cookie"), + }); + if (!principal) { + throw new TRPCError({ code: "UNAUTHORIZED", message: NOT_A_JUDGE }); + } + return next({ ctx: { ...ctx, principal } }); + }); + +/** A desk procedure for someone who can judge: a member, or a named guest. */ +export const judgeProcedure = deskProcedure.use(async ({ ctx, next }) => { + const judge = asJudge(ctx.principal); + if (!judge) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Enter your name before you start judging.", + }); + } + return next({ ctx: { ...ctx, judge } }); +}); + +const uuid = z.string().uuid(); +const ratingsInput = z.record(uuid, z.number().int().min(1).max(5)); +const responsesInput = z.record( + uuid, + z.object({ + value: z.string().max(2000, "Keep each answer under 2000 characters."), + isPublic: z.boolean(), + }), +); +const sectionTitle = z + .string() + .trim() + .min(1, "Give the section a name.") + .max(80, "Keep section names under 80 characters."); + +const escapeLike = (value: string) => value.replace(/[\\%_]/g, "\\$&"); + +const NOT_OPEN_YET = + "Judging has not opened yet. You can start once organisers open it."; +const CLOSED = "Judging is closed. Your submitted evaluations are read-only."; + +/** The (judge, project, challenge) an evaluation or draft is keyed by. */ +function taskOf< + T extends typeof judgingEvaluations | typeof judgingEvaluationDrafts, +>(table: T, judgeId: string, entryId: string, challengeId: string) { + return and( + eq(table.judgeId, judgeId), + eq(table.entryId, entryId), + eq(table.challengeId, challengeId), + ); +} + +/** Ratings and responses per evaluation, keyed by rubric item. */ +async function answersOf(db: DrizzleDB, evaluationIds: string[]) { + const answers = new Map< + string, + { + ratings: Record; + responses: Record; + } + >(); + if (evaluationIds.length === 0) return answers; + + const bucket = (id: string) => { + let found = answers.get(id); + if (!found) { + found = { ratings: {}, responses: {} }; + answers.set(id, found); + } + return found; + }; + + const ratings = await db + .select({ + evaluationId: judgingEvaluationRatings.evaluationId, + itemId: judgingEvaluationRatings.itemId, + value: judgingEvaluationRatings.value, + }) + .from(judgingEvaluationRatings) + .where(inArray(judgingEvaluationRatings.evaluationId, evaluationIds)); + for (const rating of ratings) { + bucket(rating.evaluationId).ratings[rating.itemId] = rating.value; + } + + const responses = await db + .select({ + evaluationId: judgingEvaluationResponses.evaluationId, + itemId: judgingEvaluationResponses.itemId, + value: judgingEvaluationResponses.value, + isPublic: judgingEvaluationResponses.isPublic, + }) + .from(judgingEvaluationResponses) + .where(inArray(judgingEvaluationResponses.evaluationId, evaluationIds)); + for (const response of responses) { + bucket(response.evaluationId).responses[response.itemId] = { + value: response.value, + isPublic: response.isPublic, + }; + } + + return answers; +} + +const modeOf = (challenges: readonly ChallengeRow[], challenge: ChallengeRow) => + effectiveMode(challenges, challenge.id) ?? challenge.mode; + +/** The opted-in challenges of each entry a judge may see, as tags. */ +async function tagsOf( + db: DrizzleDB, + judge: Judge, + challenges: readonly ChallengeRow[], + entryIds: string[], +) { + const tags = new Map(); + if (entryIds.length === 0) return tags; + + const visible = + judge.kind === "guest" + ? guestScope(challenges, judge.roomChallengeId) + : new Set(challenges.map((c) => c.id)); + const labelOf = new Map(challenges.map((c) => [c.id, c.label])); + + const rows = await db + .select({ + entryId: judgingEntryChallenges.entryId, + challengeId: judgingEntryChallenges.challengeId, + }) + .from(judgingEntryChallenges) + .where( + and( + inArray(judgingEntryChallenges.entryId, entryIds), + eq(judgingEntryChallenges.isOptIn, true), + ), + ); + for (const row of rows) { + if (!visible.has(row.challengeId)) continue; + const list = tags.get(row.entryId) ?? []; + list.push({ + id: row.challengeId, + label: labelOf.get(row.challengeId) ?? "", + }); + tags.set(row.entryId, list); + } + return tags; +} + +/** The room or table a guest was let into, or the member's current seat. */ +async function roomSummary( + db: DrizzleDB, + roomId: string, + challenges: readonly ChallengeRow[], +) { + const [room] = await db + .select({ + id: judgingRooms.id, + name: judgingRooms.name, + kind: judgingRooms.kind, + buildingName: judgingBuildings.name, + challengeId: judgingRooms.challengeId, + }) + .from(judgingRooms) + .leftJoin( + judgingBuildings, + eq(judgingBuildings.id, judgingRooms.buildingId), + ) + .where(eq(judgingRooms.id, roomId)) + .limit(1); + if (!room) return null; + const challenge = challenges.find((c) => c.id === room.challengeId); + return { + ...room, + challengeLabel: challenge?.label ?? "", + mode: effectiveMode(challenges, room.challengeId) ?? "scheduled", + }; +} + +/** The room a judge's open presence is in, archived or not. */ +async function openRoomId(db: DrizzleDB, judgeId: string) { + const [seat] = await db + .select({ roomId: judgingPresence.roomId }) + .from(judgingPresence) + .where( + and(eq(judgingPresence.judgeId, judgeId), isNull(judgingPresence.leftAt)), + ) + .limit(1); + return seat?.roomId ?? null; +} + +/** + * A member's seat, for the rule that a sponsor's challenge is graded from its + * room. A guest is locked to their room's challenge and needs no seat. + */ +function memberSeat( + db: DrizzleDB, + judge: Judge, + challenges: readonly ChallengeRow[], +) { + return judge.kind === "member" + ? seatOf(db, judge.judgeId, challenges) + : Promise.resolve(null); +} + +/** The track a member judge asked for when they applied, or null. */ +async function preferredTrackOf( + db: DrizzleDB, + userId: string, + hackathonId: string, +) { + const [row] = await db + .select({ track: judgeAssignments.track }) + .from(judgeAssignments) + .innerJoin(judges, eq(judges.id, judgeAssignments.judgeId)) + .where( + and( + eq(judges.userId, userId), + eq(judgeAssignments.hackathonId, hackathonId), + ), + ) + .limit(1); + return row?.track ?? null; +} + +/** Runs a deliberation write: judging open, the judge still seated. */ +function deliberationWrite( + db: DrizzleDB, + judge: Judge, + hackathonId: string, + write: (tx: Tx) => Promise, +) { + return db.transaction(async (tx) => { + await assertJudgingOpen(tx, hackathonId); + await assertGuestSeated(tx, judge); + return write(tx); + }); +} + +async function ownedSection(tx: Tx, judgeId: string, sectionId: string) { + const [section] = await tx + .select({ id: judgingDeliberationSections.id }) + .from(judgingDeliberationSections) + .where( + and( + eq(judgingDeliberationSections.id, sectionId), + eq(judgingDeliberationSections.judgeId, judgeId), + ), + ) + .for("update") + .limit(1); + if (!section) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That section is gone. Reload your shortlist.", + }); + } + return section; +} + +async function ownedRow(tx: Tx, judgeId: string, rowId: string) { + const [row] = await tx + .select({ + id: judgingDeliberationEntries.id, + sectionId: judgingDeliberationEntries.sectionId, + entryId: judgingDeliberationEntries.entryId, + }) + .from(judgingDeliberationEntries) + .innerJoin( + judgingDeliberationSections, + eq(judgingDeliberationSections.id, judgingDeliberationEntries.sectionId), + ) + .where( + and( + eq(judgingDeliberationEntries.id, rowId), + eq(judgingDeliberationSections.judgeId, judgeId), + ), + ) + .for("update", { of: judgingDeliberationEntries }) + .limit(1); + if (!row) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That project is no longer on your shortlist. Reload it.", + }); + } + return row; +} + +async function appendOrder(tx: Tx, sectionId: string) { + const rows = await tx + .select({ displayOrder: judgingDeliberationEntries.displayOrder }) + .from(judgingDeliberationEntries) + .where(eq(judgingDeliberationEntries.sectionId, sectionId)); + return nextOrder(rows.map((row) => row.displayOrder)); +} + +const ALREADY_LISTED = "That project is already in this section."; + +// A judge's private shortlist. Writes need judging open, like evaluations. +const deliberationRouter = createTRPCRouter({ + list: judgeProcedure.query(async ({ ctx }) => { + const db = ctx.db; + const sections = await db + .select({ + id: judgingDeliberationSections.id, + title: judgingDeliberationSections.title, + displayOrder: judgingDeliberationSections.displayOrder, + }) + .from(judgingDeliberationSections) + .where(eq(judgingDeliberationSections.judgeId, ctx.judge.judgeId)) + .orderBy( + asc(judgingDeliberationSections.displayOrder), + asc(judgingDeliberationSections.createdAt), + ); + if (sections.length === 0) return []; + + const rows = await db + .select({ + id: judgingDeliberationEntries.id, + sectionId: judgingDeliberationEntries.sectionId, + entryId: judgingDeliberationEntries.entryId, + note: judgingDeliberationEntries.note, + displayOrder: judgingDeliberationEntries.displayOrder, + title: judgingEntries.title, + deletedAt: judgingEntries.deletedAt, + }) + .from(judgingDeliberationEntries) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingDeliberationEntries.entryId), + ) + .where( + inArray( + judgingDeliberationEntries.sectionId, + sections.map((section) => section.id), + ), + ); + + return groupSections( + sections, + rows.map(({ deletedAt, ...row }) => ({ + ...row, + withdrawn: deletedAt !== null, + })), + ); + }), + + addSection: judgeProcedure + .input(z.object({ title: sectionTitle })) + .mutation(({ ctx, input }) => + deliberationWrite(ctx.db, ctx.judge, input.hackathonId, async (tx) => { + const existing = await tx + .select({ displayOrder: judgingDeliberationSections.displayOrder }) + .from(judgingDeliberationSections) + .where(eq(judgingDeliberationSections.judgeId, ctx.judge.judgeId)); + const [section] = await tx + .insert(judgingDeliberationSections) + .values({ + judgeId: ctx.judge.judgeId, + title: input.title, + displayOrder: nextOrder(existing.map((s) => s.displayOrder)), + }) + .returning({ + id: judgingDeliberationSections.id, + title: judgingDeliberationSections.title, + }); + return section; + }), + ), + + renameSection: judgeProcedure + .input(z.object({ sectionId: uuid, title: sectionTitle })) + .mutation(({ ctx, input }) => + deliberationWrite(ctx.db, ctx.judge, input.hackathonId, async (tx) => { + const section = await ownedSection( + tx, + ctx.judge.judgeId, + input.sectionId, + ); + await tx + .update(judgingDeliberationSections) + .set({ title: input.title }) + .where(eq(judgingDeliberationSections.id, section.id)); + return { id: section.id, title: input.title }; + }), + ), + + // Takes the section's projects and notes with it. + removeSection: judgeProcedure + .input(z.object({ sectionId: uuid })) + .mutation(({ ctx, input }) => + deliberationWrite(ctx.db, ctx.judge, input.hackathonId, async (tx) => { + const section = await ownedSection( + tx, + ctx.judge.judgeId, + input.sectionId, + ); + await tx + .delete(judgingDeliberationSections) + .where(eq(judgingDeliberationSections.id, section.id)); + return { removed: true }; + }), + ), + + reorderSections: judgeProcedure + .input(z.object({ sectionIds: z.array(uuid).max(200) })) + .mutation(({ ctx, input }) => + deliberationWrite(ctx.db, ctx.judge, input.hackathonId, async (tx) => { + const sections = await tx + .select({ id: judgingDeliberationSections.id }) + .from(judgingDeliberationSections) + .where(eq(judgingDeliberationSections.judgeId, ctx.judge.judgeId)) + .for("update"); + if ( + !isReordering( + sections.map((s) => s.id), + input.sectionIds, + ) + ) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Your sections changed in another window. Reload, then reorder again.", + }); + } + for (const [index, id] of input.sectionIds.entries()) { + await tx + .update(judgingDeliberationSections) + .set({ displayOrder: index }) + .where(eq(judgingDeliberationSections.id, id)); + } + return { reordered: true }; + }), + ), + + // Only a project the judge has finished: a shortlist ranks what they saw. + // Finished is what placings count: every rating in, written response or not. + add: judgeProcedure + .input( + z.object({ + sectionId: uuid, + entryId: uuid, + note: z.string().max(2000).optional(), + }), + ) + .mutation(({ ctx, input }) => + deliberationWrite(ctx.db, ctx.judge, input.hackathonId, async (tx) => { + const section = await ownedSection( + tx, + ctx.judge.judgeId, + input.sectionId, + ); + const [finished] = await tx + .select({ id: judgingEvaluations.id }) + .from(judgingEvaluations) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEvaluations.entryId), + ) + .where( + and( + eq(judgingEvaluations.judgeId, ctx.judge.judgeId), + eq(judgingEvaluations.entryId, input.entryId), + holdsEveryRating(input.hackathonId), + isNull(judgingEntries.deletedAt), + ), + ) + .limit(1); + if (!finished) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "Finish judging this project before you add it to your shortlist.", + }); + } + + const [row] = await tx + .insert(judgingDeliberationEntries) + .values({ + sectionId: section.id, + entryId: input.entryId, + note: input.note?.trim() ?? "", + displayOrder: await appendOrder(tx, section.id), + }) + .onConflictDoNothing({ + target: [ + judgingDeliberationEntries.sectionId, + judgingDeliberationEntries.entryId, + ], + }) + .returning({ id: judgingDeliberationEntries.id }); + if (!row) { + throw new TRPCError({ code: "CONFLICT", message: ALREADY_LISTED }); + } + return row; + }), + ), + + remove: judgeProcedure + .input(z.object({ rowId: uuid })) + .mutation(({ ctx, input }) => + deliberationWrite(ctx.db, ctx.judge, input.hackathonId, async (tx) => { + const row = await ownedRow(tx, ctx.judge.judgeId, input.rowId); + await tx + .delete(judgingDeliberationEntries) + .where(eq(judgingDeliberationEntries.id, row.id)); + return { removed: true }; + }), + ), + + // To the end of another section, note and all. + move: judgeProcedure + .input(z.object({ rowId: uuid, sectionId: uuid })) + .mutation(({ ctx, input }) => + deliberationWrite(ctx.db, ctx.judge, input.hackathonId, async (tx) => { + const row = await ownedRow(tx, ctx.judge.judgeId, input.rowId); + if (row.sectionId === input.sectionId) return { moved: false }; + const target = await ownedSection( + tx, + ctx.judge.judgeId, + input.sectionId, + ); + const [clash] = await tx + .select({ id: judgingDeliberationEntries.id }) + .from(judgingDeliberationEntries) + .where( + and( + eq(judgingDeliberationEntries.sectionId, target.id), + eq(judgingDeliberationEntries.entryId, row.entryId), + ), + ) + .limit(1); + if (clash) { + throw new TRPCError({ code: "CONFLICT", message: ALREADY_LISTED }); + } + await tx + .update(judgingDeliberationEntries) + .set({ + sectionId: target.id, + displayOrder: await appendOrder(tx, target.id), + }) + .where(eq(judgingDeliberationEntries.id, row.id)); + return { moved: true }; + }), + ), + + reorder: judgeProcedure + .input(z.object({ sectionId: uuid, rowIds: z.array(uuid).max(1000) })) + .mutation(({ ctx, input }) => + deliberationWrite(ctx.db, ctx.judge, input.hackathonId, async (tx) => { + const section = await ownedSection( + tx, + ctx.judge.judgeId, + input.sectionId, + ); + const rows = await tx + .select({ id: judgingDeliberationEntries.id }) + .from(judgingDeliberationEntries) + .where(eq(judgingDeliberationEntries.sectionId, section.id)) + .for("update"); + if ( + !isReordering( + rows.map((row) => row.id), + input.rowIds, + ) + ) { + throw new TRPCError({ + code: "CONFLICT", + message: + "This section changed in another window. Reload, then reorder again.", + }); + } + for (const [index, id] of input.rowIds.entries()) { + await tx + .update(judgingDeliberationEntries) + .set({ displayOrder: index }) + .where(eq(judgingDeliberationEntries.id, id)); + } + return { reordered: true }; + }), + ), + + setNote: judgeProcedure + .input( + z.object({ + rowId: uuid, + note: z.string().max(2000, "Keep notes under 2000 characters."), + }), + ) + .mutation(({ ctx, input }) => + deliberationWrite(ctx.db, ctx.judge, input.hackathonId, async (tx) => { + const row = await ownedRow(tx, ctx.judge.judgeId, input.rowId); + await tx + .update(judgingDeliberationEntries) + .set({ note: input.note }) + .where(eq(judgingDeliberationEntries.id, row.id)); + return { id: row.id, note: input.note }; + }), + ), +}); + +export const judgingDeskRouter = createTRPCRouter({ + // Who is at the desk, which edition, which room, and where judging stands. + // A guest who has not given a name yet gets `needsName` and little else. + context: deskProcedure.query(async ({ ctx, input }) => { + const db = ctx.db; + const { principal } = ctx; + const judge = asJudge(principal); + if (judge) await reconcileDueDrafts(db, input.hackathonId); + + const [hackathon] = await db + .select({ id: hackathons.id, name: hackathons.name }) + .from(hackathons) + .where(eq(hackathons.id, input.hackathonId)) + .limit(1); + if (!hackathon) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "That hackathon does not exist. Open the desk from the judging page.", + }); + } + + const challenges = await loadChallenges(db, input.hackathonId); + const switches = await judgingSwitches(db, input.hackathonId); + const seat = judge ? await seatOf(db, judge.judgeId, challenges) : null; + const roomId = + principal.kind === "guest" ? principal.roomId : (seat?.roomId ?? null); + // A guest sees their room's challenge; a table judge sees General's and, + // from a seat in its room, a sponsor's. + const visible = + principal.kind === "guest" + ? guestScope(challenges, principal.roomChallengeId) + : memberScope(challenges, seat); + + return { + kind: principal.kind, + needsName: !judge, + judge: judge + ? { id: judge.judgeId, displayName: judge.displayName } + : null, + hackathon, + state: switches.state, + showAllScores: principal.kind === "member" && switches.showAllScores, + room: roomId ? await roomSummary(db, roomId, challenges) : null, + challenges: challenges + .filter((c) => visible.has(c.id)) + .map((c) => ({ + id: c.id, + label: c.label, + parentId: c.parentId, + isGeneral: c.isGeneral, + isGroup: c.isGroup, + mode: modeOf(challenges, c), + })), + serverNow: new Date(), + }; + }), + + // The guest name gate. Creates the guest's judge row and seats them in the + // room their QR code opened. + setGuestName: deskProcedure + .input( + z.object({ + displayName: z + .string() + .trim() + .min(1, "Enter your name.") + .max(80, "Keep your name under 80 characters."), + }), + ) + .mutation(async ({ ctx, input }) => { + if (ctx.principal.kind !== "guest") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "You are signed in as a judge, so there is no guest name to set.", + }); + } + return nameGuest( + ctx.db, + ctx.principal, + input.displayName.replace(/\s+/g, " "), + ); + }), + + // Open rooms and tables to sit at. A guest sees only their own. + rooms: judgeProcedure.query(async ({ ctx, input }) => { + const db = ctx.db; + const challenges = await loadChallenges(db, input.hackathonId); + const seat = await seatOf(db, ctx.judge.judgeId, challenges); + const track = + ctx.judge.kind === "member" + ? await preferredTrackOf(db, ctx.judge.userId, input.hackathonId) + : null; + const rows = await db + .select({ + id: judgingRooms.id, + name: judgingRooms.name, + kind: judgingRooms.kind, + buildingName: judgingBuildings.name, + challengeId: judgingRooms.challengeId, + }) + .from(judgingRooms) + .leftJoin( + judgingBuildings, + eq(judgingBuildings.id, judgingRooms.buildingId), + ) + .where( + and( + eq(judgingRooms.hackathonId, input.hackathonId), + isNull(judgingRooms.archivedAt), + ctx.judge.kind === "guest" + ? eq(judgingRooms.id, ctx.judge.roomId) + : undefined, + ), + ) + .orderBy(asc(judgingRooms.displayOrder), asc(judgingRooms.name)); + const labelOf = new Map(challenges.map((c) => [c.id, c.label])); + + return { + currentRoomId: seat?.roomId ?? null, + rooms: rows.map((room) => ({ + ...room, + challengeLabel: labelOf.get(room.challengeId) ?? "", + mode: effectiveMode(challenges, room.challengeId) ?? "scheduled", + // The room serves the track the judge applied for, by its challenge + // or the group that challenge sits in. + yourTrack: + !!track && + [ + labelOf.get(room.challengeId), + rootOf(challenges, room.challengeId)?.label, + ].some((label) => !!label && sameLabel(label, track)), + })), + }; + }), + + joinRoom: judgeProcedure + .input(z.object({ roomId: uuid })) + .mutation(async ({ ctx, input }) => { + if (ctx.judge.kind !== "member") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Guest judges stay at the room or table their QR code opened.", + }); + } + const [room] = await ctx.db + .select({ id: judgingRooms.id }) + .from(judgingRooms) + .where( + and( + eq(judgingRooms.id, input.roomId), + eq(judgingRooms.hackathonId, input.hackathonId), + isNull(judgingRooms.archivedAt), + ), + ) + .limit(1); + if (!room) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "That room or table is not open for judging. Pick another from the list.", + }); + } + const judgeId = ctx.judge.judgeId; + await ctx.db.transaction((tx) => + seatJudge(tx, { + judgeId, + roomId: room.id, + hackathonId: input.hackathonId, + }), + ); + return { roomId: room.id }; + }), + + leaveRoom: judgeProcedure.mutation(async ({ ctx }) => { + if (ctx.judge.kind !== "member") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Guest judges stay at the room or table their QR code opened.", + }); + } + const closed = await ctx.db + .update(judgingPresence) + .set({ leftAt: new Date() }) + .where( + and( + eq(judgingPresence.judgeId, ctx.judge.judgeId), + isNull(judgingPresence.leftAt), + ), + ) + .returning({ id: judgingPresence.id }); + return { left: closed.length > 0 }; + }), + + // Tells organisers the judge is still in the room (polled every 60s). + heartbeat: judgeProcedure.mutation(async ({ ctx }) => { + const [seat] = await ctx.db + .update(judgingPresence) + .set({ lastSeenAt: new Date() }) + .where( + and( + eq(judgingPresence.judgeId, ctx.judge.judgeId), + isNull(judgingPresence.leftAt), + ), + ) + .returning({ roomId: judgingPresence.roomId }); + return { roomId: seat?.roomId ?? null }; + }), + + // The edition-wide message and the judge's room's, global first. Guests see + // only messages organisers marked for them. + announcements: judgeProcedure.query(async ({ ctx, input }) => { + const db = ctx.db; + const roomId = + ctx.judge.kind === "guest" + ? ctx.judge.roomId + : await openRoomId(db, ctx.judge.judgeId); + + const rows = await db + .select({ + id: judgingAnnouncements.id, + message: judgingAnnouncements.message, + isUrgent: judgingAnnouncements.isUrgent, + roomId: judgingAnnouncements.roomId, + roomName: judgingRooms.name, + createdAt: judgingAnnouncements.createdAt, + }) + .from(judgingAnnouncements) + .leftJoin(judgingRooms, eq(judgingRooms.id, judgingAnnouncements.roomId)) + .where( + and( + eq(judgingAnnouncements.hackathonId, input.hackathonId), + isNull(judgingAnnouncements.clearedAt), + roomId + ? or( + isNull(judgingAnnouncements.roomId), + eq(judgingAnnouncements.roomId, roomId), + ) + : isNull(judgingAnnouncements.roomId), + ctx.judge.kind === "guest" + ? eq(judgingAnnouncements.includeGuests, true) + : undefined, + ), + ) + .orderBy(asc(judgingAnnouncements.createdAt)); + + return rows.sort( + (a, b) => Number(a.roomId !== null) - Number(b.roomId !== null), + ); + }), + + // The projects to judge for one challenge. By default: in presentation + // order, only those presenting in the judge's room, not yet judged by them. + // Each says whether it is still open to grade: a project is graded once, so + // one another judge graded or holds is listed after the open ones, as is + // one still reserved for the judge assigned to it, with when it opens. + // Presentations assigned to this judge come first, marked as theirs. + projects: judgeProcedure + .input( + z.object({ + challengeId: uuid.optional(), + search: z.string().trim().max(100).optional(), + hideJudged: z.boolean().default(true), + inMyRoomOnly: z.boolean().default(true), + sort: z.enum(["scheduled", "title"]).default("scheduled"), + }), + ) + .query(async ({ ctx, input }) => { + const db = ctx.db; + const { judge } = ctx; + await reconcileDueDrafts(db, input.hackathonId); + + const challenges = await loadChallenges(db, input.hackathonId); + const seat = await seatOf(db, judge.judgeId, challenges); + const challenge = evaluatedChallenge( + judge, + challenges, + input.challengeId, + seat, + ); + const mode = modeOf(challenges, challenge); + const [schedule] = await db + .select({ id: judgingSchedules.id }) + .from(judgingSchedules) + .where(eq(judgingSchedules.hackathonId, input.hackathonId)) + .limit(1); + + // Why the room filter is or is not applied, so the desk can say so. + const roomFilter = !input.inMyRoomOnly + ? ("off" as const) + : !schedule || mode !== "scheduled" + ? ("untimed" as const) + : !seat || seat.challengeId !== challenge.id + ? ("no-room" as const) + : ("applied" as const); + + const entries = await db + .select({ + id: judgingEntries.id, + title: judgingEntries.title, + participantCount: judgingEntries.participantCount, + }) + .from(judgingEntries) + .innerJoin( + judgingEntryChallenges, + and( + eq(judgingEntryChallenges.entryId, judgingEntries.id), + eq( + judgingEntryChallenges.challengeId, + input.challengeId ?? challenge.id, + ), + ), + ) + .where( + and( + eq(judgingEntries.hackathonId, input.hackathonId), + isNull(judgingEntries.deletedAt), + input.search + ? ilike(judgingEntries.title, `%${escapeLike(input.search)}%`) + : undefined, + ), + ); + + const base = { + challenge: { + id: challenge.id, + label: challenge.label, + mode, + }, + roomFilter, + serverNow: new Date(), + }; + if (entries.length === 0) { + return { ...base, judgedCount: 0, projects: [] }; + } + const entryIds = entries.map((entry) => entry.id); + + const presentations = schedule + ? await db + .select({ + id: judgingAppointments.id, + entryId: judgingAppointments.entryId, + roomId: judgingAppointments.roomId, + roomName: judgingRooms.name, + buildingName: judgingBuildings.name, + startsAt: judgingAppointments.startsAt, + deadlineAt: judgingAppointments.deadlineAt, + endsAt: judgingAppointments.endsAt, + assignedJudgeId: judgingAppointments.assignedJudgeId, + }) + .from(judgingAppointments) + .innerJoin( + judgingRooms, + eq(judgingRooms.id, judgingAppointments.roomId), + ) + .leftJoin( + judgingBuildings, + eq(judgingBuildings.id, judgingRooms.buildingId), + ) + .where( + and( + eq(judgingAppointments.scheduleId, schedule.id), + eq(judgingAppointments.challengeId, challenge.id), + inArray(judgingAppointments.entryId, entryIds), + ), + ) + : []; + // Every judge's, to tell which projects someone has: a judge is only + // ever shown their own. + const evaluations = await db + .select({ + entryId: judgingEvaluations.entryId, + judgeId: judgingEvaluations.judgeId, + revision: judgingEvaluations.revision, + // Every rating in, as placings count it. + counted: holdsEveryRating(input.hackathonId), + }) + .from(judgingEvaluations) + .where( + and( + eq(judgingEvaluations.challengeId, challenge.id), + inArray(judgingEvaluations.entryId, entryIds), + ), + ); + const drafts = await db + .select({ + entryId: judgingEvaluationDrafts.entryId, + judgeId: judgingEvaluationDrafts.judgeId, + updatedAt: judgingEvaluationDrafts.updatedAt, + deadlineAt: judgingEvaluationDrafts.deadlineAt, + revision: judgingEvaluationDrafts.revision, + }) + .from(judgingEvaluationDrafts) + .where( + and( + eq(judgingEvaluationDrafts.challengeId, challenge.id), + inArray(judgingEvaluationDrafts.entryId, entryIds), + ), + ); + const tags = await tagsOf(db, judge, challenges, entryIds); + // One yes or no per project and nothing else. Who was flagged and why + // is for organisers; this list is polled by every judge, guests included. + const flagged = await flaggedEntryIds(db, input.hackathonId, entryIds); + + const presentationOf = new Map(presentations.map((p) => [p.entryId, p])); + const evaluationOf = new Map( + evaluations + .filter((e) => e.judgeId === judge.judgeId) + .map((e) => [e.entryId, e]), + ); + // A draft the judge has saved to. Opening a project makes an empty one + // (startGrading), and that is not a draft saved. + const drafted = new Set( + drafts + .filter((d) => d.judgeId === judge.judgeId && d.revision > 0) + .map((d) => d.entryId), + ); + // A scheduled challenge's presentation assigned to another judge is kept + // for them until its takeover time. Never says which judge. + const now = new Date(); + const timed = !!schedule && mode === "scheduled"; + const reserved = new Map(); + if (timed) { + for (const presentation of presentations) { + const until = reservedUntilOf(presentation, judge.judgeId, now); + if (until) reserved.set(presentation.entryId, until); + } + } + const grading = gradingStates( + judge.judgeId, + entryIds, + evaluations, + drafts, + now, + reserved, + ); + + const inView = entries.filter( + (entry) => + !( + roomFilter === "applied" && + presentationOf.get(entry.id)?.roomId !== seat?.roomId + ), + ); + // Judged means the evaluation counts, as placings count it: every rating + // in, written response or not. One missing a rating is not hidden. + const judgedCount = inView.filter( + (entry) => evaluationOf.get(entry.id)?.counted, + ).length; + + const projects = inView + .filter( + (entry) => !(input.hideJudged && evaluationOf.get(entry.id)?.counted), + ) + .map((entry) => { + const presentation = presentationOf.get(entry.id); + const evaluation = evaluationOf.get(entry.id); + return { + ...entry, + challenges: tags.get(entry.id) ?? [], + flagged: flagged.has(entry.id), + presentation: presentation + ? { + id: presentation.id, + roomId: presentation.roomId, + roomName: presentation.roomName, + buildingName: presentation.buildingName, + startsAt: presentation.startsAt, + deadlineAt: presentation.deadlineAt, + endsAt: presentation.endsAt, + } + : null, + myEvaluation: evaluation + ? { + revision: evaluation.revision, + counted: evaluation.counted, + } + : null, + hasDraft: drafted.has(entry.id), + grading: grading.get(entry.id) ?? ("open" as const), + // When a reserved project opens to this judge. + reservedUntil: + grading.get(entry.id) === "reserved" + ? (reserved.get(entry.id) ?? null) + : null, + assignedToYou: + timed && presentation?.assignedJudgeId === judge.judgeId, + }; + }); + + // What the judge can still do comes first, then projects another judge + // is grading or is assigned, then those another judge graded. + const standing = (state: GradingState) => + state === "graded" + ? 2 + : state === "in_progress" || state === "reserved" + ? 1 + : 0; + const byTitle = (a: { title: string }, b: { title: string }) => + a.title.localeCompare(b.title); + // Nothing is timed here, so open projects follow the judge's own queue, + // the order Grade next project walks: the natural pick is the fair one. + const queued = + input.sort === "scheduled" && (!schedule || mode !== "scheduled") + ? new Map( + queueOrder( + judge.judgeId, + challenge.id, + projects.filter((p) => p.grading === "open").map((p) => p.id), + ).map((id, place) => [id, place]), + ) + : null; + projects.sort((a, b) => { + const open = standing(a.grading) - standing(b.grading); + if (open !== 0) return open; + if (queued) { + // The judge's own come first, then the open ones in queue order. + const mine = + Number(b.grading === "mine") - Number(a.grading === "mine"); + if (mine !== 0) return mine; + const at = queued.get(a.id); + const bt = queued.get(b.id); + if (at !== undefined && bt !== undefined) return at - bt; + } else if (input.sort === "scheduled") { + // Their own assigned presentations lead, in start order. + const assigned = Number(b.assignedToYou) - Number(a.assignedToYou); + if (assigned !== 0) return assigned; + const at = a.presentation?.startsAt.getTime() ?? Infinity; + const bt = b.presentation?.startsAt.getTime() ?? Infinity; + if (at !== bt) return at - bt; + } + return byTitle(a, b); + }); + + return { ...base, judgedCount, projects }; + }), + + // The presentations assigned to this judge that have not ended, soonest + // first: what they are to judge and where to go for it. Only the judge's + // own; whether another judge graded or took one over is told without saying + // who. A project has no table of its own here: a table is a room whose kind + // says so. + itinerary: judgeProcedure.query(async ({ ctx, input }) => { + const db = ctx.db; + const { judge } = ctx; + await reconcileDueDrafts(db, input.hackathonId); + + const now = new Date(); + const [schedule] = await db + .select({ id: judgingSchedules.id }) + .from(judgingSchedules) + .where(eq(judgingSchedules.hackathonId, input.hackathonId)) + .limit(1); + if (!schedule) return { presentations: [], serverNow: now }; + + const rows = await db + .select({ + id: judgingAppointments.id, + entryId: judgingAppointments.entryId, + challengeId: judgingAppointments.challengeId, + title: judgingEntries.title, + roomId: judgingRooms.id, + roomName: judgingRooms.name, + roomKind: judgingRooms.kind, + buildingName: judgingBuildings.name, + startsAt: judgingAppointments.startsAt, + deadlineAt: judgingAppointments.deadlineAt, + }) + .from(judgingAppointments) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingAppointments.entryId), + ) + .innerJoin(judgingRooms, eq(judgingRooms.id, judgingAppointments.roomId)) + .leftJoin( + judgingBuildings, + eq(judgingBuildings.id, judgingRooms.buildingId), + ) + .where( + and( + eq(judgingAppointments.scheduleId, schedule.id), + eq(judgingAppointments.assignedJudgeId, judge.judgeId), + gt(judgingAppointments.deadlineAt, now), + isNull(judgingEntries.deletedAt), + ), + ) + .orderBy(asc(judgingAppointments.startsAt)); + if (rows.length === 0) return { presentations: [], serverNow: now }; + + const challenges = await loadChallenges(db, input.hackathonId); + const labelOf = new Map(challenges.map((c) => [c.id, c.label])); + const roomId = + judge.kind === "guest" + ? judge.roomId + : ((await seatOf(db, judge.judgeId, challenges))?.roomId ?? null); + + const entryIds = [...new Set(rows.map((row) => row.entryId))]; + const evaluations = await db + .select({ + entryId: judgingEvaluations.entryId, + challengeId: judgingEvaluations.challengeId, + judgeId: judgingEvaluations.judgeId, + }) + .from(judgingEvaluations) + .where(inArray(judgingEvaluations.entryId, entryIds)); + const drafts = await db + .select({ + entryId: judgingEvaluationDrafts.entryId, + challengeId: judgingEvaluationDrafts.challengeId, + judgeId: judgingEvaluationDrafts.judgeId, + updatedAt: judgingEvaluationDrafts.updatedAt, + deadlineAt: judgingEvaluationDrafts.deadlineAt, + }) + .from(judgingEvaluationDrafts) + .where(inArray(judgingEvaluationDrafts.entryId, entryIds)); + + return { + presentations: rows.map((row) => { + const same = (task: { entryId: string; challengeId: string }) => + task.entryId === row.entryId && task.challengeId === row.challengeId; + const graders = evaluations.filter(same); + return { + ...row, + challengeLabel: labelOf.get(row.challengeId) ?? "", + // Graded by anyone: nothing is left to do for it. + graded: graders.length > 0, + // Another judge started it after the takeover time. + takenOver: + gradingStateOf(judge.judgeId, graders, drafts.filter(same), now) === + "in_progress", + inRoom: row.roomId === roomId, + }; + }), + serverNow: now, + }; + }), + + // One project as the evaluation editor needs it: the project (names only + // for the team), the rubric, the judge's draft or evaluation, whether they + // can write now and against what deadline, and scores once they may see + // them. + project: judgeProcedure + .input(z.object({ entryId: uuid, challengeId: uuid.optional() })) + .query(async ({ ctx, input }) => { + const db = ctx.db; + const { judge } = ctx; + await reconcileDueDrafts(db, input.hackathonId); + + const challenges = await loadChallenges(db, input.hackathonId); + const seat = await seatOf(db, judge.judgeId, challenges); + const challenge = evaluatedChallenge( + judge, + challenges, + input.challengeId, + seat, + ); + const entry = await loadEntryInChallenge(db, { + hackathonId: input.hackathonId, + entryId: input.entryId, + challenge, + }); + + const members = await db + .select({ name: judgingEntryMembers.name }) + .from(judgingEntryMembers) + .where(eq(judgingEntryMembers.entryId, entry.id)) + .orderBy( + asc(judgingEntryMembers.displayOrder), + asc(judgingEntryMembers.createdAt), + ); + const tags = await tagsOf(db, judge, challenges, [entry.id]); + // A bare marker, as in the project list: never who or why. + const flagged = await flaggedEntryIds(db, input.hackathonId, [entry.id]); + const rubric = await loadRubric(db, input.hackathonId); + const switches = await judgingSwitches(db, input.hackathonId); + + const [evaluation] = await db + .select({ + id: judgingEvaluations.id, + revision: judgingEvaluations.revision, + isComplete: judgingEvaluations.isComplete, + appointmentId: judgingEvaluations.appointmentId, + submittedAt: judgingEvaluations.submittedAt, + autoSubmittedAt: judgingEvaluations.autoSubmittedAt, + updatedAt: judgingEvaluations.updatedAt, + }) + .from(judgingEvaluations) + .where( + taskOf(judgingEvaluations, judge.judgeId, entry.id, challenge.id), + ) + .limit(1); + const [draft] = await db + .select({ + ratings: judgingEvaluationDrafts.ratings, + responses: judgingEvaluationDrafts.responses, + revision: judgingEvaluationDrafts.revision, + deadlineAt: judgingEvaluationDrafts.deadlineAt, + updatedAt: judgingEvaluationDrafts.updatedAt, + }) + .from(judgingEvaluationDrafts) + .where( + taskOf( + judgingEvaluationDrafts, + judge.judgeId, + entry.id, + challenge.id, + ), + ) + .limit(1); + + // Every judge's, narrowed to what says who has the project. + const graders = await db + .select({ judgeId: judgingEvaluations.judgeId }) + .from(judgingEvaluations) + .where( + and( + eq(judgingEvaluations.entryId, entry.id), + eq(judgingEvaluations.challengeId, challenge.id), + ), + ); + const holders = await db + .select({ + judgeId: judgingEvaluationDrafts.judgeId, + updatedAt: judgingEvaluationDrafts.updatedAt, + deadlineAt: judgingEvaluationDrafts.deadlineAt, + }) + .from(judgingEvaluationDrafts) + .where( + and( + eq(judgingEvaluationDrafts.entryId, entry.id), + eq(judgingEvaluationDrafts.challengeId, challenge.id), + ), + ); + const { timing, presentation, reservedUntil } = await evaluationAccess( + db, + { + hackathonId: input.hackathonId, + judgeId: judge.judgeId, + entryId: entry.id, + challenge, + challenges, + evaluation: evaluation ?? null, + }, + ); + const grading = gradingStateOf( + judge.judgeId, + graders, + holders, + new Date(), + reservedUntil, + ); + // Another judge's project stays readable; it cannot be written to. + // One reserved for another judge is shut by the timing, which says when + // it opens. + const gradedElsewhere = gradingRefusal(grading, challenge.label); + const presentationRoom = presentation + ? await roomSummary(db, presentation.roomId, challenges) + : null; + + // An evaluation counts once it rated every rating item, as placings + // count it: a draft submitted at the deadline with a response left + // empty still does. + const ratingItems = rubric.filter( + (item) => item.kind === "rating", + ).length; + + // A judge sees how a project is scoring once they have judged it + // themselves, or (members only) when organisers show every score. + // Guests never see the overall score across challenges. + const scoresVisible = + !!evaluation || (judge.kind === "member" && switches.showAllScores); + let scores: { + challenge: ReturnType; + overall: ReturnType | null; + } | null = null; + if (scoresVisible) { + const submitted = await db + .select({ + id: judgingEvaluations.id, + challengeId: judgingEvaluations.challengeId, + }) + .from(judgingEvaluations) + .where(eq(judgingEvaluations.entryId, entry.id)); + const answers = await answersOf( + db, + submitted.map((e) => e.id), + ); + const scored = submitted.map((e) => { + const ratings = Object.values(answers.get(e.id)?.ratings ?? {}); + return { + challengeId: e.challengeId, + ratingsComplete: hasEveryRating(ratings, ratingItems), + ratings, + }; + }); + scores = { + challenge: scopedScore(scored, challenge.id), + overall: judge.kind === "member" ? overallScore(scored) : null, + }; + } + + const mine = evaluation + ? (await answersOf(db, [evaluation.id])).get(evaluation.id) + : undefined; + const open = switches.state === "open"; + + return { + entry: { + ...entry, + members: members.map((member) => member.name), + challenges: tags.get(entry.id) ?? [], + flagged: flagged.has(entry.id), + }, + challenge: { + id: challenge.id, + label: challenge.label, + mode: modeOf(challenges, challenge), + }, + rubric: rubric.map((item) => ({ + id: item.id, + kind: item.kind, + label: item.label, + description: item.description, + required: item.kind === "rating" || item.required, + // Who will read this judge's answer: the team, the judge's choice, + // or organisers only. + visibility: + item.kind === "response" + ? responseVisibility(item, judge.kind) + : null, + })), + evaluation: evaluation + ? { + id: evaluation.id, + revision: evaluation.revision, + isComplete: evaluation.isComplete, + submittedAt: evaluation.submittedAt, + autoSubmittedAt: evaluation.autoSubmittedAt, + updatedAt: evaluation.updatedAt, + ratings: mine?.ratings ?? {}, + responses: mine?.responses ?? {}, + score: hasEveryRating( + Object.values(mine?.ratings ?? {}), + ratingItems, + ) + ? evaluationScore(Object.values(mine?.ratings ?? {})) + : null, + } + : null, + draft: draft ?? null, + // Where the project stands for this judge: theirs, graded by another + // judge, being graded by one, reserved for the judge assigned to it, + // or open. Never which judge. + grading, + access: { + judgingState: switches.state, + canEdit: open && timing.canEdit && !gradedElsewhere, + reason: !open + ? switches.state === "closed" + ? CLOSED + : NOT_OPEN_YET + : (gradedElsewhere ?? (timing.canEdit ? null : timing.reason)), + // Shut because another judge graded the project or is grading it. + gradedElsewhere: gradedElsewhere !== null, + deadlineAt: + timing.canEdit && !gradedElsewhere ? timing.deadlineAt : null, + opensAt: timing.canEdit || gradedElsewhere ? null : timing.opensAt, + // Shut because the presentation's deadline passed: scores are final. + ended: open && !gradedElsewhere && !timing.canEdit && timing.ended, + // Shut only because the judge is not in the room that serves this + // challenge: a slot exists and has no start to wait for. + needsRoom: + open && + !gradedElsewhere && + !timing.canEdit && + !timing.opensAt && + !timing.ended && + presentation !== null, + }, + presentation: + presentation && presentationRoom + ? { + startsAt: presentation.startsAt, + deadlineAt: presentation.deadlineAt, + endsAt: presentation.endsAt, + roomName: presentationRoom.name, + buildingName: presentationRoom.buildingName, + } + : null, + scores, + serverNow: new Date(), + }; + }), + + // The next project for this judge to grade, in the challenge they pick or + // the one they sit at. Each judge walks the projects in an order of their own + // (services/judging/queue.ts), so judges at one table rarely reach for the + // same one. Only open projects: not graded, not held by another judge. At an + // untimed challenge only the first QUEUE_WINDOW of them are on offer, and + // `skip` passes over projects the judge turned down (a team away from its + // table). At a scheduled challenge, only presentations running now in the + // judge's room. + nextProject: judgeProcedure + .input( + z.object({ + challengeId: uuid.optional(), + skip: z.array(uuid).max(20).default([]), + }), + ) + .query(async ({ ctx, input }) => { + const db = ctx.db; + const { judge } = ctx; + await reconcileDueDrafts(db, input.hackathonId); + + const challenges = await loadChallenges(db, input.hackathonId); + const seat = await seatOf(db, judge.judgeId, challenges); + const challenge = evaluatedChallenge( + judge, + challenges, + input.challengeId, + seat, + ); + const mode = modeOf(challenges, challenge); + const found = ( + entryId: string | null, + needsRoom = false, + allSkipped = false, + ) => ({ + challenge: { id: challenge.id, label: challenge.label, mode }, + entryId, + needsRoom, + allSkipped, + }); + + const queue = await loadQueue(db, { + hackathonId: input.hackathonId, + judgeId: judge.judgeId, + challenge, + seat, + filterId: input.challengeId, + }); + if (queue.needsRoom) return found(null, true); + + // The schedule decides who sees what at a scheduled challenge. + const window = queue.timed ? Infinity : QUEUE_WINDOW; + const entryId = nextToGrade( + judge.judgeId, + challenge.id, + queue.candidates, + { skip: input.skip, window }, + ); + const allSkipped = + entryId === null && + queueWindow(judge.judgeId, challenge.id, queue.candidates, window) + .length > 0; + return found(entryId, false, allSkipped); + }), + + // Opening a project to grade it. Runs the checks a first save would (the + // project is free, the queue offers it) and, when it passes, creates or + // touches the judge's draft with no answers needed, so the hold starts as + // the judge opens the project rather than at their first save. The draft + // keeps saveDraft's deadline and revision, so the editor carries on from it. + startGrading: judgeProcedure + .input(z.object({ entryId: uuid, challengeId: uuid.optional() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db; + const { judge } = ctx; + await reconcileDueDrafts(db, input.hackathonId); + + const challenges = await loadChallenges(db, input.hackathonId); + const challenge = evaluatedChallenge( + judge, + challenges, + input.challengeId, + await memberSeat(db, judge, challenges), + ); + + try { + return await db.transaction(async (tx) => { + await assertJudgingOpen(tx, input.hackathonId); + await assertGuestSeated(tx, judge); + await loadEntryInChallenge(tx, { + hackathonId: input.hackathonId, + entryId: input.entryId, + challenge, + }); + + const [draft] = await tx + .select({ + id: judgingEvaluationDrafts.id, + revision: judgingEvaluationDrafts.revision, + deadlineAt: judgingEvaluationDrafts.deadlineAt, + }) + .from(judgingEvaluationDrafts) + .where( + taskOf( + judgingEvaluationDrafts, + judge.judgeId, + input.entryId, + challenge.id, + ), + ) + .for("update") + .limit(1); + const [evaluation] = await tx + .select({ id: judgingEvaluations.id }) + .from(judgingEvaluations) + .where( + taskOf( + judgingEvaluations, + judge.judgeId, + input.entryId, + challenge.id, + ), + ) + .limit(1); + // Their own graded project is theirs to edit; there is nothing to hold. + if (evaluation) { + return { + held: false, + revision: draft?.revision ?? 0, + deadlineAt: draft?.deadlineAt ?? null, + }; + } + + await claimGrading(tx, { + judgeId: judge.judgeId, + entryId: input.entryId, + challenge, + }); + if (!draft) { + await assertQueueOffers(tx, { + hackathonId: input.hackathonId, + judgeId: judge.judgeId, + entryId: input.entryId, + challenge, + challenges, + }); + } + + const { timing } = await evaluationAccess(tx, { + hackathonId: input.hackathonId, + judgeId: judge.judgeId, + entryId: input.entryId, + challenge, + challenges, + evaluation: null, + graceMs: DEADLINE_GRACE_MS, + }); + if (!timing.canEdit) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: timing.reason, + }); + } + + // As saveDraft stamps a draft that is not an edit of an evaluation. + const deadlineAt = timing.deadlineAt ?? draft?.deadlineAt ?? null; + const now = new Date(); + if (draft) { + await tx + .update(judgingEvaluationDrafts) + .set({ + appointmentId: timing.appointmentId, + deadlineAt, + updatedAt: now, + }) + .where(eq(judgingEvaluationDrafts.id, draft.id)); + } else { + await tx.insert(judgingEvaluationDrafts).values({ + hackathonId: input.hackathonId, + judgeId: judge.judgeId, + entryId: input.entryId, + challengeId: challenge.id, + ratings: {}, + responses: {}, + appointmentId: timing.appointmentId, + deadlineAt, + revision: 0, + updatedAt: now, + }); + } + + return { held: true, revision: draft?.revision ?? 0, deadlineAt }; + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: + "This project was just opened from another window. Reload to pick up the latest.", + }); + } + throw error; + } + }), + + // Gives back a project the judge opened (startGrading) and never saved to: + // deletes their own draft while it is still revision 0, and only when they + // have no evaluation of the project. A saved draft is work and stays. Called + // when the judge skips a project or leaves its sheet unsaved, so a skipped + // project is not held for GRADING_HOLD_MS and an accidental open of a + // scheduled presentation is not held until its deadline. It touches only the + // judge's own draft, so it needs no seat or sponsor check. + releaseGrading: judgeProcedure + .input(z.object({ entryId: uuid, challengeId: uuid.optional() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db; + const { judge } = ctx; + + const challenges = await loadChallenges(db, input.hackathonId); + const challengeId = input.challengeId + ? rootOf(challenges, input.challengeId)?.id + : evaluatedChallenge( + judge, + challenges, + undefined, + await memberSeat(db, judge, challenges), + ).id; + if (!challengeId) return { released: false }; + + return db.transaction(async (tx) => { + const [draft] = await tx + .select({ + id: judgingEvaluationDrafts.id, + revision: judgingEvaluationDrafts.revision, + }) + .from(judgingEvaluationDrafts) + .where( + taskOf( + judgingEvaluationDrafts, + judge.judgeId, + input.entryId, + challengeId, + ), + ) + .for("update") + .limit(1); + if (!draft || draft.revision > 0) return { released: false }; + + const [evaluation] = await tx + .select({ id: judgingEvaluations.id }) + .from(judgingEvaluations) + .where( + taskOf( + judgingEvaluations, + judge.judgeId, + input.entryId, + challengeId, + ), + ) + .limit(1); + if (evaluation) return { released: false }; + + await tx + .delete(judgingEvaluationDrafts) + .where(eq(judgingEvaluationDrafts.id, draft.id)); + return { released: true }; + }); + }), + + // Autosave. `revision` is the draft revision the editor last saw (0 for + // none); a save from a stale window is refused rather than overwriting. + // The draft takes the presentation's deadline, and is submitted for the + // judge when it passes. + saveDraft: judgeProcedure + .input( + z.object({ + entryId: uuid, + challengeId: uuid, + ratings: ratingsInput, + responses: responsesInput, + revision: z.number().int().min(0), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db; + const { judge } = ctx; + await reconcileDueDrafts(db, input.hackathonId); + + const challenges = await loadChallenges(db, input.hackathonId); + const challenge = evaluatedChallenge( + judge, + challenges, + input.challengeId, + await memberSeat(db, judge, challenges), + ); + const rubric = await loadRubric(db, input.hackathonId); + const check = checkAnswers(rubric, input, judge.kind, "strict"); + if (!check.ok) { + throw new TRPCError({ code: "BAD_REQUEST", message: check.message }); + } + + try { + return await db.transaction(async (tx) => { + await assertJudgingOpen(tx, input.hackathonId); + await assertGuestSeated(tx, judge); + await loadEntryInChallenge(tx, { + hackathonId: input.hackathonId, + entryId: input.entryId, + challenge, + }); + + const [draft] = await tx + .select({ + id: judgingEvaluationDrafts.id, + revision: judgingEvaluationDrafts.revision, + deadlineAt: judgingEvaluationDrafts.deadlineAt, + }) + .from(judgingEvaluationDrafts) + .where( + taskOf( + judgingEvaluationDrafts, + judge.judgeId, + input.entryId, + challenge.id, + ), + ) + .for("update") + .limit(1); + const [evaluation] = await tx + .select({ appointmentId: judgingEvaluations.appointmentId }) + .from(judgingEvaluations) + .where( + taskOf( + judgingEvaluations, + judge.judgeId, + input.entryId, + challenge.id, + ), + ) + .limit(1); + + // A project is graded once: a judge who has not graded it yet + // needs it free. Their own draft is locked above, ahead of this. + if (!evaluation) { + await claimGrading(tx, { + judgeId: judge.judgeId, + entryId: input.entryId, + challenge, + }); + // Starting a project means taking one their queue offers. + if (!draft) { + await assertQueueOffers(tx, { + hackathonId: input.hackathonId, + judgeId: judge.judgeId, + entryId: input.entryId, + challenge, + challenges, + }); + } + } + + if ((draft?.revision ?? 0) !== input.revision) { + throw new TRPCError({ + code: "CONFLICT", + message: + evaluation && !draft + ? "Time ran out, so your saved answers were submitted for you. Reload to see them." + : "Your answers changed in another window. Reload to pick up the latest before you keep going.", + }); + } + + const { timing } = await evaluationAccess(tx, { + hackathonId: input.hackathonId, + judgeId: judge.judgeId, + entryId: input.entryId, + challenge, + challenges, + evaluation: evaluation ?? null, + graceMs: DEADLINE_GRACE_MS, + }); + if (!timing.canEdit) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: timing.reason, + }); + } + + const now = new Date(); + const values = { + ratings: input.ratings, + responses: input.responses, + appointmentId: timing.appointmentId, + // Editing a submitted evaluation is never timed. + deadlineAt: evaluation + ? null + : (timing.deadlineAt ?? draft?.deadlineAt ?? null), + revision: input.revision + 1, + updatedAt: now, + }; + if (draft) { + await tx + .update(judgingEvaluationDrafts) + .set(values) + .where(eq(judgingEvaluationDrafts.id, draft.id)); + } else { + await tx.insert(judgingEvaluationDrafts).values({ + hackathonId: input.hackathonId, + judgeId: judge.judgeId, + entryId: input.entryId, + challengeId: challenge.id, + ...values, + }); + } + + return { + revision: values.revision, + deadlineAt: values.deadlineAt, + savedAt: now, + }; + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Your answers were saved from another window at the same moment. Reload to pick up the latest.", + }); + } + throw error; + } + }), + + // Submits an evaluation, or a new revision of one. `expectedRevision` is + // the revision the editor shows (0 for a first submission). Every rating and + // every required response must be answered; only a draft whose time ran out + // is ever submitted incomplete, and not through here. + submit: judgeProcedure + .input( + z.object({ + entryId: uuid, + challengeId: uuid, + ratings: ratingsInput, + responses: responsesInput, + expectedRevision: z.number().int().min(0), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db; + const { judge } = ctx; + await reconcileDueDrafts(db, input.hackathonId); + + const challenges = await loadChallenges(db, input.hackathonId); + const challenge = evaluatedChallenge( + judge, + challenges, + input.challengeId, + await memberSeat(db, judge, challenges), + ); + const rubric = await loadRubric(db, input.hackathonId); + const check = checkAnswers(rubric, input, judge.kind, "strict"); + if (!check.ok) { + throw new TRPCError({ code: "BAD_REQUEST", message: check.message }); + } + if (!check.answers.isComplete) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Answer everything before you submit. Still to do: ${check.answers.missing.join(", ")}.`, + }); + } + + try { + return await db.transaction(async (tx) => { + await assertJudgingOpen(tx, input.hackathonId); + await assertGuestSeated(tx, judge); + await loadEntryInChallenge(tx, { + hackathonId: input.hackathonId, + entryId: input.entryId, + challenge, + }); + + // The draft first: a deadline submission holding it finishes + // before this reads the evaluation, and then sees the result. + const [draft] = await tx + .select({ id: judgingEvaluationDrafts.id }) + .from(judgingEvaluationDrafts) + .where( + taskOf( + judgingEvaluationDrafts, + judge.judgeId, + input.entryId, + challenge.id, + ), + ) + .for("update"); + const [existing] = await tx + .select({ + id: judgingEvaluations.id, + revision: judgingEvaluations.revision, + appointmentId: judgingEvaluations.appointmentId, + autoSubmittedAt: judgingEvaluations.autoSubmittedAt, + }) + .from(judgingEvaluations) + .where( + taskOf( + judgingEvaluations, + judge.judgeId, + input.entryId, + challenge.id, + ), + ) + .for("update") + .limit(1); + + // A project is graded once: a first submission needs it free. The + // judge's own draft and evaluation are locked above, ahead of this. + if (!existing) { + await claimGrading(tx, { + judgeId: judge.judgeId, + entryId: input.entryId, + challenge, + }); + // Starting a project means taking one their queue offers. + if (!draft) { + await assertQueueOffers(tx, { + hackathonId: input.hackathonId, + judgeId: judge.judgeId, + entryId: input.entryId, + challenge, + challenges, + }); + } + } + + if ((existing?.revision ?? 0) !== input.expectedRevision) { + throw new TRPCError({ + code: "CONFLICT", + message: + existing?.autoSubmittedAt && input.expectedRevision === 0 + ? "Time ran out, so your saved answers were submitted for you. Reload to see them." + : "This evaluation changed in another window. Reload to see the latest before you submit.", + }); + } + + const { timing } = await evaluationAccess(tx, { + hackathonId: input.hackathonId, + judgeId: judge.judgeId, + entryId: input.entryId, + challenge, + challenges, + evaluation: existing ?? null, + graceMs: DEADLINE_GRACE_MS, + }); + if (!timing.canEdit) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: timing.reason, + }); + } + + const written = await writeEvaluation(tx, { + hackathonId: input.hackathonId, + judgeId: judge.judgeId, + entryId: input.entryId, + challengeId: challenge.id, + appointmentId: existing + ? existing.appointmentId + : timing.appointmentId, + answers: check.answers, + existing: existing ?? null, + at: new Date(), + auto: false, + }); + await tx + .delete(judgingEvaluationDrafts) + .where( + taskOf( + judgingEvaluationDrafts, + judge.judgeId, + input.entryId, + challenge.id, + ), + ); + + return { + ...written, + isComplete: true, + score: evaluationScore(check.answers.ratings.map((r) => r.value)), + }; + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: + "This evaluation was just submitted from another window. Reload to see it.", + }); + } + throw error; + } + }), + + // Everything this judge has submitted in the edition, newest first. + myEvaluations: judgeProcedure.query(async ({ ctx, input }) => { + const db = ctx.db; + await reconcileDueDrafts(db, input.hackathonId); + + const rows = await db + .select({ + id: judgingEvaluations.id, + entryId: judgingEvaluations.entryId, + challengeId: judgingEvaluations.challengeId, + challengeLabel: judgingChallenges.label, + title: judgingEntries.title, + deletedAt: judgingEntries.deletedAt, + revision: judgingEvaluations.revision, + isComplete: judgingEvaluations.isComplete, + submittedAt: judgingEvaluations.submittedAt, + autoSubmittedAt: judgingEvaluations.autoSubmittedAt, + updatedAt: judgingEvaluations.updatedAt, + }) + .from(judgingEvaluations) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEvaluations.entryId), + ) + .innerJoin( + judgingChallenges, + eq(judgingChallenges.id, judgingEvaluations.challengeId), + ) + .where( + and( + eq(judgingEvaluations.judgeId, ctx.judge.judgeId), + eq(judgingEvaluations.hackathonId, input.hackathonId), + ), + ) + .orderBy(desc(judgingEvaluations.updatedAt)); + + const answers = await answersOf( + db, + rows.map((row) => row.id), + ); + // The score shows once the evaluation counts, as placings count it. + const ratingItems = (await loadRubric(db, input.hackathonId)).filter( + (item) => item.kind === "rating", + ).length; + return rows.map(({ deletedAt, ...row }) => { + const mine = answers.get(row.id) ?? { ratings: {}, responses: {} }; + const ratings = Object.values(mine.ratings); + return { + ...row, + withdrawn: deletedAt !== null, + ratings: mine.ratings, + responses: mine.responses, + score: hasEveryRating(ratings, ratingItems) + ? evaluationScore(ratings) + : null, + }; + }); + }), + + deliberation: deliberationRouter, +}); diff --git a/packages/api/src/routers/judging/hacker.ts b/packages/api/src/routers/judging/hacker.ts new file mode 100644 index 00000000..458dcba2 --- /dev/null +++ b/packages/api/src/routers/judging/hacker.ts @@ -0,0 +1,1129 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { and, asc, eq, gt, ilike, inArray, isNull, lt, sql } from "drizzle-orm"; +import { + judgingAppointments, + judgingBuildings, + judgingChallenges, + judgingClaimLinks, + judgingEntries, + judgingEntryChallenges, + judgingEntryMembers, + judgingEvaluationResponses, + judgingEvaluations, + judgingJudges, + judgingPresence, + judgingRooms, + judgingRubricItems, + users, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { createTRPCRouter, protectedProcedure } from "../../trpc"; +import { isAdmin, notHackathonBanned } from "../../middleware/procedures"; +import { recordAdminAction } from "../../middleware/audit"; +import { rateLimit } from "../../middleware/security"; +import { + emailConcurrency, + forEachWithConcurrency, +} from "../../services/fanout"; +import { assertHackathonExists } from "../../services/judging/config"; +import type { JudgingDb } from "../../services/judging/config"; +import { reconcileDueDrafts } from "../../services/judging/evaluation-store"; +import { + MAX_TEAM_SIZE, + claimLinkPath, + claimLinkUsable, + claimPlace, + decideClaim, + decideInvite, + heldPlace, + isCheckedIn, + issueClaimToken, + loadRoster, + lockJudgingConfig, + lockPlaceForLink, + markClaimsStarted, + portalOrigin, + readJudgingConfig, + refuse, + requireCheckedIn, + rosterSize, + sameEmail, + teamSize, +} from "../../services/judging/claims"; +import { + buildItinerary, + groupFeedback, + visibleChallenges, +} from "../../services/judging/itinerary"; +import type { + Itinerary, + VisibleChallenge, +} from "../../services/judging/itinerary"; +import { holdsEveryRating } from "../../services/judging/scoring-sql"; + +const hackathonInput = z.object({ + hackathonId: z.string().uuid("Invalid hackathon ID"), +}); + +// 32 random bytes, base64url: 43 characters. +const tokenInput = z.string().trim().min(20).max(128); + +/** One organiser click sends this many claim emails; the client pages on. */ +const CLAIM_EMAIL_BATCH = 5; + +const LINK_UNUSABLE = + "This claim link has been used or has expired. Ask an organiser at the help desk for a new one."; + +const LINKS_CLOSED = + "The event has ended, so claim links no longer work. Move the event's end date first if judging is still running."; + +const escapeLike = (value: string) => value.replace(/[\\%_]/g, "\\$&"); + +async function loadChallenges(db: JudgingDb, hackathonId: string) { + return db + .select({ + id: judgingChallenges.id, + label: judgingChallenges.label, + parentId: judgingChallenges.parentId, + mode: judgingChallenges.mode, + displayOrder: judgingChallenges.displayOrder, + }) + .from(judgingChallenges) + .where(eq(judgingChallenges.hackathonId, hackathonId)); +} + +async function liveEntry(db: JudgingDb, hackathonId: string, entryId: string) { + const [entry] = await db + .select({ id: judgingEntries.id, title: judgingEntries.title }) + .from(judgingEntries) + .where( + and( + eq(judgingEntries.id, entryId), + eq(judgingEntries.hackathonId, hackathonId), + isNull(judgingEntries.deletedAt), + ), + ) + .limit(1); + if (!entry) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That project is no longer in judging.", + }); + } + return entry; +} + +// A hacker's side of judging: claims, itinerary, feedback. +export const judgingHackerRouter = createTRPCRouter({ + // Where the caller stands: checked in or not, the project they hold a place + // on (with its team, unless organisers switched to emergency mode), and, + // until they hold one, the places listed under their own email that they + // can claim in one click. + myClaim: protectedProcedure + .input(hackathonInput) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const userId = ctx.userId as string; + + const [checkedIn, config] = await Promise.all([ + isCheckedIn(db, input.hackathonId, userId), + readJudgingConfig(db, input.hackathonId), + ]); + const state = { + checkedIn, + published: config.itineraryPublished, + emergency: config.emergencyMode, + claimsStarted: !!config.claimsStartedAt, + }; + if (!checkedIn) return { ...state, claim: null, offers: [] }; + + const held = await heldPlace(db, input.hackathonId, userId); + if (held && !held.deletedAt) { + const roster = await loadRoster(db, held.entryId); + return { + ...state, + claim: { + entryId: held.entryId, + title: held.title, + memberId: held.memberId, + // Emergency mode hides the team: the list may be what is broken. + members: config.emergencyMode + ? null + : roster.map((member) => ({ + id: member.id, + name: member.name, + claimed: !!member.userId, + isYou: member.userId === userId, + addedByTeammate: !!member.invitedById, + })), + canInvite: + !config.emergencyMode && + teamSize(held.participantCount, roster.length) < MAX_TEAM_SIZE, + }, + offers: [], + }; + } + + const [user] = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + if (!user) return { ...state, claim: null, offers: [] }; + + // Every sign-in path verifies the address, so a match is proof enough. + const listed = await db + .select({ + memberId: judgingEntryMembers.id, + name: judgingEntryMembers.name, + entryId: judgingEntries.id, + title: judgingEntries.title, + participantCount: judgingEntries.participantCount, + }) + .from(judgingEntryMembers) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEntryMembers.entryId), + ) + .where( + and( + eq(judgingEntryMembers.hackathonId, input.hackathonId), + isNull(judgingEntryMembers.userId), + isNull(judgingEntries.deletedAt), + sql`lower(${judgingEntryMembers.email}) = lower(${user.email})`, + ), + ) + .orderBy(asc(judgingEntries.title)); + + const offers = await Promise.all( + listed.map(async (place) => ({ + memberId: place.memberId, + name: place.name, + entryId: place.entryId, + title: place.title, + teamTooLarge: + teamSize( + place.participantCount, + await rosterSize(db, place.entryId), + ) > MAX_TEAM_SIZE, + })), + ); + + return { ...state, claim: null, offers }; + }), + + // The one-click path: a place listed under the caller's own email. + claimByEmail: notHackathonBanned + .input(hackathonInput.extend({ memberId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const userId = ctx.userId as string; + await assertHackathonExists(db, input.hackathonId); + + return db.transaction(async (tx) => { + await lockJudgingConfig(tx, input.hackathonId); + await requireCheckedIn(tx, input.hackathonId, userId); + + const [user] = await tx + .select({ email: users.email }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + const [listed] = await tx + .select({ email: judgingEntryMembers.email }) + .from(judgingEntryMembers) + .where( + and( + eq(judgingEntryMembers.id, input.memberId), + eq(judgingEntryMembers.hackathonId, input.hackathonId), + ), + ) + .limit(1); + // A missing place falls through to claimPlace's "no longer in judging". + if (listed && (!user || !sameEmail(listed.email, user.email))) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "That place is listed under a different email. Open the claim link an organiser sent to that address instead.", + }); + } + + return claimPlace(tx, { + hackathonId: input.hackathonId, + memberId: input.memberId, + userId, + now: new Date(), + }); + }); + }), + + // What a claim link would claim, shown before the hacker confirms. Names + // only: a forwarded link must not hand out the team's email addresses. + // `refusal` is the sentence the confirm would fail with, if it would. + previewClaim: protectedProcedure + .input(hackathonInput.extend({ token: tokenInput })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const userId = ctx.userId as string; + const now = new Date(); + const hackathon = await assertHackathonExists(db, input.hackathonId); + await requireCheckedIn(db, input.hackathonId, userId); + + const [link] = await db + .select({ + token: judgingClaimLinks.token, + consumedAt: judgingClaimLinks.consumedAt, + expiresAt: judgingClaimLinks.expiresAt, + memberId: judgingEntryMembers.id, + name: judgingEntryMembers.name, + userId: judgingEntryMembers.userId, + entryId: judgingEntries.id, + title: judgingEntries.title, + participantCount: judgingEntries.participantCount, + deletedAt: judgingEntries.deletedAt, + }) + .from(judgingClaimLinks) + .innerJoin( + judgingEntryMembers, + eq(judgingEntryMembers.id, judgingClaimLinks.memberId), + ) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEntryMembers.entryId), + ) + .where( + and( + eq(judgingClaimLinks.token, input.token), + eq(judgingClaimLinks.hackathonId, input.hackathonId), + ), + ) + .limit(1); + + if (!link || !claimLinkUsable(link, hackathon.endDate, now)) { + throw new TRPCError({ code: "BAD_REQUEST", message: LINK_UNUSABLE }); + } + + const [roster, held] = await Promise.all([ + loadRoster(db, link.entryId), + heldPlace(db, input.hackathonId, userId), + ]); + const decision = decideClaim( + { + userId: link.userId, + entryDeleted: !!link.deletedAt, + teamSize: teamSize(link.participantCount, roster.length), + }, + userId, + held && held.memberId !== link.memberId && !held.deletedAt + ? held.title + : null, + ); + + return { + entryId: link.entryId, + title: link.title, + hackathonName: hackathon.name, + place: { memberId: link.memberId, name: link.name }, + members: roster.map((member) => ({ + id: member.id, + name: member.name, + claimed: !!member.userId, + })), + refusal: decision.ok ? null : decision.message, + }; + }), + + claimByToken: notHackathonBanned + .input(hackathonInput.extend({ token: tokenInput })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const userId = ctx.userId as string; + const now = new Date(); + const hackathon = await assertHackathonExists(db, input.hackathonId); + + return db.transaction(async (tx) => { + await lockJudgingConfig(tx, input.hackathonId); + await requireCheckedIn(tx, input.hackathonId, userId); + + const [link] = await tx + .select() + .from(judgingClaimLinks) + .where( + and( + eq(judgingClaimLinks.token, input.token), + eq(judgingClaimLinks.hackathonId, input.hackathonId), + ), + ) + .for("update"); + if (!link || !claimLinkUsable(link, hackathon.endDate, now)) { + throw new TRPCError({ code: "BAD_REQUEST", message: LINK_UNUSABLE }); + } + + return claimPlace(tx, { + hackathonId: input.hackathonId, + memberId: link.memberId, + userId, + now, + }); + }); + }), + + // A claimed hacker adds a checked-in teammate by the email they sign in + // with. The teammate gets a place on the team list and a claim link; the + // place is theirs once they open it (or claim it from their Judging tab, + // since the email matches). + invite: notHackathonBanned + .input( + hackathonInput.extend({ + email: z + .string() + .trim() + .toLowerCase() + .email("Enter a valid email.") + .max(255), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const userId = ctx.userId as string; + + // Per user: a campus shares one address, and the lookup below answers + // "is this email a checked-in hacker" however carefully it is worded. + const limit = rateLimit(`judging-invite:${userId}`, 10, 10 / 60); + if (!limit.allowed) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: + "Too many invitations in a row. Wait a minute, then try again.", + }); + } + + const hackathon = await assertHackathonExists(db, input.hackathonId); + const now = new Date(); + if (hackathon.endDate <= now) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "The event has ended, so teams can no longer change.", + }); + } + + const reserved = await db.transaction(async (tx) => { + const config = await lockJudgingConfig(tx, input.hackathonId); + if (config.emergencyMode) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "Team changes are paused while organisers run judging from the posted list. Ask at the help desk.", + }); + } + await requireCheckedIn(tx, input.hackathonId, userId); + + const held = await heldPlace(tx, input.hackathonId, userId); + if (!held || held.deletedAt) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Claim your project before adding teammates.", + }); + } + + const roster = await loadRoster(tx, held.entryId); + const accounts = await tx + .select({ id: users.id, name: users.name, email: users.email }) + .from(users) + .where(sql`lower(${users.email}) = ${input.email}`) + .limit(2); + // Two accounts on one address is ambiguous; neither gets the place. + const account = accounts.length === 1 ? accounts[0]! : null; + + let invitee: Parameters[0]["invitee"] = null; + if (account) { + const checkedIn = await isCheckedIn( + tx, + input.hackathonId, + account.id, + ); + const theirs = await heldPlace(tx, input.hackathonId, account.id); + invitee = { + checkedIn, + heldProject: + !theirs || theirs.deletedAt + ? null + : theirs.entryId === held.entryId + ? "this" + : "other", + }; + } + + const listed = roster.find((member) => + sameEmail(member.email, input.email), + ); + const decision = decideInvite({ + teamSize: teamSize(held.participantCount, roster.length), + invitee, + listed: listed ? { claimed: !!listed.userId } : null, + }); + if (!("action" in decision)) refuse(decision); + + let memberId: string; + if (decision.action === "resend") { + memberId = listed!.id; + } else { + const [added] = await tx + .insert(judgingEntryMembers) + .values({ + entryId: held.entryId, + hackathonId: input.hackathonId, + name: account!.name?.trim() || account!.email, + email: account!.email, + displayOrder: + roster.reduce( + (top, member) => Math.max(top, member.displayOrder), + -1, + ) + 1, + invitedById: userId, + }) + .returning({ id: judgingEntryMembers.id }); + memberId = added!.id; + await tx + .update(judgingEntries) + .set({ + participantCount: + teamSize(held.participantCount, roster.length) + 1, + updatedAt: now, + }) + .where(eq(judgingEntries.id, held.entryId)); + } + + await lockPlaceForLink(tx, input.hackathonId, memberId); + const token = await issueClaimToken(tx, { + hackathonId: input.hackathonId, + memberId, + expiresAt: hackathon.endDate, + }); + return { + memberId, + token, + email: account!.email, + title: held.title, + added: decision.action === "add", + }; + }); + + // Sent after the commit: the place is held whatever the mail server + // does, and adding the same address again resends. + try { + const { sendJudgingClaimEmail } = await import("@query/auth/email"); + await sendJudgingClaimEmail({ + email: reserved.email, + hackathonName: hackathon.name, + projectTitle: reserved.title, + claimUrl: + portalOrigin() + claimLinkPath(input.hackathonId, reserved.token), + invitedBy: ctx.session?.user?.name ?? "A teammate", + }); + await db + .update(judgingClaimLinks) + .set({ sentAt: new Date() }) + .where(eq(judgingClaimLinks.memberId, reserved.memberId)); + return { + added: reserved.added, + emailed: true, + message: reserved.added + ? "Added. They have an email with a link to claim their place, or can claim it from their Judging tab." + : "They were already on the team list. The claim email went out again.", + }; + } catch (error) { + // Deliberate server-side logging: the only record of the failed send. + // eslint-disable-next-line no-console + console.error("[Judging] Teammate claim email failed:", error); + return { + added: reserved.added, + emailed: false, + message: + "Their place is held, but the email did not send. They can claim it from their Judging tab, or add the same address again to resend.", + }; + } + }), + + // The project's challenges always; rooms, times and statuses once + // organisers publish the itinerary. In emergency mode (with the itinerary + // published) any checked-in hacker can look up any project, found through + // searchProjects, because claims may be what broke. + itinerary: protectedProcedure + .input(hackathonInput.extend({ entryId: z.string().uuid().optional() })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const userId = ctx.userId as string; + const now = new Date(); + await requireCheckedIn(db, input.hackathonId, userId); + + const [config, held] = await Promise.all([ + readJudgingConfig(db, input.hackathonId), + heldPlace(db, input.hackathonId, userId), + ]); + const ownEntryId = held && !held.deletedAt ? held.entryId : null; + const lookupOpen = config.emergencyMode && config.itineraryPublished; + if (input.entryId && input.entryId !== ownEntryId && !lookupOpen) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only see your own project's judging schedule.", + }); + } + + const state = { + published: config.itineraryPublished, + emergency: config.emergencyMode, + serverNow: now, + }; + const empty: Itinerary & { challenges: VisibleChallenge[] } = { + challenges: [], + appointments: [], + unscheduled: [], + missed: false, + nextEndsAt: null, + }; + + const entryId = input.entryId ?? ownEntryId; + if (!entryId) return { ...state, entry: null, ...empty }; + + const entry = await liveEntry(db, input.hackathonId, entryId); + const [challenges, memberships] = await Promise.all([ + loadChallenges(db, input.hackathonId), + db + .select({ challengeId: judgingEntryChallenges.challengeId }) + .from(judgingEntryChallenges) + .where(eq(judgingEntryChallenges.entryId, entry.id)), + ]); + const entryChallengeIds = memberships.map((row) => row.challengeId); + const visible = visibleChallenges(challenges, entryChallengeIds); + + if (!config.itineraryPublished) { + return { ...state, entry, ...empty, challenges: visible }; + } + + // Drafts past their deadline count as submitted; without this a slot a + // judge timed out on would read as missed until some judge's desk loads. + await reconcileDueDrafts(db, input.hackathonId, now); + + const [rooms, appointments, evaluations] = await Promise.all([ + db + .select({ + id: judgingRooms.id, + challengeId: judgingRooms.challengeId, + name: judgingRooms.name, + kind: judgingRooms.kind, + building: judgingBuildings.name, + displayOrder: judgingRooms.displayOrder, + archivedAt: judgingRooms.archivedAt, + }) + .from(judgingRooms) + .leftJoin( + judgingBuildings, + eq(judgingBuildings.id, judgingRooms.buildingId), + ) + .where(eq(judgingRooms.hackathonId, input.hackathonId)), + db + .select({ + id: judgingAppointments.id, + challengeId: judgingAppointments.challengeId, + roomId: judgingAppointments.roomId, + startsAt: judgingAppointments.startsAt, + endsAt: judgingAppointments.endsAt, + }) + .from(judgingAppointments) + .where( + and( + eq(judgingAppointments.hackathonId, input.hackathonId), + eq(judgingAppointments.entryId, entry.id), + ), + ), + db + .select({ + judgeId: judgingEvaluations.judgeId, + challengeId: judgingEvaluations.challengeId, + appointmentId: judgingEvaluations.appointmentId, + ratingsComplete: holdsEveryRating(input.hackathonId), + }) + .from(judgingEvaluations) + .where( + and( + eq(judgingEvaluations.hackathonId, input.hackathonId), + eq(judgingEvaluations.entryId, entry.id), + ), + ), + ]); + + // Only the rooms this project presents in, and only rows that began + // before its last slot ended. + const roomIds = [...new Set(appointments.map((a) => a.roomId))]; + const lastEnd = appointments.reduce( + (latest, a) => (a.endsAt > latest ? a.endsAt : latest), + new Date(0), + ); + const presence = roomIds.length + ? await db + .select({ + judgeId: judgingPresence.judgeId, + roomId: judgingPresence.roomId, + joinedAt: judgingPresence.joinedAt, + lastSeenAt: judgingPresence.lastSeenAt, + leftAt: judgingPresence.leftAt, + }) + .from(judgingPresence) + .where( + and( + inArray(judgingPresence.roomId, roomIds), + lt(judgingPresence.joinedAt, lastEnd), + ), + ) + : []; + + const itinerary = buildItinerary({ + challenges, + entryChallengeIds, + rooms: rooms.map(({ archivedAt, ...room }) => ({ + ...room, + archived: !!archivedAt, + })), + appointments, + evaluations, + presence, + now, + }); + + return { ...state, entry, challenges: visible, ...itinerary }; + }), + + // Written feedback on the caller's project: public responses that exist, from + // member judges' evaluations that hold every rating (the ones placings + // count). No judge names and no scores. Hidden in emergency mode. + feedback: protectedProcedure + .input(hackathonInput) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const userId = ctx.userId as string; + await requireCheckedIn(db, input.hackathonId, userId); + + const config = await readJudgingConfig(db, input.hackathonId); + if (config.emergencyMode) return { available: false, items: [] }; + + const held = await heldPlace(db, input.hackathonId, userId); + if (!held || held.deletedAt) return { available: true, items: [] }; + + const evaluations = await db + .select({ + id: judgingEvaluations.id, + challengeId: judgingEvaluations.challengeId, + submittedAt: judgingEvaluations.submittedAt, + }) + .from(judgingEvaluations) + .innerJoin( + judgingJudges, + eq(judgingJudges.id, judgingEvaluations.judgeId), + ) + .where( + and( + eq(judgingEvaluations.hackathonId, input.hackathonId), + eq(judgingEvaluations.entryId, held.entryId), + holdsEveryRating(input.hackathonId), + eq(judgingJudges.kind, "member"), + ), + ); + if (evaluations.length === 0) return { available: true, items: [] }; + + const [responses, challenges] = await Promise.all([ + db + .select({ + evaluationId: judgingEvaluationResponses.evaluationId, + label: judgingRubricItems.label, + displayOrder: judgingRubricItems.displayOrder, + value: judgingEvaluationResponses.value, + }) + .from(judgingEvaluationResponses) + .innerJoin( + judgingRubricItems, + eq(judgingRubricItems.id, judgingEvaluationResponses.itemId), + ) + .where( + and( + inArray( + judgingEvaluationResponses.evaluationId, + evaluations.map((evaluation) => evaluation.id), + ), + eq(judgingEvaluationResponses.isPublic, true), + ), + ), + loadChallenges(db, input.hackathonId), + ]); + + return { + available: true, + items: groupFeedback(evaluations, responses, challenges), + }; + }), + + // Emergency-mode lookup by title, so a hacker whose claim is missing can + // still find where their project presents. + searchProjects: protectedProcedure + .input(hackathonInput.extend({ query: z.string().trim().min(2).max(100) })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await requireCheckedIn(db, input.hackathonId, ctx.userId as string); + + const config = await readJudgingConfig(db, input.hackathonId); + if (!config.emergencyMode || !config.itineraryPublished) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Project search opens only if organisers switch judging to emergency mode.", + }); + } + + return db + .select({ id: judgingEntries.id, title: judgingEntries.title }) + .from(judgingEntries) + .where( + and( + eq(judgingEntries.hackathonId, input.hackathonId), + isNull(judgingEntries.deletedAt), + ilike(judgingEntries.title, `%${escapeLike(input.query)}%`), + ), + ) + .orderBy(asc(judgingEntries.title)) + .limit(25); + }), + + // Officer view of claims: every project's team list with who has claimed, + // their Devpost email, and whether a claim email went out. + claimOverview: isAdmin + .input( + hackathonInput.extend({ search: z.string().trim().max(200).optional() }), + ) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const hackathon = await assertHackathonExists(db, input.hackathonId); + const config = await readJudgingConfig(db, input.hackathonId); + + const rows = await db + .select({ + memberId: judgingEntryMembers.id, + name: judgingEntryMembers.name, + email: judgingEntryMembers.email, + userId: judgingEntryMembers.userId, + claimedAt: judgingEntryMembers.claimedAt, + invitedById: judgingEntryMembers.invitedById, + entryId: judgingEntries.id, + title: judgingEntries.title, + source: judgingEntries.source, + participantCount: judgingEntries.participantCount, + linkSentAt: judgingClaimLinks.sentAt, + linkUsedAt: judgingClaimLinks.consumedAt, + claimedByName: users.name, + claimedByEmail: users.email, + }) + .from(judgingEntryMembers) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEntryMembers.entryId), + ) + .leftJoin( + judgingClaimLinks, + eq(judgingClaimLinks.memberId, judgingEntryMembers.id), + ) + .leftJoin(users, eq(users.id, judgingEntryMembers.userId)) + .where( + and( + eq(judgingEntryMembers.hackathonId, input.hackathonId), + isNull(judgingEntries.deletedAt), + ), + ) + .orderBy( + asc(judgingEntries.title), + asc(judgingEntries.id), + asc(judgingEntryMembers.displayOrder), + asc(judgingEntryMembers.createdAt), + ); + + type Entry = { + entryId: string; + title: string; + source: "devpost" | "portal"; + teamSize: number; + teamTooLarge: boolean; + members: { + memberId: string; + name: string; + email: string; + claimed: boolean; + claimedAt: Date | null; + claimedBy: { name: string | null; email: string } | null; + addedByTeammate: boolean; + emailedAt: Date | null; + linkUsedAt: Date | null; + }[]; + }; + const entries = new Map(); + for (const row of rows) { + const entry = entries.get(row.entryId) ?? { + entryId: row.entryId, + title: row.title, + source: row.source, + participantCount: row.participantCount, + teamSize: 0, + teamTooLarge: false, + members: [], + }; + entry.members.push({ + memberId: row.memberId, + name: row.name, + email: row.email, + claimed: !!row.userId, + claimedAt: row.claimedAt, + claimedBy: + row.userId && row.claimedByEmail + ? { name: row.claimedByName, email: row.claimedByEmail } + : null, + addedByTeammate: !!row.invitedById, + emailedAt: row.linkSentAt, + linkUsedAt: row.linkUsedAt, + }); + entries.set(row.entryId, entry); + } + + const all: Entry[] = [...entries.values()].map( + ({ participantCount, ...entry }) => { + const size = teamSize(participantCount, entry.members.length); + return { + ...entry, + teamSize: size, + teamTooLarge: size > MAX_TEAM_SIZE, + }; + }, + ); + + const members = all.flatMap((entry) => entry.members); + const claimed = members.filter((member) => member.claimed).length; + const needle = input.search?.toLowerCase(); + + return { + claimsStartedAt: config.claimsStartedAt, + linksExpireAt: hackathon.endDate, + summary: { + projects: all.length, + members: members.length, + claimed, + unclaimed: members.length - claimed, + emailed: members.filter((member) => member.emailedAt).length, + // What sendClaimEmails can still send; a team over four is skipped + // until it is cut, and counted under teamsTooLarge instead. + unsent: all + .filter((entry) => !entry.teamTooLarge) + .flatMap((entry) => entry.members) + .filter((member) => !member.claimed && !member.emailedAt).length, + teamsTooLarge: all.filter((entry) => entry.teamTooLarge).length, + }, + entries: needle + ? all.filter( + (entry) => + entry.title.toLowerCase().includes(needle) || + entry.members.some( + (member) => + member.name.toLowerCase().includes(needle) || + member.email.toLowerCase().includes(needle), + ), + ) + : all, + }; + }), + + // The member's claim link, issued if they have none, for an organiser to + // copy into a message. Audited: it is a credential for someone's place. + claimLink: isAdmin + .input(hackathonInput.extend({ memberId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const hackathon = await assertHackathonExists(db, input.hackathonId); + if (hackathon.endDate <= new Date()) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: LINKS_CLOSED, + }); + } + + const { token, entryId } = await db.transaction(async (tx) => { + await lockJudgingConfig(tx, input.hackathonId); + const place = await lockPlaceForLink( + tx, + input.hackathonId, + input.memberId, + ); + return { + entryId: place.entryId, + token: await issueClaimToken(tx, { + hackathonId: input.hackathonId, + memberId: place.id, + expiresAt: hackathon.endDate, + }), + }; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.hacker.claimLink", + resourceId: input.memberId, + metadata: { hackathonId: input.hackathonId, entryId }, + }); + + return { + url: portalOrigin() + claimLinkPath(input.hackathonId, token), + expiresAt: hackathon.endDate, + }; + }), + + // Emails claim links to unclaimed team members who have not had one, five + // per call; pass back `nextCursor` until it is null. With `memberId`, resends + // to that one person. The first send marks claims as started, which stops a + // Devpost import from replacing the projects those emails point at. + sendClaimEmails: isAdmin + .input( + hackathonInput.extend({ + cursor: z.string().uuid().optional(), + memberId: z.string().uuid().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const hackathon = await assertHackathonExists(db, input.hackathonId); + const now = new Date(); + if (hackathon.endDate <= now) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: LINKS_CLOSED, + }); + } + + const candidates = input.memberId + ? [{ id: input.memberId }] + : await db + .select({ id: judgingEntryMembers.id }) + .from(judgingEntryMembers) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEntryMembers.entryId), + ) + .leftJoin( + judgingClaimLinks, + eq(judgingClaimLinks.memberId, judgingEntryMembers.id), + ) + .where( + and( + eq(judgingEntryMembers.hackathonId, input.hackathonId), + isNull(judgingEntries.deletedAt), + isNull(judgingEntryMembers.userId), + isNull(judgingClaimLinks.sentAt), + input.cursor + ? gt(judgingEntryMembers.id, input.cursor) + : undefined, + ), + ) + .orderBy(asc(judgingEntryMembers.id)) + .limit(CLAIM_EMAIL_BATCH + 1); + + const batch = candidates.slice(0, CLAIM_EMAIL_BATCH); + const hasMore = candidates.length > CLAIM_EMAIL_BATCH; + + const ready: { + memberId: string; + email: string; + title: string; + token: string; + }[] = []; + const skipped: { memberId: string; reason: string }[] = []; + + if (batch.length > 0) { + await db.transaction(async (tx) => { + await lockJudgingConfig(tx, input.hackathonId); + for (const { id } of batch) { + // A batch carries on past a team that is too big or a place + // claimed since the list was read; a single resend says why. + // These refusals are checks in code, so the transaction is intact. + const place = await lockPlaceForLink( + tx, + input.hackathonId, + id, + ).catch((error: unknown) => { + if (error instanceof TRPCError && !input.memberId) { + skipped.push({ memberId: id, reason: error.message }); + return null; + } + throw error; + }); + if (!place) continue; + ready.push({ + memberId: place.id, + email: place.email, + title: place.title, + token: await issueClaimToken(tx, { + hackathonId: input.hackathonId, + memberId: place.id, + expiresAt: hackathon.endDate, + }), + }); + } + if (ready.length > 0) + await markClaimsStarted(tx, input.hackathonId, now); + }); + } + + const { sendJudgingClaimEmail } = await import("@query/auth/email"); + let sent = 0; + const failed: string[] = []; + await forEachWithConcurrency(ready, emailConcurrency(), async (item) => { + try { + await sendJudgingClaimEmail({ + email: item.email, + hackathonName: hackathon.name, + projectTitle: item.title, + claimUrl: + portalOrigin() + claimLinkPath(input.hackathonId, item.token), + }); + // Stamped per message, so a retry after a failure mails only the rest. + await db + .update(judgingClaimLinks) + .set({ sentAt: new Date() }) + .where(eq(judgingClaimLinks.memberId, item.memberId)); + sent++; + } catch (error) { + failed.push(item.email); + // Deliberate operational logging: the only record of which address + // the provider rejected. + // eslint-disable-next-line no-console + console.error( + `[Judging] Claim email to ${item.email} failed:`, + error, + ); + } + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.hacker.sendClaimEmails", + resourceId: input.hackathonId, + severity: "warn", + metadata: { + sent, + failed: failed.length, + skipped: skipped.length, + memberId: input.memberId ?? null, + }, + }); + + return { + sent, + failed, + skipped, + nextCursor: hasMore ? batch[batch.length - 1]!.id : null, + }; + }), +}); diff --git a/packages/api/src/routers/judging/index.ts b/packages/api/src/routers/judging/index.ts new file mode 100644 index 00000000..f2aa9902 --- /dev/null +++ b/packages/api/src/routers/judging/index.ts @@ -0,0 +1,16 @@ +import { createTRPCRouter } from "../../trpc"; +import { judgingAdminRouter } from "./admin"; +import { judgingScheduleRouter } from "./schedule"; +import { judgingDeskRouter } from "./desk"; +import { judgingHackerRouter } from "./hacker"; +import { judgingResultsRouter } from "./results"; + +// Room-based project judging: projects imported from Devpost or promoted from +// portal submissions, presented to judges seated in rooms on a schedule. +export const judgingRouter = createTRPCRouter({ + admin: judgingAdminRouter, + schedule: judgingScheduleRouter, + desk: judgingDeskRouter, + hacker: judgingHackerRouter, + results: judgingResultsRouter, +}); diff --git a/packages/api/src/routers/judging/results.ts b/packages/api/src/routers/judging/results.ts new file mode 100644 index 00000000..78b9bab1 --- /dev/null +++ b/packages/api/src/routers/judging/results.ts @@ -0,0 +1,961 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { + and, + asc, + count, + desc, + eq, + gt, + inArray, + isNotNull, + isNull, + max, + ne, + or, +} from "drizzle-orm"; +import { + judgingAppointments, + judgingBuildings, + judgingChallenges, + judgingEntries, + judgingEntryChallenges, + judgingEvaluationDrafts, + judgingEvaluationRatings, + judgingEvaluationResponses, + judgingEvaluationRevisions, + judgingEvaluations, + judgingJudges, + judgingPlacements, + judgingRooms, + judgingRubricItems, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { createTRPCRouter } from "../../trpc"; +import { isAdmin } from "../../middleware/procedures"; +import { recordAdminAction } from "../../middleware/audit"; +import { CacheKeys } from "../../middleware/cache"; +import type { CacheService } from "../../middleware/cache"; +import { assertHackathonExists } from "../../services/judging/config"; +import { reconcileDueDrafts } from "../../services/judging/evaluation-store"; +import type { JudgingDb } from "../../services/judging/config"; +import { lockJudgingConfig } from "../../services/judging/claims"; +import { calibrate } from "../../services/judging/calibration"; +import { checkInFlagsForEntries } from "../../services/check-in-flags"; +import { + notifyResultsPublished, + resultsPublishedBefore, +} from "../../services/judging/notify"; +import { + evaluationScore, + hasEveryRating, + meanOrNull, +} from "../../services/judging/scoring"; +import { + OVERALL_SCOPE, + computePlacements, + formatScore, +} from "../../services/judging/placements"; + +const hackathonInput = z.object({ + hackathonId: z.string().uuid("Invalid hackathon ID"), +}); + +const toScore = (value: string | null) => + value === null ? null : Number(value); + +/** Rating values grouped by evaluation. */ +function groupRatings( + rows: readonly { evaluationId: string; value: number }[], +) { + const byEvaluation = new Map(); + for (const row of rows) { + const list = byEvaluation.get(row.evaluationId) ?? []; + list.push(row.value); + byEvaluation.set(row.evaluationId, list); + } + return byEvaluation; +} + +/** Rating values per evaluation, for the evaluations given. */ +async function ratingsOf(db: JudgingDb, evaluationIds: string[]) { + if (evaluationIds.length === 0) return new Map(); + return groupRatings( + await db + .select({ + evaluationId: judgingEvaluationRatings.evaluationId, + value: judgingEvaluationRatings.value, + }) + .from(judgingEvaluationRatings) + .where(inArray(judgingEvaluationRatings.evaluationId, evaluationIds)), + ); +} + +/** How many rating items the rubric has: an evaluation counts once it rates them all. */ +async function ratingItemCount(db: JudgingDb, hackathonId: string) { + const [row] = await db + .select({ n: count() }) + .from(judgingRubricItems) + .where( + and( + eq(judgingRubricItems.hackathonId, hackathonId), + eq(judgingRubricItems.kind, "rating"), + ), + ); + return row?.n ?? 0; +} + +/** + * Every evaluation in the edition with its ratings, and whether it rated every + * rating item: what compute ranks and the judge calibration summary reads. + * Scoring and calibration count the ones that did, whether or not a written + * response was left empty, which is how a draft submitted at the hard end reads. + * The ratings are joined rather than listed by id, since an edition's + * evaluations run to thousands. + */ +async function evaluationsForScoring(db: JudgingDb, hackathonId: string) { + const evaluations = await db + .select({ + id: judgingEvaluations.id, + entryId: judgingEvaluations.entryId, + challengeId: judgingEvaluations.challengeId, + judgeId: judgingEvaluations.judgeId, + submittedAt: judgingEvaluations.submittedAt, + }) + .from(judgingEvaluations) + .where(eq(judgingEvaluations.hackathonId, hackathonId)); + const ratingRows = await db + .select({ + evaluationId: judgingEvaluationRatings.evaluationId, + value: judgingEvaluationRatings.value, + }) + .from(judgingEvaluationRatings) + .innerJoin( + judgingEvaluations, + eq(judgingEvaluations.id, judgingEvaluationRatings.evaluationId), + ) + .where(eq(judgingEvaluations.hackathonId, hackathonId)); + const ratingItems = await ratingItemCount(db, hackathonId); + const ratings = groupRatings(ratingRows); + return evaluations.map((evaluation) => { + const values = ratings.get(evaluation.id) ?? []; + return { + ...evaluation, + ratings: values, + ratingsComplete: hasEveryRating(values, ratingItems), + }; + }); +} + +// Two decimals, and never a negative zero. +const round2 = (value: number) => Math.round(value * 100) / 100 || 0; + +// The list polls every 10 seconds; calibrating on each poll reads and sorts the +// whole edition's evaluations, so the result is kept for this long. +const CALIBRATION_TTL_SECONDS = 60; + +/** The entries compute ranks: live and still eligible for prizes. */ +const rankedEntries = (hackathonId: string) => + and( + eq(judgingEntries.hackathonId, hackathonId), + isNull(judgingEntries.deletedAt), + isNull(judgingEntries.prizeIneligibleAt), + ); + +/** + * What a calibration was computed from, as a cache key suffix: how many + * evaluations the edition has and when one was last saved, and the same two for + * its ranked entries. A new, edited or deleted evaluation (a reopened grade is + * one), or an entry pulled from prizes, withdrawn or restored, moves one of the + * four, so the next read calibrates afresh. The one pair that cancels out is a + * grade deleted while a draft is submitted as of an earlier deadline, both + * between two polls; the TTL holds that to a minute. Two aggregates; nothing is + * listed. + */ +async function calibrationFingerprint(db: JudgingDb, hackathonId: string) { + const [[evaluations], [entries]] = await Promise.all([ + db + .select({ n: count(), latest: max(judgingEvaluations.updatedAt) }) + .from(judgingEvaluations) + .where(eq(judgingEvaluations.hackathonId, hackathonId)), + db + .select({ n: count(), latest: max(judgingEntries.updatedAt) }) + .from(judgingEntries) + .where(rankedEntries(hackathonId)), + ]); + return [ + evaluations?.n ?? 0, + evaluations?.latest?.getTime() ?? 0, + entries?.n ?? 0, + entries?.latest?.getTime() ?? 0, + ].join(":"); +} + +/** + * How each judge's scores are being adjusted, grouped by challenge: the + * organiser's window onto what compute does. Read from the current evaluations + * of live, prize-eligible entries, the same ones compute calibrates. Judges and + * hackers never get this; only the organiser's results list returns it. + * + * The calibration is cached per edition (see calibrationFingerprint). compute + * does not read it: a ranking is always calibrated from the evaluations as they + * stand. + */ +async function judgeCalibrations( + db: JudgingDb, + cache: CacheService, + hackathonId: string, +) { + const key = `${CacheKeys.judgingCalibration(hackathonId)}:${await calibrationFingerprint(db, hackathonId)}`; + const calibrated = await cache.getOrSet( + key, + async () => { + const [evaluations, entries] = await Promise.all([ + evaluationsForScoring(db, hackathonId), + db + .select({ id: judgingEntries.id }) + .from(judgingEntries) + .where(rankedEntries(hackathonId)), + ]); + const live = new Set(entries.map((entry) => entry.id)); + return calibrate( + evaluations.filter((evaluation) => live.has(evaluation.entryId)), + ).judges; + }, + CALIBRATION_TTL_SECONDS, + ); + if (calibrated.length === 0) return []; + + const [judges, challenges] = await Promise.all([ + db + .select({ + id: judgingJudges.id, + displayName: judgingJudges.displayName, + kind: judgingJudges.kind, + }) + .from(judgingJudges) + .where(eq(judgingJudges.hackathonId, hackathonId)), + db + .select({ id: judgingChallenges.id, label: judgingChallenges.label }) + .from(judgingChallenges) + .where(eq(judgingChallenges.hackathonId, hackathonId)) + .orderBy( + asc(judgingChallenges.displayOrder), + asc(judgingChallenges.label), + ), + ]); + const judgeOf = new Map(judges.map((judge) => [judge.id, judge])); + + return challenges + .map((challenge) => ({ + challengeId: challenge.id, + challenge: challenge.label, + judges: calibrated + .filter( + (row) => row.challengeId === challenge.id && judgeOf.has(row.judgeId), + ) + .map((row) => { + const judge = judgeOf.get(row.judgeId)!; + return { + judgeId: row.judgeId, + judgeName: judge.displayName, + kind: judge.kind, + grades: row.evaluations, + offset: round2(row.offset), + spread: round2(row.spread), + drift: round2(row.drift), + }; + }) + .sort((a, b) => a.judgeName.localeCompare(b.judgeName)), + })) + .filter((challenge) => challenge.judges.length > 0); +} + +// Computing and publishing placements, and the organiser's view of what each +// judge submitted. +export const judgingResultsRouter = createTRPCRouter({ + // Replaces the draft placings with a fresh ranking from every evaluation that + // holds every rating. Refused while results are published: recomputing then + // would either change the public list under people's feet or silently pull + // it. + compute: isAdmin.input(hackathonInput).mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + const now = new Date(); + // Drafts past their deadline are evaluations too; rank with them in. + await reconcileDueDrafts(db, input.hackathonId, now); + + const result = await db.transaction(async (tx) => { + await lockJudgingConfig(tx, input.hackathonId); + + const [published] = await tx + .select({ n: count() }) + .from(judgingPlacements) + .where( + and( + eq(judgingPlacements.hackathonId, input.hackathonId), + isNotNull(judgingPlacements.publishedAt), + ), + ); + if ((published?.n ?? 0) > 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "Results are published. Unpublish them first, then compute again and republish.", + }); + } + + // Judges still score a team an organiser pulled from prizes, so its + // evaluations stay as they are; it just cannot place. Leaving it out of + // the entry set is enough, because computePlacements ignores + // evaluations of any entry it was not given. + const entries = await tx + .select({ id: judgingEntries.id }) + .from(judgingEntries) + .where( + and( + eq(judgingEntries.hackathonId, input.hackathonId), + isNull(judgingEntries.deletedAt), + isNull(judgingEntries.prizeIneligibleAt), + ), + ); + const memberships = await tx + .select({ + entryId: judgingEntryChallenges.entryId, + challengeId: judgingEntryChallenges.challengeId, + }) + .from(judgingEntryChallenges) + .where(eq(judgingEntryChallenges.hackathonId, input.hackathonId)); + const challenges = await tx + .select({ id: judgingChallenges.id, label: judgingChallenges.label }) + .from(judgingChallenges) + .where(eq(judgingChallenges.hackathonId, input.hackathonId)) + .orderBy( + asc(judgingChallenges.displayOrder), + asc(judgingChallenges.label), + ); + const evaluations = await evaluationsForScoring(tx, input.hackathonId); + + // Each evaluation is calibrated for its judge before it is scored. + // Always from the evaluations as they stand, never from the cache that + // the list keeps for its calibration summary. + const placements = computePlacements({ + entryIds: entries.map((entry) => entry.id), + memberships, + challenges, + evaluations, + }); + + await tx + .delete(judgingPlacements) + .where(eq(judgingPlacements.hackathonId, input.hackathonId)); + if (placements.length > 0) { + await tx.insert(judgingPlacements).values( + placements.map((placement) => ({ + hackathonId: input.hackathonId, + entryId: placement.entryId, + scope: placement.scope, + scopeLabel: placement.scopeLabel, + placement: placement.placement, + score: formatScore(placement.score), + rawScore: formatScore(placement.rawScore), + evaluationCount: placement.evaluationCount, + computedAt: now, + })), + ); + } + + return { + placements: placements.length, + scopes: new Set(placements.map((placement) => placement.scope)).size, + evaluations: evaluations.filter( + (evaluation) => evaluation.ratingsComplete, + ).length, + }; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.results.compute", + resourceId: input.hackathonId, + metadata: result, + }); + + return { ...result, computedAt: now }; + }), + + // Makes the drafts public through hackathon.getResults. + publish: isAdmin.input(hackathonInput).mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + const now = new Date(); + + const published = await db.transaction(async (tx) => { + await lockJudgingConfig(tx, input.hackathonId); + const [existing] = await tx + .select({ n: count() }) + .from(judgingPlacements) + .where(eq(judgingPlacements.hackathonId, input.hackathonId)); + if ((existing?.n ?? 0) === 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "There is nothing to publish yet. Compute results first, once judges have submitted. Reopening a grade clears the draft placings, so compute again after one.", + }); + } + const rows = await tx + .update(judgingPlacements) + .set({ publishedAt: now }) + .where( + and( + eq(judgingPlacements.hackathonId, input.hackathonId), + isNull(judgingPlacements.publishedAt), + ), + ) + .returning({ id: judgingPlacements.id }); + return rows.length; + }); + + // Read before this publish is recorded: only the first one tells hackers. + const announce = + published > 0 && !(await resultsPublishedBefore(db, input.hackathonId)); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.results.publish", + resourceId: input.hackathonId, + severity: "warn", + metadata: { placements: published }, + }); + + if (announce) void notifyResultsPublished(db, input.hackathonId); + + return { published, publishedAt: now }; + }), + + unpublish: isAdmin.input(hackathonInput).mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + + const rows = await db + .update(judgingPlacements) + .set({ publishedAt: null }) + .where( + and( + eq(judgingPlacements.hackathonId, input.hackathonId), + isNotNull(judgingPlacements.publishedAt), + ), + ) + .returning({ id: judgingPlacements.id }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.results.unpublish", + resourceId: input.hackathonId, + severity: "warn", + metadata: { placements: rows.length }, + }); + + return { unpublished: rows.length }; + }), + + // The placings as computed, grouped by scope with Overall first, and how + // many evaluations were saved after the last compute (a stale ranking). + list: isAdmin.input(hackathonInput).query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const rows = await db + .select({ + id: judgingPlacements.id, + scope: judgingPlacements.scope, + scopeLabel: judgingPlacements.scopeLabel, + placement: judgingPlacements.placement, + score: judgingPlacements.score, + rawScore: judgingPlacements.rawScore, + evaluationCount: judgingPlacements.evaluationCount, + publishedAt: judgingPlacements.publishedAt, + computedAt: judgingPlacements.computedAt, + entryId: judgingEntries.id, + title: judgingEntries.title, + deletedAt: judgingEntries.deletedAt, + prizeIneligibleAt: judgingEntries.prizeIneligibleAt, + }) + .from(judgingPlacements) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingPlacements.entryId), + ) + .where(eq(judgingPlacements.hackathonId, input.hackathonId)) + .orderBy( + asc(judgingPlacements.scopeLabel), + asc(judgingPlacements.placement), + asc(judgingEntries.title), + ); + + const computedAt = rows.reduce( + (latest, row) => + !latest || row.computedAt > latest ? row.computedAt : latest, + null, + ); + const publishedAt = + rows.find((row) => row.publishedAt)?.publishedAt ?? null; + + let evaluationsSinceCompute = 0; + if (computedAt) { + const [since] = await db + .select({ n: count() }) + .from(judgingEvaluations) + .where( + and( + eq(judgingEvaluations.hackathonId, input.hackathonId), + gt(judgingEvaluations.updatedAt, computedAt), + ), + ); + evaluationsSinceCompute = since?.n ?? 0; + } + + // An organiser decides these teams' prizes, so each row says how many + // flags are still open. The flag detail (who, which events) stays in the + // Projects section; this payload carries the count and nothing else. + const flags = await checkInFlagsForEntries(db, input.hackathonId, [ + ...new Set(rows.map((row) => row.entryId)), + ]); + const openFlags = new Map( + [...flags].map(([entryId, views]) => [ + entryId, + views.filter((view) => !view.dismissed).length, + ]), + ); + + const scopes = new Map< + string, + { + scope: string; + scopeLabel: string; + placements: { + id: string; + entryId: string; + title: string; + placement: number; + /** Adjusted for judge bias; this is what the placing ranks on. */ + score: number | null; + /** The plain mean of the same evaluations. Null on older rows. */ + rawScore: number | null; + evaluationCount: number; + /** Pulled from judging since; getResults already hides it. */ + withdrawn: boolean; + /** Check-in flags nobody has dismissed. Only the count leaves here. */ + openCheckInFlags: number; + /** Pulled from prizes; getResults hides it and a recompute drops it. */ + prizeIneligible: boolean; + }[]; + } + >(); + for (const row of rows) { + const scope = scopes.get(row.scope) ?? { + scope: row.scope, + scopeLabel: row.scopeLabel, + placements: [], + }; + scope.placements.push({ + id: row.id, + entryId: row.entryId, + title: row.title, + placement: row.placement, + score: toScore(row.score), + rawScore: toScore(row.rawScore), + evaluationCount: row.evaluationCount, + withdrawn: !!row.deletedAt, + openCheckInFlags: openFlags.get(row.entryId) ?? 0, + prizeIneligible: !!row.prizeIneligibleAt, + }); + scopes.set(row.scope, scope); + } + + return { + computedAt, + publishedAt, + evaluationsSinceCompute, + calibration: computedAt + ? await judgeCalibrations(db, ctx.cache, input.hackathonId) + : [], + scopes: [...scopes.values()].sort( + (a, b) => + Number(b.scope === OVERALL_SCOPE) - + Number(a.scope === OVERALL_SCOPE) || + a.scopeLabel.localeCompare(b.scopeLabel), + ), + }; + }), + + // One presentation's results: each judge who wrote or started an + // evaluation, their ratings and responses once every rating is in, and the + // mean. + appointmentResults: isAdmin + .input(hackathonInput.extend({ appointmentId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await reconcileDueDrafts(db, input.hackathonId); + + const [appointment] = await db + .select({ + id: judgingAppointments.id, + entryId: judgingAppointments.entryId, + challengeId: judgingAppointments.challengeId, + startsAt: judgingAppointments.startsAt, + endsAt: judgingAppointments.endsAt, + title: judgingEntries.title, + challenge: judgingChallenges.label, + room: judgingRooms.name, + building: judgingBuildings.name, + }) + .from(judgingAppointments) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingAppointments.entryId), + ) + .innerJoin( + judgingChallenges, + eq(judgingChallenges.id, judgingAppointments.challengeId), + ) + .innerJoin( + judgingRooms, + eq(judgingRooms.id, judgingAppointments.roomId), + ) + .leftJoin( + judgingBuildings, + eq(judgingBuildings.id, judgingRooms.buildingId), + ) + .where( + and( + eq(judgingAppointments.id, input.appointmentId), + eq(judgingAppointments.hackathonId, input.hackathonId), + ), + ) + .limit(1); + if (!appointment) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That appointment is not in the saved schedule any more.", + }); + } + + const [evaluations, drafts, elsewhere] = await Promise.all([ + db + .select({ + id: judgingEvaluations.id, + judgeId: judgingJudges.id, + judgeName: judgingJudges.displayName, + kind: judgingJudges.kind, + isComplete: judgingEvaluations.isComplete, + autoSubmittedAt: judgingEvaluations.autoSubmittedAt, + updatedAt: judgingEvaluations.updatedAt, + }) + .from(judgingEvaluations) + .innerJoin( + judgingJudges, + eq(judgingJudges.id, judgingEvaluations.judgeId), + ) + .where( + and( + eq(judgingEvaluations.hackathonId, input.hackathonId), + eq(judgingEvaluations.appointmentId, appointment.id), + ), + ), + db + .select({ + judgeId: judgingJudges.id, + judgeName: judgingJudges.displayName, + kind: judgingJudges.kind, + updatedAt: judgingEvaluationDrafts.updatedAt, + }) + .from(judgingEvaluationDrafts) + .innerJoin( + judgingJudges, + eq(judgingJudges.id, judgingEvaluationDrafts.judgeId), + ) + .where( + and( + eq(judgingEvaluationDrafts.hackathonId, input.hackathonId), + eq(judgingEvaluationDrafts.appointmentId, appointment.id), + ), + ), + // Evaluations of the same project and challenge outside this slot: an + // untimed visit, or a slot it was moved from. + db + .select({ id: judgingEvaluations.id }) + .from(judgingEvaluations) + .where( + and( + eq(judgingEvaluations.hackathonId, input.hackathonId), + eq(judgingEvaluations.entryId, appointment.entryId), + eq(judgingEvaluations.challengeId, appointment.challengeId), + or( + isNull(judgingEvaluations.appointmentId), + ne(judgingEvaluations.appointmentId, appointment.id), + ), + ), + ) + .limit(1), + ]); + + const evaluationIds = evaluations.map((evaluation) => evaluation.id); + const [ratings, responses, ratingItems] = evaluationIds.length + ? await Promise.all([ + db + .select({ + evaluationId: judgingEvaluationRatings.evaluationId, + itemId: judgingRubricItems.id, + label: judgingRubricItems.label, + value: judgingEvaluationRatings.value, + }) + .from(judgingEvaluationRatings) + .innerJoin( + judgingRubricItems, + eq(judgingRubricItems.id, judgingEvaluationRatings.itemId), + ) + .where( + inArray(judgingEvaluationRatings.evaluationId, evaluationIds), + ) + .orderBy(asc(judgingRubricItems.displayOrder)), + db + .select({ + evaluationId: judgingEvaluationResponses.evaluationId, + itemId: judgingRubricItems.id, + label: judgingRubricItems.label, + value: judgingEvaluationResponses.value, + isPublic: judgingEvaluationResponses.isPublic, + }) + .from(judgingEvaluationResponses) + .innerJoin( + judgingRubricItems, + eq(judgingRubricItems.id, judgingEvaluationResponses.itemId), + ) + .where( + inArray(judgingEvaluationResponses.evaluationId, evaluationIds), + ) + .orderBy(asc(judgingRubricItems.displayOrder)), + ratingItemCount(db, input.hackathonId), + ]) + : [[], [], 0]; + + // An evaluation counts once it rated every item, whether or not it + // answered every written question. + const counted = new Set( + evaluations + .filter((evaluation) => + hasEveryRating( + ratings + .filter((rating) => rating.evaluationId === evaluation.id) + .map((rating) => rating.value), + ratingItems, + ), + ) + .map((evaluation) => evaluation.id), + ); + + const judged = new Set( + evaluations.map((evaluation) => evaluation.judgeId), + ); + const judges = [ + ...evaluations.map((evaluation) => { + const own = counted.has(evaluation.id) + ? ratings.filter((rating) => rating.evaluationId === evaluation.id) + : []; + return { + judgeId: evaluation.judgeId, + judgeName: evaluation.judgeName, + kind: evaluation.kind, + evaluationId: evaluation.id as string | null, + status: evaluation.isComplete + ? ("complete" as const) + : ("incomplete" as const), + autoSubmitted: !!evaluation.autoSubmittedAt, + counted: counted.has(evaluation.id), + updatedAt: evaluation.updatedAt, + score: evaluationScore(own.map((rating) => rating.value)), + ratings: own.map(({ itemId, label, value }) => ({ + itemId, + label, + value, + })), + responses: counted.has(evaluation.id) + ? responses + .filter((response) => response.evaluationId === evaluation.id) + .map(({ itemId, label, value, isPublic }) => ({ + itemId, + label, + value, + isPublic, + })) + : [], + }; + }), + // Still typing: a draft with nothing submitted. + ...drafts + .filter((draft) => !judged.has(draft.judgeId)) + .map((draft) => ({ + judgeId: draft.judgeId, + judgeName: draft.judgeName, + kind: draft.kind, + evaluationId: null, + status: "drafting" as const, + autoSubmitted: false, + counted: false, + updatedAt: draft.updatedAt, + score: null, + ratings: [], + responses: [], + })), + ].sort((a, b) => a.judgeName.localeCompare(b.judgeName)); + + const counting = judges.filter((judge) => judge.counted); + return { + appointment, + average: meanOrNull( + counting + .map((judge) => judge.score) + .filter((score): score is number => score !== null), + ), + countedCount: counting.length, + hasResultsElsewhere: elsewhere.length > 0, + judges, + }; + }), + + // Every evaluation in the edition, newest first: who scored which project + // in which challenge, the score, and whether the deadline submitted it. + evaluationAudit: isAdmin + .input(hackathonInput.extend({ entryId: z.string().uuid().optional() })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await reconcileDueDrafts(db, input.hackathonId); + + const rows = await db + .select({ + id: judgingEvaluations.id, + entryId: judgingEntries.id, + title: judgingEntries.title, + challengeId: judgingChallenges.id, + challenge: judgingChallenges.label, + judgeId: judgingJudges.id, + judgeName: judgingJudges.displayName, + kind: judgingJudges.kind, + appointmentId: judgingEvaluations.appointmentId, + isComplete: judgingEvaluations.isComplete, + revision: judgingEvaluations.revision, + submittedAt: judgingEvaluations.submittedAt, + autoSubmittedAt: judgingEvaluations.autoSubmittedAt, + updatedAt: judgingEvaluations.updatedAt, + }) + .from(judgingEvaluations) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEvaluations.entryId), + ) + .innerJoin( + judgingChallenges, + eq(judgingChallenges.id, judgingEvaluations.challengeId), + ) + .innerJoin( + judgingJudges, + eq(judgingJudges.id, judgingEvaluations.judgeId), + ) + .where( + and( + eq(judgingEvaluations.hackathonId, input.hackathonId), + input.entryId + ? eq(judgingEvaluations.entryId, input.entryId) + : undefined, + ), + ) + .orderBy(desc(judgingEvaluations.updatedAt)) + .limit(500); + + const ratings = await ratingsOf( + db, + rows.map((row) => row.id), + ); + const ratingItems = await ratingItemCount(db, input.hackathonId); + + return rows.map(({ autoSubmittedAt, ...row }) => ({ + ...row, + autoSubmitted: !!autoSubmittedAt, + autoSubmittedAt, + score: evaluationScore(ratings.get(row.id) ?? []), + // Whether placings count it: it rated every item. + counted: hasEveryRating(ratings.get(row.id) ?? [], ratingItems), + })); + }), + + // Every saved version of one evaluation, newest first, with the rubric so + // the snapshots' item ids can be labelled. + revisions: isAdmin + .input(hackathonInput.extend({ evaluationId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const [evaluation] = await db + .select({ + id: judgingEvaluations.id, + title: judgingEntries.title, + challenge: judgingChallenges.label, + judgeName: judgingJudges.displayName, + kind: judgingJudges.kind, + revision: judgingEvaluations.revision, + isComplete: judgingEvaluations.isComplete, + autoSubmittedAt: judgingEvaluations.autoSubmittedAt, + updatedAt: judgingEvaluations.updatedAt, + }) + .from(judgingEvaluations) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEvaluations.entryId), + ) + .innerJoin( + judgingChallenges, + eq(judgingChallenges.id, judgingEvaluations.challengeId), + ) + .innerJoin( + judgingJudges, + eq(judgingJudges.id, judgingEvaluations.judgeId), + ) + .where( + and( + eq(judgingEvaluations.id, input.evaluationId), + eq(judgingEvaluations.hackathonId, input.hackathonId), + ), + ) + .limit(1); + if (!evaluation) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That evaluation no longer exists.", + }); + } + + const [revisions, rubric] = await Promise.all([ + db + .select({ + id: judgingEvaluationRevisions.id, + revision: judgingEvaluationRevisions.revision, + snapshot: judgingEvaluationRevisions.snapshot, + createdAt: judgingEvaluationRevisions.createdAt, + }) + .from(judgingEvaluationRevisions) + .where(eq(judgingEvaluationRevisions.evaluationId, evaluation.id)) + .orderBy(desc(judgingEvaluationRevisions.revision)), + db + .select({ + id: judgingRubricItems.id, + kind: judgingRubricItems.kind, + label: judgingRubricItems.label, + }) + .from(judgingRubricItems) + .where(eq(judgingRubricItems.hackathonId, input.hackathonId)) + .orderBy(asc(judgingRubricItems.displayOrder)), + ]); + + return { evaluation, revisions, rubric }; + }), +}); diff --git a/packages/api/src/routers/judging/schedule.ts b/packages/api/src/routers/judging/schedule.ts new file mode 100644 index 00000000..dd707178 --- /dev/null +++ b/packages/api/src/routers/judging/schedule.ts @@ -0,0 +1,1862 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { + judgingAppointments, + judgingBuildings, + judgingChallenges, + judgingConfigs, + judgingEntries, + judgingEntryChallenges, + judgingEvaluationDrafts, + judgingEvaluations, + judgingGuestSessions, + judgingJudges, + judgingPresence, + judgingRooms, + judgingSchedules, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { createTRPCRouter } from "../../trpc"; +import { isAdmin, isAdminUpload } from "../../middleware/procedures"; +import { recordAdminAction } from "../../middleware/audit"; +import { + assertHackathonExists, + ensureJudgingConfig, +} from "../../services/judging/config"; +import type { JudgingDb } from "../../services/judging/config"; +import { rootOf } from "../../services/judging/scope"; +import { + candidateKey, + planFingerprint, + planInputs, + selectPlan, + taskKey, +} from "../../services/judging/schedule/inputs"; +import type { + PlanDiagnostic, + PlanResult, +} from "../../services/judging/schedule/solve"; +import { aamoghCPSolver } from "../../services/judging/schedule/aamogh-cp-solver"; +import { + CPSAT_COMMAND, + cpsatAssignmentProblem, + cpsatProblem, + readCpsatAssignments, + readCpsatSolution, +} from "../../services/judging/schedule/cpsat-bridge"; +import { + assignJudges, + describeShortfall, + judgeShortfalls, + mayGrade, +} from "../../services/judging/schedule/judges"; +import type { + JudgeAssignment, + JudgeInput, +} from "../../services/judging/schedule/judges"; +import { + judgeChanges, + judgeChoices, + judgePlanOf, + rosterOf, +} from "../../services/judging/schedule/judge-plan"; +import type { + Roster, + SavedAppointment, +} from "../../services/judging/schedule/judge-plan"; +import { + CPSAT_SECONDS, + CPSAT_UNAVAILABLE, + cpsatAvailable, + runCpsat, +} from "../../services/judging/schedule/cpsat-runner"; +import { reconcileDueDrafts } from "../../services/judging/evaluation-store"; +import { holdsEveryRating } from "../../services/judging/scoring-sql"; +import { moveChoices } from "../../services/judging/schedule/moves"; +import { + staffedRoomIds, + withStatus, +} from "../../services/judging/schedule/board"; +import { judgeProgressById } from "../../services/judging/schedule/judge-progress"; +import type { JudgeProgress } from "../../services/judging/schedule/judge-progress"; +import { + appointmentTimes, + firstSlotFrom, + gridOf, + slotAt, + slotMinutes, + slotStartsAt, + timingProblems, +} from "../../services/judging/schedule/slots"; +import type { ScheduleTiming } from "../../services/judging/schedule/slots"; +import type { Placement, PlanInputs } from "../../services/judging/schedule/inputs"; + +// Building, saving and adjusting the presentation schedule. The solver and +// its rules live in services/judging/schedule as pure code; this file reads +// the inventory, puts the outcome into words and writes appointments. None +// of these reads are cached: the board is polled while judging runs. + +const uuid = z.string().uuid(); + +const timingInput = z + .object({ + windowStart: z.date(), + windowEnd: z.date(), + setupMinutes: z.number().int().min(0).max(120).default(2), + judgingMinutes: z.number().int().min(1).max(240).default(6), + teardownMinutes: z.number().int().min(0).max(120).default(2), + sameBuildingBreakMinutes: z.number().int().min(0).max(720).default(10), + crossBuildingBreakMinutes: z.number().int().min(0).max(720).default(20), + }) + .superRefine((timing, ctx) => { + for (const message of timingProblems(timing)) { + ctx.addIssue({ code: "custom", message, path: ["windowEnd"] }); + } + }); + +const plural = (n: number, one: string, many = `${one}s`) => + `${n} ${n === 1 ? one : many}`; + +function assertWindowAhead(timing: ScheduleTiming) { + if (timing.windowStart <= new Date()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "The judging window has already started. Pick a start time in the future.", + }); + } +} + +function timingOf(row: typeof judgingSchedules.$inferSelect): ScheduleTiming { + return { + windowStart: row.windowStart, + windowEnd: row.windowEnd, + setupMinutes: row.setupMinutes, + judgingMinutes: row.judgingMinutes, + teardownMinutes: row.teardownMinutes, + sameBuildingBreakMinutes: row.sameBuildingBreakMinutes, + crossBuildingBreakMinutes: row.crossBuildingBreakMinutes, + }; +} + +async function findSchedule(db: JudgingDb, hackathonId: string, lock = false) { + const query = db + .select() + .from(judgingSchedules) + .where(eq(judgingSchedules.hackathonId, hackathonId)); + const [row] = lock ? await query.for("update") : await query; + return row ?? null; +} + +/** Challenges, every room (archived too, for the buildings of old bookings) and entries. */ +async function readInventory(db: JudgingDb, hackathonId: string) { + const [challenges, rooms, memberships] = await Promise.all([ + db + .select({ + id: judgingChallenges.id, + label: judgingChallenges.label, + parentId: judgingChallenges.parentId, + mode: judgingChallenges.mode, + isGeneral: judgingChallenges.isGeneral, + isGroup: judgingChallenges.isGroup, + }) + .from(judgingChallenges) + .where(eq(judgingChallenges.hackathonId, hackathonId)), + db + .select({ + id: judgingRooms.id, + name: judgingRooms.name, + kind: judgingRooms.kind, + challengeId: judgingRooms.challengeId, + buildingId: judgingRooms.buildingId, + buildingName: judgingBuildings.name, + archivedAt: judgingRooms.archivedAt, + }) + .from(judgingRooms) + .leftJoin(judgingBuildings, eq(judgingBuildings.id, judgingRooms.buildingId)) + .where(eq(judgingRooms.hackathonId, hackathonId)) + .orderBy(asc(judgingRooms.displayOrder), asc(judgingRooms.name)), + db + .select({ + entryId: judgingEntryChallenges.entryId, + challengeId: judgingEntryChallenges.challengeId, + title: judgingEntries.title, + }) + .from(judgingEntryChallenges) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEntryChallenges.entryId), + ) + .where( + and( + eq(judgingEntryChallenges.hackathonId, hackathonId), + isNull(judgingEntries.deletedAt), + ), + ), + ]); + return { challenges, rooms, memberships }; +} + +type Inventory = Awaited>; + +/** The judges an edition can give presentations to, with every judge's name. */ +async function readRoster( + db: JudgingDb, + hackathonId: string, + inventory: Inventory, +): Promise { + const [judges, sessions] = await Promise.all([ + db + .select({ + id: judgingJudges.id, + kind: judgingJudges.kind, + userId: judgingJudges.userId, + displayName: judgingJudges.displayName, + }) + .from(judgingJudges) + .where(eq(judgingJudges.hackathonId, hackathonId)), + db + .select({ + judgeId: judgingGuestSessions.judgeId, + roomId: judgingGuestSessions.roomId, + createdAt: judgingGuestSessions.createdAt, + }) + .from(judgingGuestSessions) + .where( + and( + eq(judgingGuestSessions.hackathonId, hackathonId), + isNull(judgingGuestSessions.revokedAt), + ), + ), + ]); + return rosterOf({ + hackathonId, + judges, + seats: sessions.flatMap((s) => + s.judgeId + ? [{ judgeId: s.judgeId, roomId: s.roomId, createdAt: s.createdAt }] + : [], + ), + challenges: inventory.challenges, + rooms: selectPlan(inventory).rooms, + }); +} + +function namesOf(inventory: Inventory) { + const labels = new Map(inventory.challenges.map((c) => [c.id, c.label])); + const titles = new Map(inventory.memberships.map((m) => [m.entryId, m.title])); + const rooms = new Map(inventory.rooms.map((r) => [r.id, r])); + return { + label: (id: string) => labels.get(id) ?? "Removed challenge", + title: (id: string) => titles.get(id) ?? "Removed project", + room: (id: string) => ({ + roomName: rooms.get(id)?.name ?? "Removed room or table", + buildingName: rooms.get(id)?.buildingName ?? null, + }), + /** How many of these rooms are tables. */ + tables: (planned: readonly { id: string }[]) => + planned.filter((r) => rooms.get(r.id)?.kind === "table").length, + }; +} + +/** The solver's diagnostics as sentences an organiser can act on. */ +function explain( + diagnostics: PlanDiagnostic[], + names: ReturnType, +): string[] { + const sentences: string[] = []; + const leftOver: string[] = []; + + for (const d of diagnostics) { + if (d.kind === "capacity" && d.rooms === 0) { + sentences.push( + `"${names.label(d.challengeId)}" has ${plural(d.presentations, "presentation")} but no room or table in a building. Add one for it, or judge it unscheduled.`, + ); + } else if (d.kind === "capacity") { + sentences.push( + `"${names.label(d.challengeId)}" has ${plural(d.presentations, "presentation")} but its ${plural(d.rooms, "room or table", "rooms or tables")} hold ${d.capacity} in this window. Add a room or table, lengthen the window or shorten the presentations.`, + ); + } else if (d.kind === "overload") { + sentences.push( + `"${names.title(d.entryId)}" presents ${plural(d.presentations, "time")}, which takes ${d.slotsNeeded} slots with breaks; the window has ${d.slots}. Lengthen the window or shorten the breaks.`, + ); + } else { + leftOver.push(`"${names.title(d.entryId)}" for ${names.label(d.challengeId)}`); + } + } + + if (leftOver.length > 0) { + const shown = leftOver.slice(0, 5).join(", "); + const more = leftOver.length > 5 ? ` and ${leftOver.length - 5} more` : ""; + sentences.push( + `No schedule fits every presentation, even with shorter breaks between buildings. Left over: ${shown}${more}. Lengthen the window, add rooms or tables, or shorten the breaks.`, + ); + } + return sentences; +} + +/** The solver's inputs and fingerprint for the edition as it stands. */ +function planOf(inventory: Inventory, timing: ScheduleTiming) { + const plan = selectPlan(inventory); + if (plan.tasks.length === 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "No project is entered in a scheduled challenge yet. Import or promote projects, then build the schedule.", + }); + } + return { + inputs: planInputs(plan, timing), + fingerprint: planFingerprint(plan, timing), + }; +} + +/** Solves the edition as it stands for one timing, with aamoghCPSolver. */ +function solveInventory(inventory: Inventory, timing: ScheduleTiming) { + const { inputs, fingerprint } = planOf(inventory, timing); + const result = aamoghCPSolver(inputs); + return { + inputs, + result, + fingerprint, + // Which of the first four goals no schedule can beat (from the solver's + // counting bounds), so the preview can say so. + proven: { + sponsorsDone: result.stats.proven[0] ?? false, + sponsorRoomIdle: result.stats.proven[1] ?? false, + allDone: result.stats.proven[2] ?? false, + buildingChanges: result.stats.proven[3] ?? false, + }, + key: result.ok + ? candidateKey(fingerprint, result.relaxed, result.placements) + : null, + }; +} + +/** A solved schedule as the preview shows it. */ +function describePlan( + result: Extract, + inputs: PlanInputs, + names: ReturnType, + timing: ScheduleTiming, +) { + const minutes = slotMinutes(timing); + const [sponsorEnd, sponsorIdle, allEnd, changes, lessBreaks] = result.score; + return { + slots: inputs.slots, + slotMinutes: minutes, + presentations: inputs.tasks.length, + projects: new Set(inputs.tasks.map((t) => t.entryId)).size, + rooms: inputs.rooms.length, + tables: names.tables(inputs.rooms), + ok: true as const, + relaxed: result.relaxed, + diagnostics: [] as string[], + summary: { + sponsorsDoneAt: sponsorEnd > 0 ? slotStartsAt(timing, sponsorEnd) : null, + allDoneAt: allEnd > 0 ? slotStartsAt(timing, allEnd) : null, + sponsorRoomIdleMinutes: sponsorIdle * minutes, + buildingChanges: changes, + breakMinutes: Math.abs(lessBreaks) * minutes, + }, + appointments: result.placements + .map((p) => ({ + entryId: p.entryId, + entryTitle: names.title(p.entryId), + challengeId: p.challengeId, + challengeLabel: names.label(p.challengeId), + roomId: p.roomId, + ...names.room(p.roomId), + slot: p.slot, + ...appointmentTimes(timing, p.slot), + })) + .sort((a, b) => a.slot - b.slot || a.roomName.localeCompare(b.roomName)), + reducedBreaks: result.reducedBreaks.map((r) => ({ + entryId: r.entryId, + entryTitle: names.title(r.entryId), + fromRoomName: names.room(r.fromRoomId).roomName, + toRoomName: names.room(r.toRoomId).roomName, + breakMinutes: r.gap * minutes, + })), + }; +} + +/** + * Writes a schedule the server has checked, in one transaction that holds + * the edition's config lock, refusing if one is already saved or the + * projects and rooms moved since `fingerprint` was taken. + */ +async function writeSchedule( + db: DrizzleDB, + userId: string, + input: { hackathonId: string; timing: ScheduleTiming }, + schedule: { + placements: Placement[]; + relaxed: boolean; + fingerprint: string; + /** The judge of each presentation, as far as there are judges. */ + assignments: readonly JudgeAssignment[]; + }, +) { + return db.transaction(async (tx) => { + await ensureJudgingConfig(tx, input.hackathonId); + // One save at a time per edition, and none while an import or room + // change that locks the same row is mid-flight. + await tx + .select({ id: judgingConfigs.hackathonId }) + .from(judgingConfigs) + .where(eq(judgingConfigs.hackathonId, input.hackathonId)) + .for("update"); + if (await findSchedule(tx, input.hackathonId)) throw scheduleExists(); + + const current = await readInventory(tx, input.hackathonId); + if (planFingerprint(selectPlan(current), input.timing) !== schedule.fingerprint) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Projects, rooms or tables changed while the schedule was being saved. Preview again and check it before saving.", + }); + } + + const [row] = await tx + .insert(judgingSchedules) + .values({ + hackathonId: input.hackathonId, + ...input.timing, + reducedBreaksAcknowledgedAt: schedule.relaxed ? new Date() : null, + createdById: userId, + }) + .returning({ id: judgingSchedules.id }); + + // A judge removed since the assignment was made has no appointments left. + const judges = new Set( + ( + await tx + .select({ id: judgingJudges.id }) + .from(judgingJudges) + .where(eq(judgingJudges.hackathonId, input.hackathonId)) + ).map((judge) => judge.id), + ); + const judgeOf = new Map( + schedule.assignments + .filter((a) => judges.has(a.judgeId)) + .map((a) => [taskKey(a.entryId, a.challengeId), a.judgeId]), + ); + + const rows = schedule.placements.map((p) => ({ + scheduleId: row!.id, + hackathonId: input.hackathonId, + entryId: p.entryId, + challengeId: p.challengeId, + roomId: p.roomId, + assignedJudgeId: judgeOf.get(taskKey(p.entryId, p.challengeId)) ?? null, + ...appointmentTimes(input.timing, p.slot), + })); + // Postgres takes at most 65535 parameters per statement. + for (let i = 0; i < rows.length; i += 1000) { + await tx.insert(judgingAppointments).values(rows.slice(i, i + 1000)); + } + + return { + scheduleId: row!.id, + appointments: rows.length, + judged: rows.filter((r) => r.assignedJudgeId).length, + }; + }); +} + +/** + * The uploaded CP-SAT solution, checked against the edition as it stands, + * with its judges checked against `judges` as they are now. + */ +function readUpload( + inventory: Inventory, + timing: ScheduleTiming, + solution: string, + judges: readonly JudgeInput[], +) { + const { inputs, fingerprint } = planOf(inventory, timing); + const read = readCpsatSolution(inputs, fingerprint, solution, judges); + if (!read.ok) { + throw new TRPCError({ code: "BAD_REQUEST", message: read.reason }); + } + return { + inputs, + fingerprint, + read, + key: candidateKey(fingerprint, read.relaxed, read.placements), + }; +} + +/** What a CP-SAT preview says about the judges. */ +function describeJudging( + read: { judging: { source: "cp-sat" | "in-app"; metrics: { unassigned: number } } }, + judges: readonly JudgeInput[], + presentations: number, +) { + return { + judges: judges.length, + presentations, + unassigned: read.judging.metrics.unassigned, + // Who worked the assignment out. + source: read.judging.source, + }; +} + +/** The saved schedule with everything the board and the move pickers need. */ +async function loadBoard( + db: JudgingDb, + hackathonId: string, + now: Date, + lock = false, +) { + const schedule = await findSchedule(db, hackathonId, lock); + const [inventory, booked, evaluations, presence] = await Promise.all([ + readInventory(db, hackathonId), + schedule + ? db + .select({ + id: judgingAppointments.id, + entryId: judgingAppointments.entryId, + challengeId: judgingAppointments.challengeId, + roomId: judgingAppointments.roomId, + startsAt: judgingAppointments.startsAt, + deadlineAt: judgingAppointments.deadlineAt, + endsAt: judgingAppointments.endsAt, + revision: judgingAppointments.revision, + assignedJudgeId: judgingAppointments.assignedJudgeId, + entryTitle: judgingEntries.title, + entryDeletedAt: judgingEntries.deletedAt, + }) + .from(judgingAppointments) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingAppointments.entryId), + ) + .where(eq(judgingAppointments.scheduleId, schedule.id)) + .orderBy(asc(judgingAppointments.startsAt)) + : Promise.resolve([]), + schedule + ? db + .select({ + judgeId: judgingEvaluations.judgeId, + entryId: judgingEvaluations.entryId, + challengeId: judgingEvaluations.challengeId, + appointmentId: judgingEvaluations.appointmentId, + ratingsComplete: holdsEveryRating(hackathonId), + }) + .from(judgingEvaluations) + .where(eq(judgingEvaluations.hackathonId, hackathonId)) + : Promise.resolve([]), + db + .select({ + judgeId: judgingPresence.judgeId, + roomId: judgingPresence.roomId, + joinedAt: judgingPresence.joinedAt, + lastSeenAt: judgingPresence.lastSeenAt, + leftAt: judgingPresence.leftAt, + }) + .from(judgingPresence) + .where(eq(judgingPresence.hackathonId, hackathonId)), + ]); + + const roots = new Map( + inventory.challenges.map((c) => [ + c.id, + rootOf(inventory.challenges, c.id)?.id ?? null, + ]), + ); + const rootIdOf = (id: string) => roots.get(id) ?? null; + return { + schedule, + inventory, + plan: selectPlan(inventory), + names: namesOf(inventory), + evaluations, + rootIdOf, + appointments: withStatus(booked, evaluations, presence, rootIdOf, now), + staffed: staffedRoomIds(presence, now), + }; +} + +type Board = Awaited>; +type SavedSchedule = NonNullable; + +/** + * The board's judges: who can be given presentations, and the saved + * appointments the way the assigner takes them. An appointment is open to a + * new judge while it has not started and nobody has begun an evaluation. + */ +async function loadJudging(db: JudgingDb, board: Board, schedule: SavedSchedule) { + const hackathonId = schedule.hackathonId; + const [roster, drafts] = await Promise.all([ + readRoster(db, hackathonId, board.inventory), + db + .select({ + judgeId: judgingEvaluationDrafts.judgeId, + entryId: judgingEvaluationDrafts.entryId, + challengeId: judgingEvaluationDrafts.challengeId, + appointmentId: judgingEvaluationDrafts.appointmentId, + updatedAt: judgingEvaluationDrafts.updatedAt, + deadlineAt: judgingEvaluationDrafts.deadlineAt, + }) + .from(judgingEvaluationDrafts) + .where(eq(judgingEvaluationDrafts.hackathonId, hackathonId)), + ]); + + const timing = timingOf(schedule); + const planned = new Set( + board.plan.tasks.map((t) => taskKey(t.entryId, t.challengeId)), + ); + const rootOfChallenge = (id: string) => + rootOf(board.inventory.challenges, id)?.id ?? null; + const started = (a: Board["appointments"][number]) => + drafts.some( + (d) => + d.entryId === a.entryId && + (d.appointmentId + ? d.appointmentId === a.id + : rootOfChallenge(d.challengeId) === a.challengeId), + ); + + const saved: SavedAppointment[] = board.appointments.flatMap((a) => { + // Saved appointments sit on the grid; one that does not has no slot to compare. + const slot = slotAt(timing, a.startsAt); + if (slot === null) return []; + return [ + { + id: a.id, + entryId: a.entryId, + challengeId: a.challengeId, + roomId: a.roomId, + slot, + assignedJudgeId: a.assignedJudgeId, + removed: + !!a.entryDeletedAt || !planned.has(taskKey(a.entryId, a.challengeId)), + open: a.status === "future" && !started(a), + }, + ]; + }); + + return { + roster, + saved, + drafts, + plan: planOfSaved(board, timing, saved, roster), + timing, + }; +} + +/** The saved appointments as the judge assigner takes them. */ +function planOfSaved( + board: Board, + timing: ScheduleTiming, + saved: readonly SavedAppointment[], + roster: Roster, +) { + return judgePlanOf({ + rooms: board.inventory.rooms, + sponsorChallengeIds: new Set( + board.inventory.challenges.filter((c) => !c.isGeneral).map((c) => c.id), + ), + grid: gridOf(timing), + appointments: saved, + judgeIds: new Set(roster.judges.map((j) => j.id)), + }); +} + +type Judging = Awaited>; + +/** What the board shows about judges: loads, and where there are too few. */ +function describeBoardJudging(judging: Judging, board: Board, now: Date) { + const { roster, saved, plan, timing } = judging; + const live = saved.filter((a) => !a.removed); + const rooms = new Map(board.inventory.rooms.map((r) => [r.id, r.name])); + + const loads = new Map(); + for (const a of live) { + if (a.assignedJudgeId) { + loads.set(a.assignedJudgeId, (loads.get(a.assignedJudgeId) ?? 0) + 1); + } + } + const available = new Map(roster.judges.map((j) => [j.id, j])); + const listed = new Set([...available.keys(), ...loads.keys()]); + + const from = firstSlotFrom(timing, now); + return { + judges: roster.judges.length, + assigned: live.filter((a) => a.assignedJudgeId).length, + // Still waiting for a judge: ones that have started or are over cannot + // be given one. + unassigned: live.filter((a) => a.open && !a.assignedJudgeId).length, + loads: [...listed] + .map((id) => { + const judge = available.get(id); + return { + judgeId: id, + name: roster.names.get(id) ?? "Removed judge", + kind: judge?.kind ?? null, + roomName: judge?.roomId ? (rooms.get(judge.roomId) ?? null) : null, + // Cannot take presentations: a guest with no room or table. + available: !!judge, + load: loads.get(id) ?? 0, + }; + }) + .sort( + (a, b) => + (a.kind === "guest" ? 1 : 0) - (b.kind === "guest" ? 1 : 0) || + a.name.localeCompare(b.name), + ), + warnings: judgeShortfalls( + plan.inputs, + plan.placements, + roster.judges, + plan.current, + ) + .filter((w) => w.slot >= from) + .map((w) => { + const challengeLabel = board.names.label(w.challengeId); + return { + slot: w.slot, + startsAt: slotStartsAt(timing, w.slot), + challengeId: w.challengeId, + challengeLabel, + presentations: w.presentations, + judgesFree: w.judgesFree, + message: describeShortfall(w, challengeLabel), + }; + }), + }; +} + +/** + * Who should grade the open presentations: CP-SAT when this server runs it + * and its answer passes the checks, the in-app assigner otherwise. + */ +async function makeAssignments( + judging: Judging, + options: { seconds: number; signal?: AbortSignal }, +) { + const { plan, roster } = judging; + if (cpsatAvailable()) { + const run = await runCpsat( + cpsatAssignmentProblem(plan.inputs, plan.placements, roster.judges, plan.keep), + options, + ); + if (run.status === "solved") { + const read = readCpsatAssignments( + plan.inputs, + plan.placements, + roster.judges, + plan.keep, + run.solution, + ); + if (read.ok) { + return { assignments: read.assignments, solver: "cp-sat" as const }; + } + } + } + const made = assignJudges(plan.inputs, plan.placements, roster.judges, { + keep: plan.keep, + }); + return { assignments: made.assignment, solver: "in-app" as const }; +} + +/** + * Every free future slot `task` can take, best first. Only rooms with a + * judge seen in the last 15 minutes are offered: a slot in an empty room is + * a presentation nobody sees. + */ +function rankChoices( + board: Board, + schedule: SavedSchedule, + task: { entryId: string; challengeId: string }, + current: { id: string; roomId: string; startsAt: Date } | null, + now: Date, +) { + const timing = timingOf(schedule); + // Saved appointments sit on the grid: save and every move put them there. + const slotOf = (startsAt: Date) => slotAt(timing, startsAt)!; + const choices = moveChoices({ + ...gridOf(timing), + rooms: board.inventory.rooms, + sponsorChallengeIds: new Set( + board.inventory.challenges.filter((c) => !c.isGeneral).map((c) => c.id), + ), + booked: board.appointments + .filter((a) => a.id !== current?.id) + .map((a) => ({ + entryId: a.entryId, + challengeId: a.challengeId, + roomId: a.roomId, + slot: slotOf(a.startsAt), + })), + task, + current: current + ? { roomId: current.roomId, slot: slotOf(current.startsAt) } + : null, + openRoomIds: board.plan.rooms + .filter((r) => r.challengeId === task.challengeId && board.staffed.has(r.id)) + .map((r) => r.id), + fromSlot: firstSlotFrom(timing, now), + }); + + return choices.map((choice) => ({ + roomId: choice.roomId, + ...board.names.room(choice.roomId), + ...appointmentTimes(timing, choice.slot), + reducedBreaks: choice.reducedBreaks, + })); +} + +type Choice = ReturnType[number]; + +function pickChoice( + choices: Choice[], + wanted: { roomId: string; startsAt: Date; acknowledgeReducedBreaks: boolean }, + timing: ScheduleTiming, +): Choice { + const choice = choices.find( + (c) => + c.roomId === wanted.roomId && + c.startsAt.getTime() === wanted.startsAt.getTime(), + ); + if (!choice) { + throw new TRPCError({ + code: "CONFLICT", + message: + "That time at that room or table is no longer open for this project. Reload the choices and pick another.", + }); + } + if (choice.reducedBreaks > 0 && !wanted.acknowledgeReducedBreaks) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `That time leaves the team less than ${timing.crossBuildingBreakMinutes} minutes to change buildings. Confirm the shorter break to book it.`, + }); + } + return choice; +} + +function assertMovable(status: Board["appointments"][number]["status"]) { + if (status === "pending") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "This presentation is happening now, so it stays where it is. Move it once its slot has passed if nobody judged it.", + }); + } + if (status === "complete" || status === "incomplete") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "Judges have already evaluated this presentation, so it stays where it is.", + }); + } +} + +const noSchedule = () => + new TRPCError({ + code: "NOT_FOUND", + message: "No schedule is saved for this hackathon. Preview one and save it first.", + }); + +const noJudgeNeeded = () => + new TRPCError({ + code: "PRECONDITION_FAILED", + message: "That project is no longer entered, so it needs no judge.", + }); + +const scheduleExists = () => + new TRPCError({ + code: "CONFLICT", + message: + "A schedule is already saved. Move appointments one at a time, or drop it before building a new one.", + }); + +export const judgingScheduleRouter = createTRPCRouter({ + /** + * Solves the edition for a timing without saving anything. The answer is + * deterministic, and `candidateKey` names it so save can tell whether it + * would write the same schedule. + */ + preview: isAdmin + .input(z.object({ hackathonId: uuid, timing: timingInput })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + assertWindowAhead(input.timing); + if (await findSchedule(db, input.hackathonId)) throw scheduleExists(); + + const inventory = await readInventory(db, input.hackathonId); + const names = namesOf(inventory); + const { inputs, result, key, proven } = solveInventory( + inventory, + input.timing, + ); + + if (!result.ok) { + return { + slots: inputs.slots, + slotMinutes: slotMinutes(input.timing), + presentations: inputs.tasks.length, + projects: new Set(inputs.tasks.map((t) => t.entryId)).size, + rooms: inputs.rooms.length, + tables: names.tables(inputs.rooms), + ok: false as const, + relaxed: false, + candidateKey: null, + diagnostics: explain(result.diagnostics, names), + summary: null, + proven: null, + appointments: [], + reducedBreaks: [], + }; + } + + return { + ...describePlan(result, inputs, names, input.timing), + candidateKey: key!, + proven, + }; + }), + + /** + * Saves the schedule the organiser previewed. Solves again here rather + * than trusting the client, refuses if that gives a different schedule or + * the inventory changed before the write, and needs a yes to shortened + * breaks. + */ + save: isAdmin + .input( + z.object({ + hackathonId: uuid, + timing: timingInput, + candidateKey: z.string().regex(/^[0-9a-f]{64}$/), + acknowledgeReducedBreaks: z.boolean().default(false), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + assertWindowAhead(input.timing); + + // Solved before the transaction so no lock is held while it computes. + const inventory = await readInventory(db, input.hackathonId); + const { inputs, result, key, fingerprint } = solveInventory( + inventory, + input.timing, + ); + if (!result.ok) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: explain(result.diagnostics, namesOf(inventory)).join(" "), + }); + } + if (key !== input.candidateKey) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Projects, rooms, tables or timing changed since this preview, so it is not the schedule that would be saved. Preview again and check it before saving.", + }); + } + if (result.relaxed && !input.acknowledgeReducedBreaks) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `This schedule gives ${plural(result.reducedBreaks.length, "change", "changes")} of building only the ${input.timing.sameBuildingBreakMinutes}-minute break. Confirm the shorter breaks to save it.`, + }); + } + + // Judges first come with the schedule, as far as there are any yet; + // assignJudges picks up the rest once more have joined. + const roster = await readRoster(db, input.hackathonId, inventory); + const written = await writeSchedule(db, ctx.userId, input, { + placements: result.placements, + relaxed: result.relaxed, + fingerprint, + assignments: assignJudges(inputs, result.placements, roster.judges) + .assignment, + }); + const saved = { + ...written, + relaxed: result.relaxed, + reducedBreaks: result.reducedBreaks.length, + }; + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.schedule.save", + resourceId: input.hackathonId, + severity: "warn", + metadata: saved, + }); + + return saved; + }), + + /** Deletes the saved schedule and its appointments, until the first result is in. */ + drop: isAdmin + .input(z.object({ hackathonId: uuid })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const dropped = await db.transaction(async (tx) => { + const schedule = await findSchedule(tx, input.hackathonId, true); + if (!schedule) throw noSchedule(); + if (schedule.firstResultAt) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Judges have submitted results against this schedule, so it can no longer be dropped. Move appointments one at a time instead.", + }); + } + const removed = await tx + .delete(judgingAppointments) + .where(eq(judgingAppointments.scheduleId, schedule.id)) + .returning({ id: judgingAppointments.id }); + await tx + .delete(judgingSchedules) + .where(eq(judgingSchedules.id, schedule.id)); + return { scheduleId: schedule.id, appointments: removed.length }; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.schedule.drop", + resourceId: input.hackathonId, + severity: "warn", + metadata: dropped, + }); + + return dropped; + }), + + /** + * The problem for the CP-SAT fallback (scripts/judging-schedule/cpsat.py), + * to run on a laptop when the in-app solver finds no schedule, or to look + * for a better one. When the in-app solver did find one, it goes along as + * the hint, so CP-SAT starts from it rather than from nothing. + */ + cpsatProblem: isAdmin + .input(z.object({ hackathonId: uuid, timing: timingInput })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + if (await findSchedule(db, input.hackathonId)) throw scheduleExists(); + + const inventory = await readInventory(db, input.hackathonId); + const { inputs, result, fingerprint } = solveInventory(inventory, input.timing); + const roster = await readRoster(db, input.hackathonId, inventory); + const problem = cpsatProblem( + inputs, + fingerprint, + result.ok ? result.placements : undefined, + roster.judges, + ); + return { + fileName: "problem.json", + contents: JSON.stringify(problem, null, 2), + command: CPSAT_COMMAND, + presentations: inputs.tasks.length, + hinted: result.ok, + judges: roster.judges.length, + }; + }), + + /** Checks an uploaded CP-SAT solution and shows it the way preview does. */ + previewCpsat: isAdminUpload + .input( + z.object({ + hackathonId: uuid, + timing: timingInput, + solution: z.string().min(1).max(10 * 1024 * 1024), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + assertWindowAhead(input.timing); + if (await findSchedule(db, input.hackathonId)) throw scheduleExists(); + + const inventory = await readInventory(db, input.hackathonId); + const roster = await readRoster(db, input.hackathonId, inventory); + const { inputs, read, key } = readUpload( + inventory, + input.timing, + input.solution, + roster.judges, + ); + return { + ...describePlan(read, inputs, namesOf(inventory), input.timing), + candidateKey: key, + // CP-SAT's own word: "optimal" means it proved nothing beats this. + solverStatus: read.status, + judging: describeJudging(read, roster.judges, inputs.tasks.length), + }; + }), + + /** Saves a checked CP-SAT solution, the one previewCpsat showed. */ + saveCpsat: isAdminUpload + .input( + z.object({ + hackathonId: uuid, + timing: timingInput, + solution: z.string().min(1).max(10 * 1024 * 1024), + candidateKey: z.string().regex(/^[0-9a-f]{64}$/), + acknowledgeReducedBreaks: z.boolean().default(false), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + assertWindowAhead(input.timing); + + const inventory = await readInventory(db, input.hackathonId); + const roster = await readRoster(db, input.hackathonId, inventory); + const { read, key, fingerprint } = readUpload( + inventory, + input.timing, + input.solution, + roster.judges, + ); + if (key !== input.candidateKey) { + throw new TRPCError({ + code: "CONFLICT", + message: + "This is not the schedule that was previewed. Upload the solution again and check it before saving.", + }); + } + if (read.relaxed && !input.acknowledgeReducedBreaks) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `This schedule gives ${plural(read.reducedBreaks.length, "change", "changes")} of building only the ${input.timing.sameBuildingBreakMinutes}-minute break. Confirm the shorter breaks to save it.`, + }); + } + + const written = await writeSchedule(db, ctx.userId, input, { + placements: read.placements, + relaxed: read.relaxed, + fingerprint, + assignments: read.judging.assignments, + }); + const saved = { + ...written, + relaxed: read.relaxed, + reducedBreaks: read.reducedBreaks.length, + solver: "cp-sat", + judges: read.judging.source, + }; + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.schedule.save", + resourceId: input.hackathonId, + severity: "warn", + metadata: saved, + }); + + return saved; + }), + + /** + * Whether this server can run CP-SAT itself (JUDGING_CPSAT_PYTHON set and + * cpsat.py present). Without it, CP-SAT is download, run, upload. + */ + cpsatAvailability: isAdmin + .input(z.object({ hackathonId: uuid })) + .query(() => ({ available: cpsatAvailable() })), + + /** + * Runs CP-SAT here on the problem cpsatProblem would download, starting + * from aamoghCPSolver's schedule, and shows the result the way previewCpsat + * does. `solution` is what the script printed: saveCpsat takes it back and + * checks it again, exactly as it does an upload. The script is stopped if + * the request is abandoned (a closed tab) before it ends. + */ + solveCpsat: isAdmin + .input( + z.object({ + hackathonId: uuid, + timing: timingInput, + seconds: z + .number() + .int() + .min(CPSAT_SECONDS.min) + .max(CPSAT_SECONDS.max) + .default(CPSAT_SECONDS.default), + }), + ) + .mutation(async ({ ctx, input, signal }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + assertWindowAhead(input.timing); + if (await findSchedule(db, input.hackathonId)) throw scheduleExists(); + if (!cpsatAvailable()) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: CPSAT_UNAVAILABLE }); + } + + const inventory = await readInventory(db, input.hackathonId); + const { inputs, result, fingerprint } = solveInventory(inventory, input.timing); + const roster = await readRoster(db, input.hackathonId, inventory); + const run = await runCpsat( + cpsatProblem( + inputs, + fingerprint, + result.ok ? result.placements : undefined, + roster.judges, + ), + { seconds: input.seconds, signal }, + ); + if (run.status === "failed") { + throw new TRPCError({ code: "BAD_REQUEST", message: run.reason }); + } + if (run.status === "timedOut") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "CP-SAT ran out of time before finding a schedule. Give it more seconds, or download the problem and run the script with a larger --time-limit.", + }); + } + // A proof of infeasibility is refused here with its reasons; a schedule + // has to pass the same checks as an upload. + const { read, key } = readUpload( + inventory, + input.timing, + run.solution, + roster.judges, + ); + return { + ...describePlan(read, inputs, namesOf(inventory), input.timing), + candidateKey: key, + solverStatus: read.status, + judging: describeJudging(read, roster.judges, inputs.tasks.length), + solution: run.solution, + }; + }), + + /** + * The board: every appointment with its status, the rooms and whether a + * judge is in each, and the presentations that have no appointment (a late + * import, say). + */ + get: isAdmin + .input(z.object({ hackathonId: uuid })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + const now = new Date(); + // Drafts past their deadline count as submitted; there is no worker + // that submits them, so the board does before reading statuses. + await reconcileDueDrafts(db, input.hackathonId, now); + const board = await loadBoard(db, input.hackathonId, now); + const { schedule, names } = board; + const judging = schedule ? await loadJudging(db, board, schedule) : null; + + const planned = new Set(board.plan.tasks.map((t) => taskKey(t.entryId, t.challengeId))); + const booked = new Set( + board.appointments.map((a) => taskKey(a.entryId, a.challengeId)), + ); + const scheduledRooms = new Set(board.plan.rooms.map((r) => r.id)); + // Its judge can be chosen anew: it has not started and nobody has + // begun on it. + const assignable = new Set( + judging?.saved.filter((s) => s.open && !s.removed).map((s) => s.id), + ); + // Deleted, or no longer in this challenge, since the schedule was saved. + const isRemoved = (a: Board["appointments"][number]) => + !!a.entryDeletedAt || !planned.has(taskKey(a.entryId, a.challengeId)); + // Where each assigned judge has got on a presentation that has started. + const progress = judging + ? judgeProgressById( + board.appointments.filter((a) => !isRemoved(a)), + board.evaluations, + judging.drafts, + board.rootIdOf, + now, + ) + : new Map(); + + return { + serverNow: now, + schedule: schedule + ? { + id: schedule.id, + ...timingOf(schedule), + slotMinutes: slotMinutes(timingOf(schedule)), + reducedBreaksAcknowledgedAt: schedule.reducedBreaksAcknowledgedAt, + firstResultAt: schedule.firstResultAt, + createdAt: schedule.createdAt, + canDrop: !schedule.firstResultAt, + } + : null, + rooms: board.inventory.rooms + .filter((r) => !r.archivedAt) + .map((r) => ({ + id: r.id, + name: r.name, + kind: r.kind, + buildingId: r.buildingId, + buildingName: r.buildingName, + challengeId: r.challengeId, + challengeLabel: names.label(r.challengeId), + // Takes appointments: in a building, serving a scheduled challenge. + scheduled: scheduledRooms.has(r.id), + staffed: board.staffed.has(r.id), + })), + appointments: board.appointments.map((a) => ({ + id: a.id, + entryId: a.entryId, + entryTitle: a.entryTitle, + removed: isRemoved(a), + challengeId: a.challengeId, + challengeLabel: names.label(a.challengeId), + roomId: a.roomId, + ...names.room(a.roomId), + startsAt: a.startsAt, + deadlineAt: a.deadlineAt, + endsAt: a.endsAt, + revision: a.revision, + status: a.status, + canMove: a.canMove, + evaluationCount: a.evaluationCount, + assignedJudgeId: a.assignedJudgeId, + assignedJudgeName: a.assignedJudgeId + ? (judging?.roster.names.get(a.assignedJudgeId) ?? "Removed judge") + : null, + canAssignJudge: assignable.has(a.id), + judgeProgress: progress.get(a.id) ?? null, + })), + judging: judging ? describeBoardJudging(judging, board, now) : null, + unscheduled: schedule + ? board.plan.tasks + .filter((t) => !booked.has(taskKey(t.entryId, t.challengeId))) + .map((t) => ({ + entryId: t.entryId, + entryTitle: names.title(t.entryId), + challengeId: t.challengeId, + challengeLabel: names.label(t.challengeId), + })) + : [], + }; + }), + + /** + * Where an appointment could go instead: every future free slot in a + * staffed room of its challenge that keeps the team's breaks, those that + * shorten a walk between buildings last. The first is the suggestion. + */ + moveChoices: isAdmin + .input(z.object({ hackathonId: uuid, appointmentId: uuid })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const now = new Date(); + const board = await loadBoard(db, input.hackathonId, now); + const appointment = board.appointments.find( + (a) => a.id === input.appointmentId, + ); + if (!board.schedule || !appointment) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That appointment no longer exists. Reload the board.", + }); + } + assertMovable(appointment.status); + + const choices = rankChoices( + board, + board.schedule, + appointment, + appointment, + now, + ); + return { + appointment: { + id: appointment.id, + entryId: appointment.entryId, + entryTitle: appointment.entryTitle, + challengeId: appointment.challengeId, + challengeLabel: board.names.label(appointment.challengeId), + roomId: appointment.roomId, + ...board.names.room(appointment.roomId), + startsAt: appointment.startsAt, + endsAt: appointment.endsAt, + revision: appointment.revision, + status: appointment.status, + }, + choices, + smartChoice: choices[0] ?? null, + }; + }), + + /** + * Moves an appointment to one of its choices. `revision` is the one the + * organiser saw: if someone else moved it since, nothing changes. + */ + move: isAdmin + .input( + z.object({ + hackathonId: uuid, + appointmentId: uuid, + revision: z.number().int().min(0), + roomId: uuid, + startsAt: z.date(), + acknowledgeReducedBreaks: z.boolean().default(false), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const stale = () => + new TRPCError({ + code: "CONFLICT", + message: + "Someone else moved this appointment since you opened it. Reload the board to see where it is now, then choose again.", + }); + + const moved = await db.transaction(async (tx) => { + const now = new Date(); + // The schedule row guards every booking in it: moves and assignments + // take turns. + const board = await loadBoard(tx, input.hackathonId, now, true); + const appointment = board.appointments.find( + (a) => a.id === input.appointmentId, + ); + if (!board.schedule || !appointment) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That appointment no longer exists. Reload the board.", + }); + } + if (appointment.revision !== input.revision) throw stale(); + assertMovable(appointment.status); + + const choice = pickChoice( + rankChoices(board, board.schedule, appointment, appointment, now), + input, + timingOf(board.schedule), + ); + + // Its judge stays only if they may grade in the new room and are free + // at the new time; otherwise the organiser assigns judges again. + let clearsJudge = false; + if (appointment.assignedJudgeId) { + const judging = await loadJudging(tx, board, board.schedule); + const judge = judging.roster.judges.find( + (j) => j.id === appointment.assignedJudgeId, + ); + const slot = slotAt(judging.timing, choice.startsAt); + clearsJudge = + !judge || + slot === null || + !mayGrade(judge, { + challengeId: appointment.challengeId, + roomId: choice.roomId, + }) || + judging.saved.some( + (other) => + other.id !== appointment.id && + !other.removed && + other.assignedJudgeId === judge.id && + other.slot === slot, + ); + } + const [updated] = await tx + .update(judgingAppointments) + .set({ + roomId: choice.roomId, + startsAt: choice.startsAt, + deadlineAt: choice.deadlineAt, + endsAt: choice.endsAt, + revision: sql`${judgingAppointments.revision} + 1`, + ...(clearsJudge ? { assignedJudgeId: null } : {}), + }) + .where( + and( + eq(judgingAppointments.id, appointment.id), + eq(judgingAppointments.revision, input.revision), + ), + ) + .returning({ revision: judgingAppointments.revision }); + if (!updated) throw stale(); + + return { + appointmentId: appointment.id, + revision: updated.revision, + fromRoomId: appointment.roomId, + fromStartsAt: appointment.startsAt, + toRoomId: choice.roomId, + toStartsAt: choice.startsAt, + reducedBreaks: choice.reducedBreaks, + clearedJudgeId: clearsJudge ? appointment.assignedJudgeId : null, + }; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.schedule.move", + resourceId: moved.appointmentId, + metadata: { hackathonId: input.hackathonId, ...moved }, + }); + + return moved; + }), + + /** + * Who could grade an appointment instead: the judges who may grade its + * challenge in its room, least loaded first, with those already grading + * something else at that time marked. + */ + judgeChoices: isAdmin + .input(z.object({ hackathonId: uuid, appointmentId: uuid })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const now = new Date(); + const board = await loadBoard(db, input.hackathonId, now); + const appointment = board.appointments.find( + (a) => a.id === input.appointmentId, + ); + if (!board.schedule || !appointment) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That appointment no longer exists. Reload the board.", + }); + } + const judging = await loadJudging(db, board, board.schedule); + const saved = judging.saved.find((s) => s.id === appointment.id); + if (!saved || saved.removed) throw noJudgeNeeded(); + + const titles = new Map(board.appointments.map((a) => [a.id, a.entryTitle])); + const loads = new Map(); + for (const s of judging.saved) { + if (s.assignedJudgeId && !s.removed) { + loads.set(s.assignedJudgeId, (loads.get(s.assignedJudgeId) ?? 0) + 1); + } + } + const kinds = new Map(judging.roster.judges.map((j) => [j.id, j.kind])); + return { + appointment: { + id: appointment.id, + entryTitle: appointment.entryTitle, + challengeLabel: board.names.label(appointment.challengeId), + ...board.names.room(appointment.roomId), + startsAt: appointment.startsAt, + assignedJudgeId: appointment.assignedJudgeId, + canAssignJudge: saved.open, + }, + choices: judgeChoices(judging.roster, saved, judging.saved) + .map((choice) => ({ + judgeId: choice.judgeId, + name: judging.roster.names.get(choice.judgeId) ?? "Removed judge", + kind: kinds.get(choice.judgeId)!, + load: loads.get(choice.judgeId) ?? 0, + bookedWith: choice.bookedWith ? (titles.get(choice.bookedWith) ?? null) : null, + booked: choice.bookedWith !== null, + })) + .sort( + (a, b) => + Number(a.booked) - Number(b.booked) || + a.load - b.load || + a.name.localeCompare(b.name), + ), + }; + }), + + /** + * Sets, or with null clears, the judge of one appointment, until it starts. + * The judge has to be one who may grade it there and is not already + * grading something else at that time. + */ + setAssignedJudge: isAdmin + .input( + z.object({ + hackathonId: uuid, + appointmentId: uuid, + judgeId: uuid.nullable(), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const changed = await db.transaction(async (tx) => { + const now = new Date(); + const board = await loadBoard(tx, input.hackathonId, now, true); + const appointment = board.appointments.find( + (a) => a.id === input.appointmentId, + ); + if (!board.schedule || !appointment) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That appointment no longer exists. Reload the board.", + }); + } + const judging = await loadJudging(tx, board, board.schedule); + const saved = judging.saved.find((s) => s.id === appointment.id); + if (!saved || saved.removed) throw noJudgeNeeded(); + if (!saved.open) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "This presentation has started, or a judge has begun on it, so its judge stays.", + }); + } + + if (input.judgeId) { + const judge = judging.roster.judges.find((j) => j.id === input.judgeId); + if (!judge) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "That judge cannot be assigned. A guest judge needs a room or table of their own.", + }); + } + const name = judging.roster.names.get(judge.id) ?? "That judge"; + if (!mayGrade(judge, saved)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${name} cannot grade ${board.names.label(saved.challengeId)} at ${board.names.room(saved.roomId).roomName}.`, + }); + } + const booked = judgeChoices(judging.roster, saved, judging.saved).find( + (c) => c.judgeId === judge.id, + )?.bookedWith; + if (booked) { + throw new TRPCError({ + code: "CONFLICT", + message: `${name} is already grading ${board.appointments.find((a) => a.id === booked)?.entryTitle ?? "another project"} at that time.`, + }); + } + } + + await tx + .update(judgingAppointments) + .set({ assignedJudgeId: input.judgeId }) + .where(eq(judgingAppointments.id, appointment.id)); + return { + appointmentId: appointment.id, + fromJudgeId: appointment.assignedJudgeId, + toJudgeId: input.judgeId, + }; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.schedule.setJudge", + resourceId: changed.appointmentId, + metadata: { hackathonId: input.hackathonId, ...changed }, + }); + + return changed; + }), + + /** + * Gives every appointment that has not started, and that no judge has + * begun on, a judge again. The others keep theirs. CP-SAT does it when + * this server runs it, otherwise the in-app assigner. Warns of each slot + * where there are too few judges. + */ + assignJudges: isAdmin + .input( + z.object({ + hackathonId: uuid, + seconds: z + .number() + .int() + .min(CPSAT_SECONDS.min) + .max(CPSAT_SECONDS.max) + .default(CPSAT_SECONDS.default), + }), + ) + .mutation(async ({ ctx, input, signal }) => { + const db = ctx.db as DrizzleDB; + await assertHackathonExists(db, input.hackathonId); + + // Worked out before the transaction, so no lock is held while CP-SAT + // runs, and checked against the board again inside it. + const first = await loadBoard(db, input.hackathonId, new Date()); + if (!first.schedule) throw noSchedule(); + const before = await loadJudging(db, first, first.schedule); + if (before.roster.judges.length === 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "No judge can be assigned yet. Judges appear here once they have joined a room or table.", + }); + } + const made = await makeAssignments(before, { + seconds: input.seconds, + signal, + }); + + const result = await db.transaction(async (tx) => { + const now = new Date(); + const board = await loadBoard(tx, input.hackathonId, now, true); + if (!board.schedule) throw noSchedule(); + const judging = await loadJudging(tx, board, board.schedule); + const stale = (a: Judging) => + JSON.stringify([a.saved, a.roster.judges]); + if (stale(judging) !== stale(before)) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Appointments or judges changed while judges were being assigned. Assign judges again.", + }); + } + + const changes = judgeChanges(judging.saved, made.assignments); + // One update per judge, however many appointments they gain. + const byJudge = new Map(); + for (const change of changes) { + const list = byJudge.get(change.assignedJudgeId); + if (list) list.push(change.id); + else byJudge.set(change.assignedJudgeId, [change.id]); + } + for (const [judgeId, ids] of byJudge) { + for (let i = 0; i < ids.length; i += 1000) { + await tx + .update(judgingAppointments) + .set({ assignedJudgeId: judgeId }) + .where(inArray(judgingAppointments.id, ids.slice(i, i + 1000))); + } + } + + const chosen = new Map(changes.map((c) => [c.id, c.assignedJudgeId])); + const saved = judging.saved.map((s) => + chosen.has(s.id) ? { ...s, assignedJudgeId: chosen.get(s.id)! } : s, + ); + const summary = describeBoardJudging( + { + ...judging, + saved, + plan: planOfSaved(board, judging.timing, saved, judging.roster), + }, + board, + now, + ); + return { + solver: made.solver, + changed: changes.length, + kept: judging.plan.keep.length, + assigned: summary.assigned, + unassigned: summary.unassigned, + warnings: summary.warnings, + }; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.schedule.assignJudges", + resourceId: input.hackathonId, + metadata: { + solver: result.solver, + changed: result.changed, + kept: result.kept, + assigned: result.assigned, + unassigned: result.unassigned, + warnings: result.warnings.length, + }, + }); + + return result; + }), + + /** Where a presentation with no appointment yet could go, best first. */ + assignChoices: isAdmin + .input(z.object({ hackathonId: uuid, entryId: uuid, challengeId: uuid })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const now = new Date(); + const board = await loadBoard(db, input.hackathonId, now); + const schedule = assertAssignable(board, input); + const choices = rankChoices(board, schedule, input, null, now); + return { + task: { + entryId: input.entryId, + entryTitle: board.names.title(input.entryId), + challengeId: input.challengeId, + challengeLabel: board.names.label(input.challengeId), + }, + choices, + smartChoice: choices[0] ?? null, + }; + }), + + /** Books a presentation that has no appointment into one of its choices. */ + assign: isAdmin + .input( + z.object({ + hackathonId: uuid, + entryId: uuid, + challengeId: uuid, + roomId: uuid, + startsAt: z.date(), + acknowledgeReducedBreaks: z.boolean().default(false), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const assigned = await db.transaction(async (tx) => { + const now = new Date(); + const board = await loadBoard(tx, input.hackathonId, now, true); + const schedule = assertAssignable(board, input); + const choice = pickChoice( + rankChoices(board, schedule, input, null, now), + input, + timingOf(schedule), + ); + const [appointment] = await tx + .insert(judgingAppointments) + .values({ + scheduleId: schedule.id, + hackathonId: input.hackathonId, + entryId: input.entryId, + challengeId: input.challengeId, + roomId: choice.roomId, + startsAt: choice.startsAt, + deadlineAt: choice.deadlineAt, + endsAt: choice.endsAt, + }) + .returning({ id: judgingAppointments.id }); + + return { + appointmentId: appointment!.id, + entryId: input.entryId, + challengeId: input.challengeId, + roomId: choice.roomId, + startsAt: choice.startsAt, + reducedBreaks: choice.reducedBreaks, + }; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.schedule.assign", + resourceId: assigned.appointmentId, + metadata: { hackathonId: input.hackathonId, ...assigned }, + }); + + return assigned; + }), +}); + +/** The saved schedule, once `task` is known to need an appointment in it. */ +function assertAssignable( + board: Board, + task: { entryId: string; challengeId: string }, +): SavedSchedule { + if (!board.schedule) throw noSchedule(); + const key = taskKey(task.entryId, task.challengeId); + if (!board.plan.tasks.some((t) => taskKey(t.entryId, t.challengeId) === key)) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "That project is not entered in a scheduled challenge, so it has nothing to book.", + }); + } + if (board.appointments.some((a) => taskKey(a.entryId, a.challengeId) === key)) { + throw new TRPCError({ + code: "CONFLICT", + message: "That presentation already has an appointment. Move it instead.", + }); + } + return board.schedule; +} diff --git a/packages/api/src/routers/points/admin.ts b/packages/api/src/routers/points/admin.ts new file mode 100644 index 00000000..3e4f02db --- /dev/null +++ b/packages/api/src/routers/points/admin.ts @@ -0,0 +1,238 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { and, asc, eq, inArray, sql } from "drizzle-orm"; +import { + hackathonEvents, + hackathonEventAttendees, + hackathonParticipants, + hackathonPointAdjustments, + users, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { createTRPCRouter } from "../../trpc"; +import { isAdmin } from "../../middleware/procedures"; +import { recordAdminAction } from "../../middleware/audit"; +import { + fullName, + personName, + rankedLeaderboard, + readBalance, + readLedger, +} from "../../services/points"; + +const participantScope = z.object({ + hackathonId: z.string().uuid("Invalid hackathon ID"), + participantId: z.string().uuid("Invalid participant ID"), +}); + +// Staff tools for points: adjustments, one person's ledger, the whole board, +// and how each event's check-ins came in. Check-in codes live with the events +// (hackathon.generateEventCheckInCode) and manual check-in with the other +// station tools (hackathon.manualEventCheckIn). +export const pointsAdminRouter = createTRPCRouter({ + // Points added or taken away by hand, always with the reason, which the + // hacker sees on their own history. + adjust: isAdmin + .input( + participantScope.extend({ + delta: z + .number() + .int("Adjust by a whole number of points") + .min(-1000, "Adjust by at most 1,000 points at a time") + .max(1000, "Adjust by at most 1,000 points at a time") + .refine((delta) => delta !== 0, "An adjustment of 0 changes nothing"), + reason: z + .string() + .trim() + .min(1, "Say why, so the hacker can see it on their history") + .max(300), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const result = await db.transaction(async (tx) => { + // Locked the way a purchase locks it, so a deduction and a purchase + // for the same person are decided one after the other. + const [participant] = await tx + .select({ id: hackathonParticipants.id }) + .from(hackathonParticipants) + .where( + and( + eq(hackathonParticipants.id, input.participantId), + eq(hackathonParticipants.hackathonId, input.hackathonId), + ), + ) + .for("update"); + + if (!participant) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That person is not registered for this hackathon.", + }); + } + + const [adjustment] = await tx + .insert(hackathonPointAdjustments) + .values({ + hackathonId: input.hackathonId, + participantId: participant.id, + delta: input.delta, + reason: input.reason, + actorId: ctx.userId, + }) + .returning(); + + return { + adjustment: adjustment!, + balance: await readBalance(tx, participant.id), + }; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "points.adjust", + resourceId: input.participantId, + severity: input.delta < 0 ? "warn" : "info", + metadata: { + hackathonId: input.hackathonId, + adjustmentId: result.adjustment.id, + delta: input.delta, + reason: input.reason, + }, + }); + + return result; + }), + + // Everything behind one person's balance, with who did what. + ledger: isAdmin.input(participantScope).query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const participant = await db.query.hackathonParticipants.findFirst({ + where: and( + eq(hackathonParticipants.id, input.participantId), + eq(hackathonParticipants.hackathonId, input.hackathonId), + ), + columns: { + id: true, + firstName: true, + lastName: true, + registrationStatus: true, + }, + with: { user: { columns: { name: true, email: true } } }, + }); + + if (!participant) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That person is not registered for this hackathon.", + }); + } + + const ledger = await readLedger(db, participant.id); + + const actorIds = new Set(); + for (const row of ledger.attendance) { + if (row.checkedInById) actorIds.add(row.checkedInById); + } + for (const row of ledger.adjustments) { + if (row.actorId) actorIds.add(row.actorId); + } + for (const row of ledger.purchases) { + if (row.actorId) actorIds.add(row.actorId); + if (row.voidedById) actorIds.add(row.voidedById); + } + + const actors = + actorIds.size === 0 + ? [] + : await db + .select({ id: users.id, name: users.name, email: users.email }) + .from(users) + .where(inArray(users.id, [...actorIds])); + const actorName = new Map( + actors.map((actor) => [actor.id, actor.name || actor.email]), + ); + const nameOf = (id: string | null) => + id ? (actorName.get(id) ?? null) : null; + + return { + participant: { + id: participant.id, + name: fullName( + personName({ ...participant, accountName: participant.user.name }), + participant.user.email, + ), + email: participant.user.email, + registrationStatus: participant.registrationStatus, + }, + balance: ledger.balance, + attendance: ledger.attendance.map((row) => ({ + ...row, + checkedInByName: nameOf(row.checkedInById), + })), + adjustments: ledger.adjustments.map((row) => ({ + ...row, + actorName: nameOf(row.actorId), + })), + purchases: ledger.purchases.map((row) => ({ + ...row, + actorName: nameOf(row.actorId), + voidedByName: nameOf(row.voidedById), + })), + }; + }), + + // The whole board with full names and spending, for staff. + leaderboard: isAdmin + .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") })) + .query(async ({ ctx, input }) => { + const ranked = await rankedLeaderboard( + ctx.db as DrizzleDB, + input.hackathonId, + ); + + return ranked.map((row) => ({ + rank: row.rank, + participantId: row.id, + name: fullName(row.name, row.email), + email: row.email, + ...row.balance, + })); + }), + + // How each event's check-ins arrived and what they paid out. An event with + // nobody in it still gets a row. + attendance: isAdmin + .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const method = hackathonEventAttendees.method; + + return db + .select({ + eventId: hackathonEvents.id, + name: hackathonEvents.name, + type: hackathonEvents.type, + startTime: hackathonEvents.startTime, + endTime: hackathonEvents.endTime, + points: hackathonEvents.points, + selfCheckIn: hackathonEvents.selfCheckIn, + hasCode: sql`${hackathonEvents.checkInCode} is not null`, + total: sql`count(${hackathonEventAttendees.id})::int`, + byScan: sql`(count(*) filter (where ${method} = 'scan'))::int`, + byCode: sql`(count(*) filter (where ${method} = 'code'))::int`, + byManual: sql`(count(*) filter (where ${method} = 'manual'))::int`, + pointsAwarded: sql`coalesce(sum(${hackathonEventAttendees.pointsAwarded}), 0)::int`, + }) + .from(hackathonEvents) + .leftJoin( + hackathonEventAttendees, + eq(hackathonEventAttendees.eventId, hackathonEvents.id), + ) + .where(eq(hackathonEvents.hackathonId, input.hackathonId)) + .groupBy(hackathonEvents.id) + .orderBy(asc(hackathonEvents.startTime), asc(hackathonEvents.id)); + }), +}); diff --git a/packages/api/src/routers/points/index.ts b/packages/api/src/routers/points/index.ts new file mode 100644 index 00000000..eb1dd586 --- /dev/null +++ b/packages/api/src/routers/points/index.ts @@ -0,0 +1,11 @@ +import { createTRPCRouter } from "../../trpc"; +import { pointsAdminRouter } from "./admin"; +import { pointsStoreRouter } from "./store"; +import { pointsMeRouter } from "./me"; + +// Hackathon points: earned at events, adjusted by staff, spent at the store. +export const pointsRouter = createTRPCRouter({ + admin: pointsAdminRouter, + store: pointsStoreRouter, + me: pointsMeRouter, +}); diff --git a/packages/api/src/routers/points/me.ts b/packages/api/src/routers/points/me.ts new file mode 100644 index 00000000..2e665a7a --- /dev/null +++ b/packages/api/src/routers/points/me.ts @@ -0,0 +1,328 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { and, asc, eq } from "drizzle-orm"; +import { + hackathons, + hackathonEvents, + hackathonParticipants, + hackathonStoreItems, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { createTRPCRouter, protectedProcedure } from "../../trpc"; +import { notHackathonBanned } from "../../middleware/procedures"; +import { rateLimit } from "../../middleware/security"; +import { flaggingRuleBreaks } from "../../services/check-in-flags"; +import { + isSoldOut, + leaderboardName, + normalizeCheckInCode, + pointsShort, + rankedLeaderboard, + readBalance, + readLedger, + recordEventAttendance, + selfCheckInWindow, +} from "../../services/points"; + +const scope = z.object({ + hackathonId: z.string().uuid("Invalid hackathon ID"), +}); + +// Points belong to a registration, not to the account, so every read starts +// from the caller's own participant row for this edition. Nothing here takes +// a participant id from the client. +async function myParticipant( + db: DrizzleDB, + userId: string, + hackathonId: string, +) { + const participant = await db.query.hackathonParticipants.findFirst({ + where: and( + eq(hackathonParticipants.hackathonId, hackathonId), + eq(hackathonParticipants.userId, userId), + ), + columns: { id: true, registrationStatus: true }, + }); + + if (!participant) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "You are not registered for this hackathon.", + }); + } + + return participant; +} + +// Pending, waitlisted and rejected registrations have nothing to show yet. +function assertAccepted(participant: { registrationStatus: string }) { + if ( + participant.registrationStatus !== "approved" && + participant.registrationStatus !== "checked_in" + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Points open once your registration is accepted.", + }); + } +} + +export const pointsMeRouter = createTRPCRouter({ + // Balance and every row behind it. Staff names and void reasons stay on the + // staff side. + summary: protectedProcedure.input(scope).query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const participant = await myParticipant(db, ctx.userId, input.hackathonId); + assertAccepted(participant); + + const ledger = await readLedger(db, participant.id); + + return { + checkedIn: participant.registrationStatus === "checked_in", + balance: ledger.balance, + events: ledger.attendance.map((row) => ({ + id: row.id, + eventId: row.eventId, + eventName: row.eventName, + eventStartTime: row.eventStartTime, + checkedInAt: row.checkedInAt, + points: row.pointsAwarded ?? 0, + method: row.method, + })), + adjustments: ledger.adjustments.map((row) => ({ + id: row.id, + delta: row.delta, + reason: row.reason, + createdAt: row.createdAt, + })), + purchases: ledger.purchases.map((row) => ({ + id: row.id, + itemName: row.itemName, + quantity: row.quantity, + unitPrice: row.unitPrice, + total: row.total, + createdAt: row.createdAt, + voidedAt: row.voidedAt, + })), + }; + }), + + // The top of the board and where the caller stands on it. Other hackers + // appear as "First L." with no ids. + leaderboard: protectedProcedure + .input( + scope.extend({ limit: z.number().int().min(1).max(100).default(10) }), + ) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const participant = await myParticipant( + db, + ctx.userId, + input.hackathonId, + ); + assertAccepted(participant); + + const ranked = await rankedLeaderboard(db, input.hackathonId); + const mine = ranked.find((row) => row.id === participant.id); + + return { + entries: ranked.slice(0, input.limit).map((row) => ({ + rank: row.rank, + name: leaderboardName(row.name), + earned: row.earned, + isMe: row.id === participant.id, + })), + // Null until the caller is checked in: only arrivals are ranked. + me: mine ? { rank: mine.rank, earned: mine.earned } : null, + ranked: ranked.length, + }; + }), + + // Store status and pickup location for every accepted hacker; the catalog + // only once they are checked in and the organisers have revealed it. + store: protectedProcedure.input(scope).query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const participant = await myParticipant(db, ctx.userId, input.hackathonId); + assertAccepted(participant); + + const hackathon = await db.query.hackathons.findFirst({ + where: eq(hackathons.id, input.hackathonId), + columns: { + storeCatalogVisible: true, + storeOpen: true, + storeLocation: true, + }, + }); + + if (!hackathon) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hackathon not found", + }); + } + + const checkedIn = participant.registrationStatus === "checked_in"; + const status = { + catalogVisible: hackathon.storeCatalogVisible, + open: hackathon.storeOpen, + location: hackathon.storeLocation, + checkedIn, + }; + + if (!checkedIn || !hackathon.storeCatalogVisible) { + return { ...status, available: null, items: [] }; + } + + const [balance, items] = await Promise.all([ + readBalance(db, participant.id), + db + .select({ + id: hackathonStoreItems.id, + name: hackathonStoreItems.name, + description: hackathonStoreItems.description, + price: hackathonStoreItems.price, + stock: hackathonStoreItems.stock, + soldOut: hackathonStoreItems.soldOut, + }) + .from(hackathonStoreItems) + .where( + and( + eq(hackathonStoreItems.hackathonId, input.hackathonId), + eq(hackathonStoreItems.archived, false), + ), + ) + .orderBy( + asc(hackathonStoreItems.displayOrder), + asc(hackathonStoreItems.name), + asc(hackathonStoreItems.id), + ), + ]); + + return { + ...status, + available: balance.available, + items: items.map((item) => ({ + id: item.id, + name: item.name, + description: item.description, + price: item.price, + // Shown only where somebody is counting. + stock: item.stock, + soldOut: isSoldOut(item), + pointsShort: pointsShort(item.price, balance.available), + })), + }; + }), + + // Self check-in with the code shown at the event. Every refusal says what + // to do instead, since the hacker is standing in a doorway reading it. Behind + // the ban gate because it writes: a banned hacker who kept their + // registration could otherwise keep collecting points. The reads above stay + // open, as with the rest of the API. + checkIn: notHackathonBanned + .input(scope.extend({ code: z.string().max(32) })) + .mutation(async ({ ctx, input }) => { + // There are 31^6 codes; this keeps anyone from walking the space. Per + // user, because a whole venue shares one address. + const limit = rateLimit(`points-code:${ctx.userId}`, 10, 0.1); + if (!limit.allowed) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: `Too many codes tried. Wait ${limit.retryAfter ?? 60} seconds, then check the code on the screen.`, + }); + } + + const code = normalizeCheckInCode(input.code); + if (!code) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Event codes are 6 letters and numbers. Check the code on the screen and try again.", + }); + } + + const db = ctx.db as DrizzleDB; + const participant = await myParticipant( + db, + ctx.userId, + input.hackathonId, + ); + + // Before the code lookup, so somebody who is not here cannot use this + // to learn which codes are live. + if (participant.registrationStatus !== "checked_in") { + throw new TRPCError({ + code: "FORBIDDEN", + message: + participant.registrationStatus === "approved" + ? "Check in at the registration desk first. Event codes work once you have arrived." + : "Only checked-in hackers can collect event points.", + }); + } + + const event = await db.query.hackathonEvents.findFirst({ + where: and( + eq(hackathonEvents.hackathonId, input.hackathonId), + eq(hackathonEvents.checkInCode, code), + ), + columns: { + id: true, + name: true, + type: true, + points: true, + startTime: true, + endTime: true, + selfCheckIn: true, + }, + }); + + if (!event) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "No event here uses that code. Check the code on the screen and try again.", + }); + } + + if (!event.selfCheckIn) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `${event.name} is not taking codes right now. Ask staff at the door to check you in.`, + }); + } + + const window = selfCheckInWindow(event, new Date()); + if (window === "early") { + throw new TRPCError({ + code: "FORBIDDEN", + message: `Check-in for ${event.name} opens 15 minutes before it starts.`, + }); + } + if (window === "ended") { + throw new TRPCError({ + code: "FORBIDDEN", + message: `${event.name} has ended, so its code no longer works.`, + }); + } + + // A refusal for the check-in rules is recorded for organisers, on the + // pool and outside the transaction it rolled back. The hacker sees only + // the refusal. + const { pointsAwarded } = await flaggingRuleBreaks( + db, + input.hackathonId, + () => + recordEventAttendance(db, { + event, + participant: { id: participant.id, name: "You" }, + method: "code", + actorId: ctx.userId, + }), + ); + + // The public schedule shows how many people came. + ctx.cache.delete(`hackathon:${input.hackathonId}:events`); + + return { eventName: event.name, pointsAwarded }; + }), +}); diff --git a/packages/api/src/routers/points/store.ts b/packages/api/src/routers/points/store.ts new file mode 100644 index 00000000..9e237d06 --- /dev/null +++ b/packages/api/src/routers/points/store.ts @@ -0,0 +1,752 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { and, asc, desc, eq, sql } from "drizzle-orm"; +import { + hackathons, + hackathonParticipants, + hackathonStoreItems, + hackathonStorePurchases, + users, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { createTRPCRouter } from "../../trpc"; +import { isAdmin, isScanner } from "../../middleware/procedures"; +import { recordAdminAction } from "../../middleware/audit"; +import { CacheKeys } from "../../middleware/cache"; +import { isUniqueViolation } from "../../middleware/db-errors"; +import { + balancesFor, + canSupply, + fullName, + personName, + readBalance, + searchParticipants, +} from "../../services/points"; + +const hackathonScope = z.object({ + hackathonId: z.string().uuid("Invalid hackathon ID"), +}); + +const itemScope = hackathonScope.extend({ + itemId: z.string().uuid("Invalid item ID"), +}); + +const MAX_POINTS = 100_000; + +const itemName = z + .string() + .trim() + .min(1, "Give the item a name") + .max(120, "Keep the name under 120 characters"); +const itemDescription = z.string().trim().max(2000).nullish(); +const itemPrice = z + .number() + .int("Prices are whole points") + .min(0, "A price cannot be negative") + .max(MAX_POINTS); +// Null means nobody is counting; the soldOut switch then says when it is gone. +const itemStock = z + .number() + .int("Stock is a whole number") + .min(0, "Stock cannot be negative") + .max(MAX_POINTS) + .nullable(); +const itemOrder = z.number().int().min(0).max(10_000); + +// Blank means none, so clearing the field clears the column. +const blankToNull = (value: string | null | undefined) => + value === undefined ? undefined : value || null; + +const staleItem = (name: string) => + new TRPCError({ + code: "CONFLICT", + message: `Someone changed ${name} since you opened it. Reload to see their changes, then edit again.`, + }); + +export const pointsStoreRouter = createTRPCRouter({ + settings: isAdmin.input(hackathonScope).query(async ({ ctx, input }) => { + const hackathon = await (ctx.db as DrizzleDB).query.hackathons.findFirst({ + where: eq(hackathons.id, input.hackathonId), + columns: { + storeCatalogVisible: true, + storeOpen: true, + storeLocation: true, + }, + }); + + if (!hackathon) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hackathon not found", + }); + } + + return { + catalogVisible: hackathon.storeCatalogVisible, + open: hackathon.storeOpen, + location: hackathon.storeLocation, + }; + }), + + // Visibility and open are separate on purpose: a catalog can go up the + // night before so hackers can plan, while the table opens on Sunday. + // Closed is a note for hackers, not a lock; staff can still record a sale. + updateSettings: isAdmin + .input( + hackathonScope.extend({ + catalogVisible: z.boolean().optional(), + open: z.boolean().optional(), + location: z.string().trim().max(240).nullish(), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const [updated] = await db + .update(hackathons) + .set({ + storeCatalogVisible: input.catalogVisible, + storeOpen: input.open, + storeLocation: blankToNull(input.location), + updatedAt: new Date(), + }) + .where(eq(hackathons.id, input.hackathonId)) + .returning({ + catalogVisible: hackathons.storeCatalogVisible, + open: hackathons.storeOpen, + location: hackathons.storeLocation, + }); + + if (!updated) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hackathon not found", + }); + } + + // getById serves the hackathon row from cache, store columns included. + ctx.cache.delete(CacheKeys.hackathon(input.hackathonId)); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "points.store.updateSettings", + resourceId: input.hackathonId, + metadata: updated, + }); + + return updated; + }), + + // The catalog as the table sees it. Archived items only on request: they + // are out of the catalog but their purchases still point at them. + items: isScanner + .input( + hackathonScope.extend({ includeArchived: z.boolean().default(false) }), + ) + .query(async ({ ctx, input }) => { + return (ctx.db as DrizzleDB) + .select() + .from(hackathonStoreItems) + .where( + and( + eq(hackathonStoreItems.hackathonId, input.hackathonId), + input.includeArchived + ? undefined + : eq(hackathonStoreItems.archived, false), + ), + ) + .orderBy( + asc(hackathonStoreItems.archived), + asc(hackathonStoreItems.displayOrder), + asc(hackathonStoreItems.name), + asc(hackathonStoreItems.id), + ); + }), + + createItem: isAdmin + .input( + hackathonScope.extend({ + name: itemName, + description: itemDescription, + price: itemPrice, + stock: itemStock, + soldOut: z.boolean().default(false), + displayOrder: itemOrder.default(0), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const hackathon = await db.query.hackathons.findFirst({ + where: eq(hackathons.id, input.hackathonId), + columns: { id: true }, + }); + + if (!hackathon) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hackathon not found", + }); + } + + const [item] = await db + .insert(hackathonStoreItems) + .values({ + hackathonId: input.hackathonId, + name: input.name, + description: blankToNull(input.description), + price: input.price, + stock: input.stock, + // The switch means nothing while stock is counted. + soldOut: input.stock === null && input.soldOut, + displayOrder: input.displayOrder, + }) + .returning(); + + return item!; + }), + + // Partial edits, guarded by the revision the form was opened at. A sale + // bumps the revision too, so a stale form cannot write its old stock count + // over a decrement that happened while it was open. + updateItem: isAdmin + .input( + itemScope.extend({ + revision: z.number().int().min(0), + name: itemName.optional(), + description: itemDescription, + price: itemPrice.optional(), + stock: itemStock.optional(), + soldOut: z.boolean().optional(), + archived: z.boolean().optional(), + displayOrder: itemOrder.optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const current = await db.query.hackathonStoreItems.findFirst({ + where: and( + eq(hackathonStoreItems.id, input.itemId), + eq(hackathonStoreItems.hackathonId, input.hackathonId), + ), + }); + + if (!current) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That item is no longer in the store.", + }); + } + + if (current.revision !== input.revision) throw staleItem(current.name); + + const stock = input.stock === undefined ? current.stock : input.stock; + + const [updated] = await db + .update(hackathonStoreItems) + .set({ + name: input.name, + description: blankToNull(input.description), + price: input.price, + stock, + soldOut: stock === null && (input.soldOut ?? current.soldOut), + archived: input.archived, + displayOrder: input.displayOrder, + revision: sql`${hackathonStoreItems.revision} + 1`, + updatedAt: new Date(), + }) + .where( + and( + eq(hackathonStoreItems.id, current.id), + eq(hackathonStoreItems.revision, input.revision), + ), + ) + .returning(); + + // Someone saved, or something sold, between the read and the write. + if (!updated) throw staleItem(current.name); + + return updated; + }), + + // Only an item nobody has bought. Once there are purchases, archiving takes + // it out of the catalog and keeps the link a restock needs. + deleteItem: isAdmin.input(itemScope).mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const item = await db.transaction(async (tx) => { + // Locked as a sale locks it, so a sale in flight finishes before the + // count below is taken. + const [locked] = await tx + .select({ id: hackathonStoreItems.id, name: hackathonStoreItems.name }) + .from(hackathonStoreItems) + .where( + and( + eq(hackathonStoreItems.id, input.itemId), + eq(hackathonStoreItems.hackathonId, input.hackathonId), + ), + ) + .for("update"); + + if (!locked) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That item is no longer in the store.", + }); + } + + const [sales] = await tx + .select({ count: sql`count(*)::int` }) + .from(hackathonStorePurchases) + .where(eq(hackathonStorePurchases.itemId, locked.id)); + + const sold = sales?.count ?? 0; + if (sold > 0) { + throw new TRPCError({ + code: "CONFLICT", + message: `${locked.name} is on ${sold} purchase${sold === 1 ? "" : "s"}. Archive it instead; that takes it out of the catalog and keeps its history.`, + }); + } + + await tx + .delete(hackathonStoreItems) + .where(eq(hackathonStoreItems.id, locked.id)); + + return locked; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "points.store.deleteItem", + resourceId: item.id, + metadata: { hackathonId: input.hackathonId, name: item.name }, + }); + + return { success: true }; + }), + + // Finding the buyer by name or email, with what they can spend. + searchParticipants: isScanner + .input(hackathonScope.extend({ query: z.string().trim().min(1).max(120) })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const matches = await searchParticipants(db, { + hackathonId: input.hackathonId, + query: input.query, + limit: 20, + }); + + const balanceOf = await balancesFor( + db, + input.hackathonId, + matches.map((match) => match.id), + ); + + return matches.map((match) => ({ + ...match, + balance: balanceOf(match.id), + })); + }), + + // The buyer from a scanned pass, which carries only their participant id. + lookupParticipant: isScanner + .input( + hackathonScope.extend({ + participantId: z.string().uuid("Invalid participant ID"), + }), + ) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const participant = await db.query.hackathonParticipants.findFirst({ + where: and( + eq(hackathonParticipants.id, input.participantId), + eq(hackathonParticipants.hackathonId, input.hackathonId), + ), + columns: { + id: true, + firstName: true, + lastName: true, + registrationStatus: true, + }, + with: { user: { columns: { name: true, email: true } } }, + }); + + if (!participant) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "That pass is not for this hackathon. Search for the person by name instead.", + }); + } + + return { + id: participant.id, + name: fullName( + personName({ ...participant, accountName: participant.user.name }), + participant.user.email, + ), + email: participant.user.email, + registrationStatus: participant.registrationStatus, + balance: await readBalance(db, participant.id), + }; + }), + + // One sale at the table. The id comes from the station's browser and is + // the idempotency key: a retry after a dropped response finds the sale it + // already made instead of charging twice. + purchase: isScanner + .input( + hackathonScope.extend({ + purchaseId: z.string().uuid("Invalid purchase ID"), + participantId: z.string().uuid("Invalid participant ID"), + itemId: z.string().uuid("Invalid item ID"), + quantity: z + .number() + .int() + .min(1, "Buy at least one") + .max(20, "At most 20 of one item per sale"), + // The price the station showed. A different price now means someone + // edited the item, and the buyer should see the new total first. + unitPrice: itemPrice, + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + try { + return await db.transaction(async (tx) => { + // Participant first, then item. Two sales to one person, or a sale + // and a deduction, queue here and each reads the balance the other + // left behind; that is what stops two stations overspending it. + const [participant] = await tx + .select({ + id: hackathonParticipants.id, + registrationStatus: hackathonParticipants.registrationStatus, + firstName: hackathonParticipants.firstName, + lastName: hackathonParticipants.lastName, + accountName: users.name, + email: users.email, + }) + .from(hackathonParticipants) + .innerJoin(users, eq(users.id, hackathonParticipants.userId)) + .where( + and( + eq(hackathonParticipants.id, input.participantId), + eq(hackathonParticipants.hackathonId, input.hackathonId), + ), + ) + .for("update", { of: hackathonParticipants }); + + if (!participant) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That person is not registered for this hackathon.", + }); + } + + const [existing] = await tx + .select() + .from(hackathonStorePurchases) + .where(eq(hackathonStorePurchases.id, input.purchaseId)); + + if (existing) { + const sameSale = + existing.hackathonId === input.hackathonId && + existing.participantId === input.participantId && + existing.itemId === input.itemId && + existing.quantity === input.quantity && + existing.unitPrice === input.unitPrice; + if (!sameSale) { + throw new TRPCError({ + code: "CONFLICT", + message: + "This sale's id was already used for a different sale. Start the sale again.", + }); + } + return { + purchase: existing, + replayed: true, + balance: await readBalance(tx, participant.id), + }; + } + + const name = fullName(personName(participant), participant.email); + + if (participant.registrationStatus !== "checked_in") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `${name} has not checked in at the registration desk, so they cannot spend points yet.`, + }); + } + + const [item] = await tx + .select() + .from(hackathonStoreItems) + .where( + and( + eq(hackathonStoreItems.id, input.itemId), + eq(hackathonStoreItems.hackathonId, input.hackathonId), + ), + ) + .for("update"); + + if (!item || item.archived) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "That item is no longer in the store. Reload the catalog.", + }); + } + + if (item.price !== input.unitPrice) { + throw new TRPCError({ + code: "CONFLICT", + message: `${item.name} now costs ${item.price} points. Check the new total with the buyer and try again.`, + }); + } + + if (!canSupply(item, input.quantity)) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + item.stock === null || item.stock === 0 + ? `${item.name} is sold out.` + : `Only ${item.stock} ${item.name} left.`, + }); + } + + const total = item.price * input.quantity; + const balance = await readBalance(tx, participant.id); + + if (total > balance.available) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `${name} has ${balance.available} points to spend; this costs ${total}.`, + }); + } + + const [purchase] = await tx + .insert(hackathonStorePurchases) + .values({ + id: input.purchaseId, + hackathonId: input.hackathonId, + participantId: participant.id, + participantName: name, + itemId: item.id, + itemName: item.name, + unitPrice: item.price, + quantity: input.quantity, + total, + stockTracked: item.stock !== null, + actorId: ctx.userId, + }) + .returning(); + + if (item.stock !== null) { + await tx + .update(hackathonStoreItems) + .set({ + stock: item.stock - input.quantity, + revision: sql`${hackathonStoreItems.revision} + 1`, + updatedAt: new Date(), + }) + .where(eq(hackathonStoreItems.id, item.id)); + } + + return { + purchase: purchase!, + replayed: false, + // Affordable means earned - spent covered the total, so this + // stays exact without the clamp. + balance: { + earned: balance.earned, + spent: balance.spent + total, + available: balance.available - total, + }, + }; + }); + } catch (error) { + // The same id sent for a different buyer: their locks do not collide, + // so the primary key is what catches it. + if (isUniqueViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: + "This sale's id was already used for a different sale. Start the sale again.", + }); + } + throw error; + } + }), + + // Undoes a sale and gives the points back. The row stays, marked voided, + // so history shows what happened. Voiding twice changes nothing. + voidPurchase: isAdmin + .input( + hackathonScope.extend({ + purchaseId: z.string().uuid("Invalid purchase ID"), + // Put the items back on the shelf count. Only possible when stock was + // counted at the sale and still is. + restock: z.boolean().default(false), + reason: z + .string() + .trim() + .min(1, "Say why the sale is being voided") + .max(300), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const outcome = await db.transaction(async (tx) => { + const [purchase] = await tx + .select() + .from(hackathonStorePurchases) + .where( + and( + eq(hackathonStorePurchases.id, input.purchaseId), + eq(hackathonStorePurchases.hackathonId, input.hackathonId), + ), + ) + .for("update"); + + if (!purchase) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That sale does not exist.", + }); + } + + if (purchase.voidedAt) return { purchase, changed: false }; + + if (input.restock) { + const [item] = purchase.itemId + ? await tx + .select() + .from(hackathonStoreItems) + .where(eq(hackathonStoreItems.id, purchase.itemId)) + .for("update") + : []; + + if (!purchase.stockTracked || !item || item.stock === null) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "This sale cannot go back on the shelf count: stock was not being counted when it was sold, or is not now. Void it without restocking.", + }); + } + + await tx + .update(hackathonStoreItems) + .set({ + stock: item.stock + purchase.quantity, + revision: sql`${hackathonStoreItems.revision} + 1`, + updatedAt: new Date(), + }) + .where(eq(hackathonStoreItems.id, item.id)); + } + + const [voided] = await tx + .update(hackathonStorePurchases) + .set({ + voidedAt: new Date(), + voidedById: ctx.userId, + voidReason: input.reason, + restocked: input.restock, + }) + .where(eq(hackathonStorePurchases.id, purchase.id)) + .returning(); + + return { purchase: voided!, changed: true }; + }); + + if (outcome.changed) { + await recordAdminAction(db, { + userId: ctx.userId, + action: "points.store.voidPurchase", + resourceId: input.purchaseId, + severity: "warn", + metadata: { + hackathonId: input.hackathonId, + participantId: outcome.purchase.participantId, + itemName: outcome.purchase.itemName, + total: outcome.purchase.total, + restocked: input.restock, + reason: input.reason, + }, + }); + } + + return { purchase: outcome.purchase, alreadyVoided: !outcome.changed }; + }), + + // Sales, newest first, for the whole store or one buyer. + purchases: isScanner + .input( + hackathonScope.extend({ + participantId: z.string().uuid("Invalid participant ID").optional(), + limit: z.number().int().min(1).max(100).default(50), + offset: z.number().int().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const where = and( + eq(hackathonStorePurchases.hackathonId, input.hackathonId), + input.participantId + ? eq(hackathonStorePurchases.participantId, input.participantId) + : undefined, + ); + + const [rows, [totals]] = await Promise.all([ + db + .select({ + id: hackathonStorePurchases.id, + participantId: hackathonStorePurchases.participantId, + participantName: hackathonStorePurchases.participantName, + itemId: hackathonStorePurchases.itemId, + itemName: hackathonStorePurchases.itemName, + unitPrice: hackathonStorePurchases.unitPrice, + quantity: hackathonStorePurchases.quantity, + total: hackathonStorePurchases.total, + stockTracked: hackathonStorePurchases.stockTracked, + createdAt: hackathonStorePurchases.createdAt, + voidedAt: hackathonStorePurchases.voidedAt, + voidReason: hackathonStorePurchases.voidReason, + restocked: hackathonStorePurchases.restocked, + actorName: users.name, + itemStock: hackathonStoreItems.stock, + }) + .from(hackathonStorePurchases) + .leftJoin(users, eq(users.id, hackathonStorePurchases.actorId)) + .leftJoin( + hackathonStoreItems, + eq(hackathonStoreItems.id, hackathonStorePurchases.itemId), + ) + .where(where) + .orderBy( + desc(hackathonStorePurchases.createdAt), + desc(hackathonStorePurchases.id), + ) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(hackathonStorePurchases) + .where(where), + ]); + + return { + purchases: rows.map(({ itemStock, ...row }) => ({ + ...row, + // Whether a void could also restock, so the button can say so. + canRestock: !row.voidedAt && row.stockTracked && itemStock !== null, + })), + matching: totals?.count ?? 0, + }; + }), +}); diff --git a/packages/api/src/routers/team.ts b/packages/api/src/routers/team.ts index 0e036a1e..c3a9f19a 100644 --- a/packages/api/src/routers/team.ts +++ b/packages/api/src/routers/team.ts @@ -7,7 +7,7 @@ import { hackathonParticipants, hackathonProjects, hackathons, - judgingProjects, + judgingEntries, } from "@query/db"; import { eq, and, or, isNull, isNotNull, inArray, lt, sql } from "drizzle-orm"; import { VOLATILE_TTL } from "../middleware/cache"; @@ -664,6 +664,48 @@ export const teamRouter = createTRPCRouter({ } }), + // Lets the captain stop new joins without disbanding. joinTeam already + // refuses a closed team; this is the only writer of the flag. + setOpen: protectedProcedure + .input( + z.object({ + hackathonId: z.string().uuid("Invalid hackathon ID"), + teamId: z.string().uuid("Invalid team ID"), + isOpen: z.boolean(), + }), + ) + .mutation(async ({ ctx, input }) => { + await checkTeamEditWindow(ctx.db as DrizzleDB, input.hackathonId); + + const db = ctx.db as NonNullable; + const team = await db.query.hackathonTeams.findFirst({ + where: and( + eq(hackathonTeams.id, input.teamId), + eq(hackathonTeams.hackathonId, input.hackathonId), + ), + }); + + if (!team) + throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); + if (team.captainId !== (ctx.userId as string)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the captain can open or close the team.", + }); + } + + await db + .update(hackathonTeams) + .set({ isOpen: input.isOpen, updatedAt: new Date() }) + .where(eq(hackathonTeams.id, team.id)); + + // Not in CACHE_INVALIDATION_MAP, so the namespace fallback would sweep + // `team:*` and leave the cached roster list showing the old flag. + ctx.cache.delete(`hackathon:${input.hackathonId}:teams`); + + return { success: true }; + }), + submitProject: notHackathonBanned .input( z.object({ @@ -681,8 +723,8 @@ export const teamRouter = createTRPCRouter({ technologies: z.array(z.string().max(60)).max(30).optional(), tracks: z.array(z.string().max(100)).max(20).optional(), challenges: z.array(z.string().max(100)).max(20).optional(), - // Judge routing filters on exactly this, and nothing else ever set it — every - // CreateX judge got an empty pool. + // Promoting submissions into judging files the project under the CreateX + // challenge from this flag. isCreateX: z.boolean().optional(), githubUrl: z .string() @@ -743,10 +785,10 @@ export const teamRouter = createTRPCRouter({ }); } - // Judge routing matches on these strings, so only what this hackathon - // offers is kept. Dropped rather than refused: the form pre-fills a saved - // project's tracks but only shows current ones, so a track removed since - // could not be unticked and every edit would fail. + // Promotion into judging matches these strings to challenges by label, so + // only what this hackathon offers is kept. Dropped rather than refused: + // the form pre-fills a saved project's tracks but only shows current ones, + // so a track removed since could not be unticked and every edit would fail. const offered = (allowed: string[] | null, picked?: string[]) => picked && allowed?.length ? picked.filter((value) => allowed.includes(value)) @@ -798,15 +840,15 @@ export const teamRouter = createTRPCRouter({ throw new TRPCError({ code: "FORBIDDEN", message: - "Project edits are closed. Devposts must be final 34 hours after the hacking starts.", + "Project edits are closed. Submissions must be final 34 hours after the hacking starts.", }); } // An organiser pulled it: re-saving would put it back in the gallery. - // The column covers a pull before judging; the judging row covers - // pulls made before the column existed (a team can only withdraw - // before judging has the project, so a withdrawn judging entry means - // an organiser did it). + // The column covers a pull made from the admin projects screen; a + // removed judging entry covers one made from judging (a team can + // only withdraw before judging has the project, so a removed + // judging entry means an organiser did it). if (existingProject?.withdrawnByAdminAt) { throw new TRPCError({ code: "FORBIDDEN", @@ -815,10 +857,10 @@ export const teamRouter = createTRPCRouter({ }); } if (existingProject) { - const pulled = await tx.query.judgingProjects.findFirst({ + const pulled = await tx.query.judgingEntries.findFirst({ where: and( - eq(judgingProjects.sourceProjectId, existingProject.id), - isNotNull(judgingProjects.withdrawnAt), + eq(judgingEntries.sourceProjectId, existingProject.id), + isNotNull(judgingEntries.deletedAt), ), columns: { id: true }, }); @@ -1000,8 +1042,20 @@ export const teamRouter = createTRPCRouter({ } // Once judging has it, withdrawing would pull a project out from under scores - // that already reference it. - if (project.status === "judging" || project.status === "winner") { + // that already reference it. Judging has it when the status says so or + // when it was promoted into a judging entry that is still live. + const inJudging = await tx.query.judgingEntries.findFirst({ + where: and( + eq(judgingEntries.sourceProjectId, project.id), + isNull(judgingEntries.deletedAt), + ), + columns: { id: true }, + }); + if ( + project.status === "judging" || + project.status === "winner" || + inJudging + ) { throw new TRPCError({ code: "FORBIDDEN", message: @@ -1140,17 +1194,17 @@ export const teamRouter = createTRPCRouter({ }); if (!project) return null; - // Judges are sent to a table number; the team is the one party that was - // never told it. Null until the project is promoted to judging. - const judging = await db.query.judgingProjects.findFirst({ + // Whether the project made it into judging, so the submit page can send + // the team to their judging times instead of leaving them guessing. + const judging = await db.query.judgingEntries.findFirst({ where: and( - eq(judgingProjects.sourceProjectId, project.id), - isNull(judgingProjects.withdrawnAt), + eq(judgingEntries.sourceProjectId, project.id), + isNull(judgingEntries.deletedAt), ), - columns: { tableNumber: true }, + columns: { id: true }, }); - return { ...project, tableNumber: judging?.tableNumber ?? null }; + return { ...project, inJudging: !!judging }; }), // Every project the caller owns, across hackathons. Reading off the team diff --git a/packages/api/src/services/check-in-flags.test.ts b/packages/api/src/services/check-in-flags.test.ts new file mode 100644 index 00000000..df1afb34 --- /dev/null +++ b/packages/api/src/services/check-in-flags.test.ts @@ -0,0 +1,817 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { PgDialect, getTableConfig } from "drizzle-orm/pg-core"; +import { + hackathonCheckInFlags, + hackathonEvents, + judgingEntries, + judgingEntryMembers, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { + checkInFlagsForEntries, + entriesForFlags, + flaggedEntryIds, + flaggingRuleBreaks, + openCheckInFlagCount, + recordCheckInFlag, +} from "./check-in-flags"; +import { CheckInRuleBreak } from "./points"; +import type { CheckInRuleBreakDetails } from "./points"; + +const HACK = "6f1d2c3b-4a5e-4f60-8a7b-9c0d1e2f3a4b"; +const AT = new Date("2027-02-06T18:00:00Z"); + +const breach: CheckInRuleBreakDetails = { + participantId: "part-1", + eventId: "event-2", + conflictEventId: "event-1", + rule: "overlap", + method: "code", + actorId: "user-1", + at: AT, +}; + +const render = (condition: unknown) => + new PgDialect().sqlToQuery(condition as never); + +// --------------------------------------------------------------------------- +// The table + +describe("hackathonCheckInFlags", () => { + const foreignKey = (name: string) => + getTableConfig(hackathonCheckInFlags).foreignKeys.find( + (key) => key.getName() === name, + )!; + + // Deleting an event must not delete the record that someone broke the rules + // at it: prize review reads these long after a session is cancelled. + it.each([ + ["event_id", "check_in_flag_event_fk", hackathonCheckInFlags.eventId], + [ + "conflict_event_id", + "check_in_flag_conflict_event_fk", + hackathonCheckInFlags.conflictEventId, + ], + ])( + "keeps the flag and clears %s when that event is deleted", + (_, name, column) => { + const key = foreignKey(name); + expect(key.reference().foreignTable).toBe(hackathonEvents); + expect(key.onDelete).toBe("set null"); + expect(column.notNull).toBe(false); + }, + ); + + it("still goes with its participant", () => { + const key = foreignKey("check_in_flag_participant_fk"); + expect(key.onDelete).toBe("cascade"); + expect(hackathonCheckInFlags.participantId.notNull).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Writing + +describe("recordCheckInFlag", () => { + const fakeInsertDb = () => { + const writes: { + table: unknown; + values: Record; + conflict: { target: unknown[]; set: Record }; + }[] = []; + const db = { + insert: (table: unknown) => ({ + values: (values: Record) => ({ + onConflictDoUpdate: async (conflict: { + target: unknown[]; + set: Record; + }) => { + writes.push({ table, values, conflict }); + }, + }), + }), + }; + return { db: db as unknown as DrizzleDB, writes }; + }; + + it("inserts one row for the person, the event tried and the event they were in", async () => { + const { db, writes } = fakeInsertDb(); + await recordCheckInFlag(db, HACK, breach); + + expect(writes).toHaveLength(1); + expect(writes[0]!.table).toBe(hackathonCheckInFlags); + // Both timestamps come from the clock the rule was judged on, not from the + // database's now(). + expect(writes[0]!.values).toEqual({ + hackathonId: HACK, + participantId: "part-1", + eventId: "event-2", + conflictEventId: "event-1", + rule: "overlap", + method: "code", + attemptedById: "user-1", + attempts: 1, + createdAt: AT, + lastAttemptAt: AT, + }); + }); + + it("counts a repeat instead of adding a row, and leaves the dismissal alone", async () => { + const { db, writes } = fakeInsertDb(); + await recordCheckInFlag(db, HACK, breach); + + const { target, set } = writes[0]!.conflict; + expect(target).toEqual([ + hackathonCheckInFlags.participantId, + hackathonCheckInFlags.eventId, + hackathonCheckInFlags.conflictEventId, + ]); + expect(Object.keys(set).sort()).toEqual(["attempts", "lastAttemptAt"]); + expect(set.lastAttemptAt).toBe(AT); + expect(render(set.attempts).sql).toBe( + '"hackathon_check_in_flag"."attempts" + 1', + ); + }); + + it("keeps the staff member as the one who tried when there is no hacker", async () => { + const { db, writes } = fakeInsertDb(); + await recordCheckInFlag(db, HACK, { + ...breach, + method: "scan", + rule: "too_soon", + actorId: null, + }); + expect(writes[0]!.values).toMatchObject({ + method: "scan", + rule: "too_soon", + attemptedById: null, + }); + }); +}); + +describe("flaggingRuleBreaks", () => { + afterEach(() => vi.restoreAllMocks()); + + const refusal = () => + new TRPCError({ + code: "FORBIDDEN", + message: "You're already checked into BNY Workshop.", + cause: new CheckInRuleBreak(breach), + }); + + // A pool whose insert chain ends in `onConflictDoUpdate`, noting each flag. + const pool = (opts: { failWith?: Error } = {}) => { + const flags: unknown[] = []; + const db = { + insert: () => ({ + values: (values: unknown) => ({ + onConflictDoUpdate: async () => { + if (opts.failWith) throw opts.failWith; + flags.push(values); + }, + }), + }), + }; + return { db: db as unknown as DrizzleDB, flags }; + }; + + it("returns what the write returned and records nothing", async () => { + const { db, flags } = pool(); + await expect( + flaggingRuleBreaks(db, HACK, async () => ({ pointsAwarded: 5 })), + ).resolves.toEqual({ pointsAwarded: 5 }); + expect(flags).toEqual([]); + }); + + it("records a refusal for the check-in rules and rethrows the same error", async () => { + const { db, flags } = pool(); + const error = refusal(); + + const caught = await flaggingRuleBreaks(db, HACK, async () => { + throw error; + }).catch((e: unknown) => e); + + expect(caught).toBe(error); + expect(flags).toEqual([ + expect.objectContaining({ + hackathonId: HACK, + participantId: "part-1", + eventId: "event-2", + conflictEventId: "event-1", + rule: "overlap", + method: "code", + attemptedById: "user-1", + }), + ]); + }); + + it("ignores every other failure", async () => { + for (const error of [ + new Error("connection reset"), + new TRPCError({ code: "CONFLICT", message: "Already checked in." }), + new TRPCError({ + code: "FORBIDDEN", + message: "Not accepted.", + cause: new Error("something else"), + }), + // A cause shaped like a rule break is not one. + new TRPCError({ + code: "FORBIDDEN", + message: "Lookalike.", + cause: Object.assign(new Error("lookalike"), { ...breach }), + }), + ]) { + const { db, flags } = pool(); + const caught = await flaggingRuleBreaks(db, HACK, async () => { + throw error; + }).catch((e: unknown) => e); + expect(caught).toBe(error); + expect(flags).toEqual([]); + } + }); + + it("still throws the original refusal when the flag cannot be written", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const { db } = pool({ failWith: new Error("database is down") }); + const error = refusal(); + + const caught = await flaggingRuleBreaks(db, HACK, async () => { + throw error; + }).catch((e: unknown) => e); + + // The person at the door reads the same sentence either way. + expect(caught).toBe(error); + expect((caught as TRPCError).code).toBe("FORBIDDEN"); + expect((caught as TRPCError).message).toBe( + "You're already checked into BNY Workshop.", + ); + expect(consoleError).toHaveBeenCalledTimes(1); + }); + + it("is not thrown off when the pool itself is unusable", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const db = { + insert: () => { + throw new Error("pool is closed"); + }, + } as unknown as DrizzleDB; + const error = refusal(); + + await expect( + flaggingRuleBreaks(db, HACK, async () => { + throw error; + }), + ).rejects.toBe(error); + }); +}); + +// --------------------------------------------------------------------------- +// Reading + +describe("entriesForFlags", () => { + const flag = ( + id: string, + over: Partial<{ + participantId: string; + teamId: string | null; + userId: string; + }> = {}, + ) => ({ + id, + participantId: `part-${id}`, + teamId: null as string | null, + userId: `user-${id}`, + ...over, + }); + + it("puts a hacker's flag on their team's portal entry", () => { + const result = entriesForFlags( + [flag("f1", { teamId: "team-1" })], + [ + { entryId: "e-team", teamId: "team-1", submittedById: "part-cap" }, + { entryId: "e-other", teamId: "team-2", submittedById: "part-x" }, + ], + [], + ); + expect(result.get("f1")).toEqual(new Set(["e-team"])); + }); + + it("matches a solo project by who filed it", () => { + const result = entriesForFlags( + [flag("f1")], + [ + { entryId: "e-solo", teamId: null, submittedById: "part-f1" }, + { entryId: "e-not-me", teamId: null, submittedById: "part-other" }, + ], + [], + ); + expect(result.get("f1")).toEqual(new Set(["e-solo"])); + }); + + it("does not treat a former captain as a member of the team's project", () => { + // They filed it, then moved to team-2: only the team link counts for a + // project that has a team. + const result = entriesForFlags( + [flag("f1", { teamId: "team-2" })], + [{ entryId: "e-team-1", teamId: "team-1", submittedById: "part-f1" }], + [], + ); + expect(result.get("f1")).toEqual(new Set()); + }); + + it("does not match teamless hackers to teamless projects by their missing team", () => { + const result = entriesForFlags( + [flag("f1", { teamId: null })], + [{ entryId: "e-solo", teamId: null, submittedById: "part-other" }], + [], + ); + expect(result.get("f1")).toEqual(new Set()); + }); + + it("matches a member by account, which covers Devpost entries and claimed teammates", () => { + const result = entriesForFlags( + [flag("f1")], + [], + [ + { entryId: "e-devpost", userId: "user-f1" }, + { entryId: "e-unclaimed", userId: null }, + { entryId: "e-else", userId: "user-else" }, + ], + ); + expect(result.get("f1")).toEqual(new Set(["e-devpost"])); + }); + + it("counts an entry once however many ways the hacker reaches it", () => { + const result = entriesForFlags( + [flag("f1", { teamId: "team-1" })], + [{ entryId: "e1", teamId: "team-1", submittedById: null }], + [ + { entryId: "e1", userId: "user-f1" }, + { entryId: "e2", userId: "user-f1" }, + ], + ); + expect(result.get("f1")).toEqual(new Set(["e1", "e2"])); + }); + + it("gives every flag an answer, empty when it is on nobody's entry", () => { + const result = entriesForFlags( + [flag("f1"), flag("f2", { teamId: "team-1" })], + [{ entryId: "e1", teamId: "team-1", submittedById: null }], + [], + ); + expect(result.get("f1")).toEqual(new Set()); + expect(result.get("f2")).toEqual(new Set(["e1"])); + }); +}); + +describe("reading flags", () => { + // A db that answers by the table a query reads from, and notes each query so + // a test can say how many there were and what they asked for. It does not + // evaluate any SQL, so the conditions are checked by rendering them. + const fakeReadDb = (rows: Map) => { + const calls: { + table: unknown; + fields: Record; + where?: unknown; + }[] = []; + const db = { + select: (fields: Record) => ({ + from: (table: unknown) => { + const call: (typeof calls)[number] = { table, fields }; + calls.push(call); + // Chainable and awaitable, like a drizzle query. + type Query = { + innerJoin: () => Query; + where: (condition: unknown) => Query; + then: ( + resolve: (value: unknown) => unknown, + reject: (reason: unknown) => unknown, + ) => Promise; + }; + const node: Query = { + innerJoin: () => node, + where: (condition) => { + call.where = condition; + return node; + }, + then: (resolve, reject) => + Promise.resolve(rows.get(table) ?? []).then(resolve, reject), + }; + return node; + }, + }), + }; + return { db: db as unknown as DrizzleDB, calls }; + }; + + const flagRow = (id: string, over: Record = {}) => ({ + id, + participantId: `part-${id}`, + eventId: "event-2", + conflictEventId: "event-1", + rule: "overlap", + method: "code", + attempts: 1, + createdAt: new Date("2027-02-06T18:00:00Z"), + lastAttemptAt: new Date("2027-02-06T18:00:00Z"), + dismissedAt: null, + teamId: null, + userId: `user-${id}`, + firstName: "Ada", + lastName: "Lovelace", + accountName: "ada l", + email: "ada@example.com", + ...over, + }); + + const events = [ + { id: "event-1", name: "BNY Workshop" }, + { id: "event-2", name: "Full Sail Fireside" }, + ]; + + const table = (...entries: [unknown, unknown[]][]) => new Map(entries); + + describe("checkInFlagsForEntries", () => { + it("asks nothing for an empty list of entries", async () => { + const { db, calls } = fakeReadDb(table()); + const result = await checkInFlagsForEntries(db, HACK, []); + expect(result.size).toBe(0); + expect(calls).toEqual([]); + }); + + it("stops after one read when the edition has no flags", async () => { + const { db, calls } = fakeReadDb(table()); + const result = await checkInFlagsForEntries(db, HACK, null); + expect(result.size).toBe(0); + expect(calls).toHaveLength(1); + }); + + it("describes each flag with names for organisers", async () => { + const { db } = fakeReadDb( + table( + [ + hackathonCheckInFlags, + [ + flagRow("f1", { + teamId: "team-1", + rule: "too_soon", + method: "scan", + attempts: 3, + }), + ], + ], + [hackathonEvents, events], + [ + judgingEntries, + [{ entryId: "e1", teamId: "team-1", submittedById: null }], + ], + ), + ); + + const result = await checkInFlagsForEntries(db, HACK, ["e1"]); + + expect(result.get("e1")).toEqual([ + { + id: "f1", + participantName: "Ada Lovelace", + rule: "too_soon", + eventName: "Full Sail Fireside", + conflictEventName: "BNY Workshop", + method: "scan", + attempts: 3, + createdAt: new Date("2027-02-06T18:00:00Z"), + lastAttemptAt: new Date("2027-02-06T18:00:00Z"), + dismissed: false, + }, + ]); + }); + + it("still lists a flag whose event was deleted, under a stand-in name", async () => { + const { db, calls } = fakeReadDb( + table( + [ + hackathonCheckInFlags, + [flagRow("f1", { teamId: "team-1", eventId: null })], + ], + [hackathonEvents, events], + [ + judgingEntries, + [{ entryId: "e1", teamId: "team-1", submittedById: null }], + ], + ), + ); + + const [flag] = (await checkInFlagsForEntries(db, HACK, ["e1"])).get( + "e1", + )!; + + expect(flag).toMatchObject({ + id: "f1", + eventName: "A deleted event", + conflictEventName: "BNY Workshop", + }); + // Only the events that still have an id are looked up. + const lookup = render( + calls.find((c) => c.table === hackathonEvents)!.where, + ); + expect(lookup.params).toEqual(["event-1"]); + }); + + it("still lists a flag whose conflicting event was deleted", async () => { + const { db } = fakeReadDb( + table( + [ + hackathonCheckInFlags, + [flagRow("f1", { teamId: "team-1", conflictEventId: null })], + ], + [hackathonEvents, events], + [ + judgingEntries, + [{ entryId: "e1", teamId: "team-1", submittedById: null }], + ], + ), + ); + + const [flag] = (await checkInFlagsForEntries(db, HACK, ["e1"])).get( + "e1", + )!; + + expect(flag).toMatchObject({ + eventName: "Full Sail Fireside", + conflictEventName: "A deleted event", + }); + }); + + it("still lists a flag when both events were deleted", async () => { + const { db, calls } = fakeReadDb( + table( + [ + hackathonCheckInFlags, + [ + flagRow("f1", { + teamId: "team-1", + eventId: null, + conflictEventId: null, + }), + ], + ], + [ + judgingEntries, + [{ entryId: "e1", teamId: "team-1", submittedById: null }], + ], + ), + ); + + const [flag] = (await checkInFlagsForEntries(db, HACK, ["e1"])).get( + "e1", + )!; + + expect(flag).toMatchObject({ + eventName: "A deleted event", + conflictEventName: "A deleted event", + dismissed: false, + }); + // With no event ids left there is nothing to look up. + expect(calls.some((c) => c.table === hackathonEvents)).toBe(false); + }); + + it("falls back to the account name, then the email, when the registration has none", async () => { + const { db } = fakeReadDb( + table( + [ + hackathonCheckInFlags, + [ + flagRow("f1", { firstName: null, lastName: null }), + flagRow("f2", { + firstName: null, + lastName: null, + accountName: null, + }), + ], + ], + [hackathonEvents, events], + [ + judgingEntryMembers, + [ + { entryId: "e1", userId: "user-f1" }, + { entryId: "e1", userId: "user-f2" }, + ], + ], + ), + ); + + const flags = (await checkInFlagsForEntries(db, HACK, null)).get("e1")!; + expect(flags.map((f) => f.participantName).sort()).toEqual([ + "ada l", + "ada@example.com", + ]); + }); + + it("lists open flags before cleared ones, newest attempt first", async () => { + const t = (minute: number) => new Date(Date.UTC(2027, 1, 6, 18, minute)); + const { db } = fakeReadDb( + table( + [ + hackathonCheckInFlags, + [ + flagRow("old-open", { lastAttemptAt: t(1) }), + flagRow("cleared", { + lastAttemptAt: t(30), + dismissedAt: t(40), + }), + flagRow("new-open", { lastAttemptAt: t(20) }), + ], + ], + [hackathonEvents, events], + [ + judgingEntryMembers, + ["old-open", "cleared", "new-open"].map((id) => ({ + entryId: "e1", + userId: `user-${id}`, + })), + ], + ), + ); + + const flags = (await checkInFlagsForEntries(db, HACK, ["e1"])).get("e1")!; + expect(flags.map((f) => [f.id, f.dismissed])).toEqual([ + ["new-open", false], + ["old-open", false], + ["cleared", true], + ]); + }); + + it("keeps to the entries asked for, and to every entry when asked for null", async () => { + const rows = table( + [hackathonCheckInFlags, [flagRow("f1", { teamId: "team-1" })]], + [hackathonEvents, events], + [ + judgingEntries, + [ + { entryId: "e1", teamId: "team-1", submittedById: null }, + { entryId: "e2", teamId: "team-1", submittedById: null }, + ], + ], + ); + + const some = await checkInFlagsForEntries(fakeReadDb(rows).db, HACK, [ + "e2", + "e-unflagged", + ]); + expect([...some.keys()]).toEqual(["e2"]); + + const all = await checkInFlagsForEntries(fakeReadDb(rows).db, HACK, null); + expect([...all.keys()].sort()).toEqual(["e1", "e2"]); + // One flag, one description, wherever it shows. + expect(all.get("e1")![0]).toBe(all.get("e2")![0]); + }); + + it("leaves out a flag whose hacker is on no entry", async () => { + const { db } = fakeReadDb( + table( + [hackathonCheckInFlags, [flagRow("f1")]], + [hackathonEvents, events], + ), + ); + const result = await checkInFlagsForEntries(db, HACK, null); + expect(result.size).toBe(0); + }); + + it("reads in four batched queries however many flags there are", async () => { + const many = Array.from({ length: 40 }, (_, i) => + flagRow(`f${i}`, { teamId: `team-${i}` }), + ); + const { db, calls } = fakeReadDb( + table([hackathonCheckInFlags, many], [hackathonEvents, events]), + ); + + await checkInFlagsForEntries(db, HACK, null); + + expect(calls.map((c) => c.table)).toEqual([ + hackathonCheckInFlags, + hackathonEvents, + judgingEntries, + judgingEntryMembers, + ]); + }); + + it("looks for live entries of this edition only", async () => { + const { db, calls } = fakeReadDb( + table( + [hackathonCheckInFlags, [flagRow("f1", { teamId: "team-1" })]], + [hackathonEvents, events], + ), + ); + await checkInFlagsForEntries(db, HACK, null); + + const portal = render( + calls.find((c) => c.table === judgingEntries)!.where, + ); + const members = render( + calls.find((c) => c.table === judgingEntryMembers)!.where, + ); + for (const query of [portal, members]) { + expect(query.sql).toContain('"deleted_at" is null'); + expect(query.params).toContain(HACK); + } + expect(portal.params).toContain("portal"); + expect(portal.params).toContain("team-1"); + expect(members.params).toContain("user-f1"); + }); + }); + + describe("flaggedEntryIds", () => { + const target = (id: string, over: Record = {}) => ({ + id, + participantId: `part-${id}`, + teamId: null, + userId: `user-${id}`, + ...over, + }); + + it("asks nothing for an empty list of entries", async () => { + const { db, calls } = fakeReadDb(table()); + expect(await flaggedEntryIds(db, HACK, [])).toEqual(new Set()); + expect(calls).toEqual([]); + }); + + it("returns only the asked-for entries that carry a flag", async () => { + const { db } = fakeReadDb( + table( + [hackathonCheckInFlags, [target("f1", { teamId: "team-1" })]], + [ + judgingEntries, + [ + { entryId: "e1", teamId: "team-1", submittedById: null }, + { entryId: "e-not-asked", teamId: "team-1", submittedById: null }, + ], + ], + ), + ); + const flagged = await flaggedEntryIds(db, HACK, ["e1", "e2"]); + expect(flagged).toEqual(new Set(["e1"])); + }); + + it("reads open flags only, and never who or why", async () => { + const { db, calls } = fakeReadDb(table()); + await flaggedEntryIds(db, HACK, ["e1"]); + + const flags = calls.find((c) => c.table === hackathonCheckInFlags)!; + const where = render(flags.where); + expect(where.sql).toContain('"dismissed_at" is null'); + expect(where.params).toContain(HACK); + // Ids to find the team by, and nothing a judge could read. + expect(Object.keys(flags.fields).sort()).toEqual([ + "id", + "participantId", + "teamId", + "userId", + ]); + }); + }); + + describe("openCheckInFlagCount", () => { + it("is zero without reading entries when nothing is open", async () => { + const { db, calls } = fakeReadDb(table()); + expect(await openCheckInFlagCount(db, HACK)).toBe(0); + expect(calls).toHaveLength(1); + }); + + it("counts each open flag that sits on an entry once, and skips the rest", async () => { + const { db } = fakeReadDb( + table( + [ + hackathonCheckInFlags, + [ + { id: "f1", participantId: "p1", teamId: "team-1", userId: "u1" }, + { id: "f2", participantId: "p2", teamId: null, userId: "u2" }, + { id: "f3", participantId: "p3", teamId: null, userId: "u3" }, + ], + ], + [ + judgingEntries, + [ + { entryId: "e1", teamId: "team-1", submittedById: null }, + { entryId: "e2", teamId: "team-1", submittedById: null }, + ], + ], + [judgingEntryMembers, [{ entryId: "e3", userId: "u2" }]], + ), + ); + // f1 is on two entries and counts once, f2 reaches one through its + // account, f3 is on nobody's. + expect(await openCheckInFlagCount(db, HACK)).toBe(2); + }); + + it("reads open flags of this edition only", async () => { + const { db, calls } = fakeReadDb(table()); + await openCheckInFlagCount(db, HACK); + const where = render(calls[0]!.where); + expect(where.sql).toContain('"dismissed_at" is null'); + expect(where.params).toContain(HACK); + }); + }); +}); diff --git a/packages/api/src/services/check-in-flags.ts b/packages/api/src/services/check-in-flags.ts new file mode 100644 index 00000000..32ee02a2 --- /dev/null +++ b/packages/api/src/services/check-in-flags.ts @@ -0,0 +1,413 @@ +import { TRPCError } from "@trpc/server"; +import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { + hackathonCheckInFlags, + hackathonEvents, + hackathonParticipants, + hackathonProjects, + judgingEntries, + judgingEntryMembers, + users, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import type { JudgingDb } from "./judging/config"; +import { CheckInRuleBreak, fullName, personName } from "./points"; +import type { CheckInRuleBreakDetails } from "./points"; + +// Check-in rule breaks, as organisers and judges meet them. recordEventAttendance +// refuses an overlapping or too-soon check-in and the refusal rolls its +// transaction back, so the attempt is written here, on the pool, afterwards. +// A flag belongs to a person; judging shows it on the team they are on, found +// when it is read (see entriesForFlags) because people change teams and +// projects are promoted late. Hackers never read any of this, and nothing here +// may reach a cached or public payload. + +/** One refused attempt, as organisers read it. Judges get only an entry id. */ +export type CheckInFlagView = { + id: string; + participantName: string; + rule: "overlap" | "too_soon"; + /** The event they tried to enter, or "A deleted event" once it is gone. */ + eventName: string; + /** The event they were already in, worded the same way if deleted. */ + conflictEventName: string; + method: "scan" | "code" | "manual"; + attempts: number; + createdAt: Date; + lastAttemptAt: Date; + /** An organiser reviewed it and cleared it. */ + dismissed: boolean; +}; + +/** + * Stores a refused attempt. The same person trying the same pair of events + * again adds to `attempts` instead of a second row; a dismissal stays as it + * is, so a cleared flag does not reopen itself but still shows the retry count. + */ +export async function recordCheckInFlag( + db: DrizzleDB, + hackathonId: string, + breach: CheckInRuleBreakDetails, +): Promise { + await db + .insert(hackathonCheckInFlags) + .values({ + hackathonId, + participantId: breach.participantId, + eventId: breach.eventId, + conflictEventId: breach.conflictEventId, + rule: breach.rule, + method: breach.method, + attemptedById: breach.actorId, + attempts: 1, + createdAt: breach.at, + lastAttemptAt: breach.at, + }) + .onConflictDoUpdate({ + target: [ + hackathonCheckInFlags.participantId, + hackathonCheckInFlags.eventId, + hackathonCheckInFlags.conflictEventId, + ], + set: { + attempts: sql`${hackathonCheckInFlags.attempts} + 1`, + lastAttemptAt: breach.at, + }, + }); +} + +/** + * Runs an attendance write and, if the check-in rules refuse it, records the + * attempt on the pool before rethrowing. `db` has to be the pool and not the + * transaction the refusal rolled back: a row written inside it would go with + * it. Writing the flag is best effort. The person at the door must see the same + * refusal whether or not the flag landed, so a failure here is logged and the + * original error still goes out. + */ +export async function flaggingRuleBreaks( + db: DrizzleDB, + hackathonId: string, + run: () => Promise, +): Promise { + try { + return await run(); + } catch (error) { + if (error instanceof TRPCError && error.cause instanceof CheckInRuleBreak) { + try { + await recordCheckInFlag(db, hackathonId, error.cause); + } catch (flagError) { + // Deliberate server-side logging: the refusal is the answer the person + // gets, so the console is the only place a lost flag can show up. + // eslint-disable-next-line no-console + console.error( + "[CheckInFlags] Failed to record a rule break:", + flagError, + ); + } + } + throw error; + } +} + +// --------------------------------------------------------------------------- +// Reading flags against judging entries + +/** What finding a flag's entries needs from the flag and its participant. */ +export type FlagTarget = { + id: string; + participantId: string; + teamId: string | null; + userId: string; +}; + +/** A portal entry and the project it was promoted from. */ +export type PortalEntryLink = { + entryId: string; + teamId: string | null; + submittedById: string | null; +}; + +/** A person on an entry, claimed or not. */ +export type MemberEntryLink = { entryId: string; userId: string | null }; + +const pushTo = (map: Map, key: string, value: string) => { + const list = map.get(key); + if (list) list.push(value); + else map.set(key, [value]); +}; + +/** + * The entries each flag belongs to, by flag id. A participant is on an entry + * when it is a portal project filed by their team, or by them alone when it + * has no team, or when they are one of its members (which covers Devpost + * entries and claimed teammates). Reaching the same entry by more than one + * route counts once. A flag on nobody's entry maps to an empty set. Pure, so + * the matching is testable without a database. + */ +export function entriesForFlags( + flags: readonly FlagTarget[], + portal: readonly PortalEntryLink[], + members: readonly MemberEntryLink[], +): Map> { + const byTeam = new Map(); + const bySoloSubmitter = new Map(); + for (const link of portal) { + if (link.teamId) pushTo(byTeam, link.teamId, link.entryId); + else if (link.submittedById) { + pushTo(bySoloSubmitter, link.submittedById, link.entryId); + } + } + const byUser = new Map(); + for (const link of members) { + if (link.userId) pushTo(byUser, link.userId, link.entryId); + } + + const result = new Map>(); + for (const flag of flags) { + const entries = new Set(); + const sources = [ + flag.teamId ? byTeam.get(flag.teamId) : undefined, + bySoloSubmitter.get(flag.participantId), + byUser.get(flag.userId), + ]; + for (const ids of sources) for (const id of ids ?? []) entries.add(id); + result.set(flag.id, entries); + } + return result; +} + +/** Resolves flags to live entries of the edition in two batched reads. */ +async function entriesFor( + db: JudgingDb, + hackathonId: string, + flags: readonly FlagTarget[], +): Promise>> { + if (flags.length === 0) return new Map(); + + const teamIds = [ + ...new Set(flags.flatMap((flag) => (flag.teamId ? [flag.teamId] : []))), + ]; + const participantIds = [...new Set(flags.map((flag) => flag.participantId))]; + const userIds = [...new Set(flags.map((flag) => flag.userId))]; + + const [portal, members] = await Promise.all([ + db + .select({ + entryId: judgingEntries.id, + teamId: hackathonProjects.teamId, + submittedById: hackathonProjects.submittedById, + }) + .from(judgingEntries) + .innerJoin( + hackathonProjects, + eq(hackathonProjects.id, judgingEntries.sourceProjectId), + ) + .where( + and( + eq(judgingEntries.hackathonId, hackathonId), + eq(judgingEntries.source, "portal"), + isNull(judgingEntries.deletedAt), + or( + teamIds.length > 0 + ? inArray(hackathonProjects.teamId, teamIds) + : undefined, + and( + isNull(hackathonProjects.teamId), + inArray(hackathonProjects.submittedById, participantIds), + ), + ), + ), + ), + db + .select({ + entryId: judgingEntryMembers.entryId, + userId: judgingEntryMembers.userId, + }) + .from(judgingEntryMembers) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEntryMembers.entryId), + ) + .where( + and( + eq(judgingEntryMembers.hackathonId, hackathonId), + isNull(judgingEntries.deletedAt), + inArray(judgingEntryMembers.userId, userIds), + ), + ), + ]); + + return entriesForFlags(flags, portal, members); +} + +/** The open flags of an edition with just enough to find their entries. */ +async function openFlagTargets( + db: JudgingDb, + hackathonId: string, +): Promise { + return db + .select({ + id: hackathonCheckInFlags.id, + participantId: hackathonCheckInFlags.participantId, + teamId: hackathonParticipants.teamId, + userId: hackathonParticipants.userId, + }) + .from(hackathonCheckInFlags) + .innerJoin( + hackathonParticipants, + eq(hackathonParticipants.id, hackathonCheckInFlags.participantId), + ) + .where( + and( + eq(hackathonCheckInFlags.hackathonId, hackathonId), + isNull(hackathonCheckInFlags.dismissedAt), + ), + ); +} + +/** + * Every flag on the given entries, grouped by entry, for organisers: names and + * events included. Open flags come first, then the most recent attempt. Null + * entry ids means every live entry of the edition (the "Flagged" filter). An + * entry with no flags is not in the map. Never pass the result to a judge or + * put it in a cached payload. + */ +export async function checkInFlagsForEntries( + db: JudgingDb, + hackathonId: string, + entryIds: string[] | null, +): Promise> { + const result = new Map(); + if (entryIds && entryIds.length === 0) return result; + + const rows = await db + .select({ + id: hackathonCheckInFlags.id, + participantId: hackathonCheckInFlags.participantId, + eventId: hackathonCheckInFlags.eventId, + conflictEventId: hackathonCheckInFlags.conflictEventId, + rule: hackathonCheckInFlags.rule, + method: hackathonCheckInFlags.method, + attempts: hackathonCheckInFlags.attempts, + createdAt: hackathonCheckInFlags.createdAt, + lastAttemptAt: hackathonCheckInFlags.lastAttemptAt, + dismissedAt: hackathonCheckInFlags.dismissedAt, + teamId: hackathonParticipants.teamId, + userId: hackathonParticipants.userId, + firstName: hackathonParticipants.firstName, + lastName: hackathonParticipants.lastName, + accountName: users.name, + email: users.email, + }) + .from(hackathonCheckInFlags) + .innerJoin( + hackathonParticipants, + eq(hackathonParticipants.id, hackathonCheckInFlags.participantId), + ) + .innerJoin(users, eq(users.id, hackathonParticipants.userId)) + .where(eq(hackathonCheckInFlags.hackathonId, hackathonId)); + if (rows.length === 0) return result; + + // A deleted event leaves its id null on the flag; there is nothing to look up. + const eventIds = [ + ...new Set( + rows + .flatMap((row) => [row.eventId, row.conflictEventId]) + .filter((id) => id !== null), + ), + ]; + const [events, entriesByFlag] = await Promise.all([ + eventIds.length === 0 + ? ([] as { id: string; name: string }[]) + : db + .select({ id: hackathonEvents.id, name: hackathonEvents.name }) + .from(hackathonEvents) + .where(inArray(hackathonEvents.id, eventIds)), + entriesFor(db, hackathonId, rows), + ]); + const eventName = new Map(events.map((event) => [event.id, event.name])); + const nameOf = (id: string | null) => + (id ? eventName.get(id) : undefined) ?? "A deleted event"; + + const wanted = entryIds ? new Set(entryIds) : null; + const views: [CheckInFlagView, string[]][] = []; + for (const row of rows) { + const entries = [...(entriesByFlag.get(row.id) ?? [])].filter( + (id) => !wanted || wanted.has(id), + ); + if (entries.length === 0) continue; + views.push([ + { + id: row.id, + participantName: fullName(personName(row), row.email), + rule: row.rule, + eventName: nameOf(row.eventId), + conflictEventName: nameOf(row.conflictEventId), + method: row.method, + attempts: row.attempts, + createdAt: row.createdAt, + lastAttemptAt: row.lastAttemptAt, + dismissed: row.dismissedAt !== null, + }, + entries, + ]); + } + + // One order for every entry: open before cleared, newest attempt first, and + // the id last so equal timestamps never shuffle between polls. + views.sort( + ([a], [b]) => + Number(a.dismissed) - Number(b.dismissed) || + b.lastAttemptAt.getTime() - a.lastAttemptAt.getTime() || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + ); + for (const [view, entries] of views) { + for (const entryId of entries) { + const list = result.get(entryId); + if (list) list.push(view); + else result.set(entryId, [view]); + } + } + return result; +} + +/** + * Which of the given entries carry at least one flag nobody has dismissed. + * Ids only, which is all a judge is told: that the team was flagged, not who + * or why. + */ +export async function flaggedEntryIds( + db: JudgingDb, + hackathonId: string, + entryIds: string[], +): Promise> { + if (entryIds.length === 0) return new Set(); + + const targets = await openFlagTargets(db, hackathonId); + const entriesByFlag = await entriesFor(db, hackathonId, targets); + + const wanted = new Set(entryIds); + const flagged = new Set(); + for (const entries of entriesByFlag.values()) { + for (const id of entries) if (wanted.has(id)) flagged.add(id); + } + return flagged; +} + +/** + * Open flags in the edition that sit on a live entry, for the organiser + * header. A flag whose hacker is on no entry is left out: there is nowhere in + * judging to review it, and a count of things nobody can open is noise. + */ +export async function openCheckInFlagCount( + db: JudgingDb, + hackathonId: string, +): Promise { + const targets = await openFlagTargets(db, hackathonId); + const entriesByFlag = await entriesFor(db, hackathonId, targets); + + let count = 0; + for (const entries of entriesByFlag.values()) + if (entries.size > 0) count += 1; + return count; +} diff --git a/packages/api/src/services/hackathon-metrics.test.ts b/packages/api/src/services/hackathon-metrics.test.ts new file mode 100644 index 00000000..db8717ea --- /dev/null +++ b/packages/api/src/services/hackathon-metrics.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { getTableName } from "drizzle-orm"; +import type { DrizzleDB } from "@query/db"; +import { readLiveHackathons, refreshDbGauges, registry } from "./metrics"; + +const rules = readFileSync( + fileURLToPath( + new URL("../../../../monitoring/rules/hackathon.yml", import.meta.url), + ), + "utf-8", +); + +type GaugeReading = { + values: { value: number; labels: Record }[]; +}; + +/** Drizzle conditions point back at their tables, so this only follows SQL text. */ +function sqlText(condition: unknown) { + const parts: string[] = []; + const visit = (value: unknown, seen: Set) => { + if (typeof value === "string") { + parts.push(value); + return; + } + if (!value || typeof value !== "object" || seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + for (const item of value) visit(item, seen); + return; + } + const record = value as { value?: unknown; queryChunks?: unknown }; + if ("value" in record) visit(record.value, seen); + if ("queryChunks" in record) visit(record.queryChunks, seen); + }; + visit(condition, new Set()); + return parts.join(" "); +} + +function readings(name: string) { + return registry.getSingleMetric(name) as { + get: () => Promise | GaugeReading; + set: (labels: Record, value: number) => void; + }; +} + +describe("hackathon gauges", () => { + it("counts a live edition and drops one that is no longer live", async () => { + readings("dsgt_hackathon_projects").set( + { edition: "Hacklytics 2026", phase: "completed" }, + 40, + ); + + const queries: string[] = []; + const rowsFor = (table: string) => { + if (table === "hackathon") { + return [ + { + id: "hack_1", + edition: "Hacklytics 2027", + phase: "in_progress", + judgingActive: true, + }, + ]; + } + if (table === "hackathon_participant") { + return [ + { hackathonId: "hack_1", status: "approved", total: 12 }, + { hackathonId: "hack_1", status: "checked_in", total: 3 }, + ]; + } + if (table === "hackathon_project") { + return [{ hackathonId: "hack_1", total: 4 }]; + } + if (table === "hackathon_event_attendee") { + return [{ hackathonId: "hack_1", total: 9 }]; + } + if (table === "judging_project") { + return [{ hackathonId: "hack_1", total: 4 }]; + } + return []; + }; + + const db = { + select() { + return { + from(table: Parameters[0]) { + type Pending = Promise & { + from?: () => Pending; + innerJoin?: () => Pending; + groupBy?: () => Pending; + where?: (condition: unknown) => Pending; + }; + const pending: Pending = Promise.resolve( + rowsFor(getTableName(table)), + ); + pending.from = () => pending; + pending.innerJoin = () => pending; + pending.groupBy = () => pending; + pending.where = (condition: unknown) => { + queries.push(sqlText(condition)); + return pending; + }; + return pending; + }, + }; + }, + execute: async (query: unknown) => { + const text = JSON.stringify(query); + if (text.includes("member_resume")) { + return { rows: [{ files: "1", bytes: "10" }] }; + } + if (text.includes("metadata")) { + return { rows: [{ plan: "annual", total: "2" }] }; + } + return { + rows: [{ active: "5", lapsed: "1", bootcamp: "2", unlinked: "0" }], + }; + }, + }; + + const live = await readLiveHackathons(db as unknown as DrizzleDB); + expect(live).toEqual([ + { + edition: "Hacklytics 2027", + phase: "in_progress", + judgingActive: true, + projects: 4, + checkinsToday: 9, + judgingProjects: 4, + votes: 0, + participants: { + pending: 0, + approved: 12, + rejected: 0, + waitlisted: 0, + checked_in: 3, + }, + }, + ]); + + await refreshDbGauges(db as unknown as DrizzleDB); + + const projects = (await readings("dsgt_hackathon_projects").get()).values; + expect(projects).toEqual([ + { + labels: { edition: "Hacklytics 2027", phase: "in_progress" }, + value: 4, + }, + ]); + + const participants = (await readings("dsgt_hackathon_participants").get()) + .values; + expect( + participants.find((row) => row.labels.status === "checked_in")?.value, + ).toBe(3); + expect( + participants.find((row) => row.labels.status === "pending")?.value, + ).toBe(0); + expect( + participants.find((row) => row.labels.edition === "Hacklytics 2026"), + ).toBeUndefined(); + + expect( + (await readings("dsgt_hackathon_checkins_today").get()).values[0]?.value, + ).toBe(9); + expect( + (await readings("dsgt_hackathon_votes").get()).values[0]?.value, + ).toBe(0); + expect( + (await readings("dsgt_hackathon_judging_active").get()).values[0]?.value, + ).toBe(1); + + const filters = queries.join("\n"); + expect(filters).toContain("in_progress"); + expect(filters).toContain("draft"); + expect(filters).toContain("is null"); + expect(filters).not.toContain("completed"); + }); + + it("alerts when judging is on, tables are assigned, and no scores exist", () => { + expect(rules).toContain("alert: HackathonJudgingIdle"); + expect(rules).toContain("dsgt_hackathon_judging_active"); + expect(rules).toContain("dsgt_hackathon_judging_projects"); + expect(rules).toContain("dsgt_hackathon_votes"); + expect(rules).toContain("for: 30m"); + }); +}); diff --git a/packages/api/src/services/judging/appointment-status.test.ts b/packages/api/src/services/judging/appointment-status.test.ts new file mode 100644 index 00000000..29f8415a --- /dev/null +++ b/packages/api/src/services/judging/appointment-status.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { appointmentStatus } from "./appointment-status"; + +const startsAt = new Date("2027-02-08T15:00:00Z"); +const endsAt = new Date("2027-02-08T15:10:00Z"); +const before = new Date("2027-02-08T14:59:00Z"); +const during = new Date("2027-02-08T15:04:00Z"); +const after = new Date("2027-02-08T15:10:00Z"); + +describe("appointmentStatus", () => { + it("follows the clock while nothing is submitted", () => { + const slot = { startsAt, endsAt, evaluations: [] }; + expect(appointmentStatus(slot, before)).toBe("future"); + expect(appointmentStatus(slot, during)).toBe("pending"); + expect(appointmentStatus(slot, after)).toBe("missed"); + }); + + it("is complete when every submitted evaluation holds every rating", () => { + const slot = { + startsAt, + endsAt, + evaluations: [ + { judgeId: "a", ratingsComplete: true }, + { judgeId: "b", ratingsComplete: true }, + ], + presentJudgeIds: ["a", "b"], + }; + expect(appointmentStatus(slot, during)).toBe("complete"); + expect(appointmentStatus(slot, after)).toBe("complete"); + }); + + it("is incomplete when an evaluation was auto-submitted short of a rating", () => { + const slot = { + startsAt, + endsAt, + evaluations: [ + { judgeId: "a", ratingsComplete: true }, + { judgeId: "b", ratingsComplete: false }, + ], + }; + expect(appointmentStatus(slot, after)).toBe("incomplete"); + }); + + it("is incomplete while a judge in the room has not submitted", () => { + const slot = { + startsAt, + endsAt, + evaluations: [{ judgeId: "a", ratingsComplete: true }], + presentJudgeIds: ["a", "b"], + }; + expect(appointmentStatus(slot, during)).toBe("incomplete"); + }); + + it("lets evaluations outrank the clock", () => { + const slot = { + startsAt, + endsAt, + evaluations: [{ judgeId: "a", ratingsComplete: true }], + }; + expect(appointmentStatus(slot, before)).toBe("complete"); + }); +}); diff --git a/packages/api/src/services/judging/appointment-status.ts b/packages/api/src/services/judging/appointment-status.ts new file mode 100644 index 00000000..b63f02c3 --- /dev/null +++ b/packages/api/src/services/judging/appointment-status.ts @@ -0,0 +1,49 @@ +// Where one scheduled presentation stands, for the organiser board and the +// hacker itinerary alike. Evaluations decide it once any exist; until then the +// clock does. +// +// complete every judge who was in the room has an evaluation that holds +// every rating, the same rule placings count by +// incomplete some evaluation exists, but not all of the above +// missed the slot is over and nobody evaluated it +// pending the slot has started, nothing submitted yet +// future the slot has not started + +export type AppointmentStatus = + | "complete" + | "incomplete" + | "missed" + | "pending" + | "future"; + +export type AppointmentStatusInput = { + startsAt: Date; + endsAt: Date; + /** Evaluations of this appointment's project for its challenge. */ + evaluations: readonly { judgeId: string; ratingsComplete: boolean }[]; + /** Judges whose presence in the room overlapped the slot. */ + presentJudgeIds?: readonly string[]; +}; + +export function appointmentStatus( + input: AppointmentStatusInput, + now: Date, +): AppointmentStatus { + if (input.evaluations.length > 0) { + const completeBy = new Set( + input.evaluations.filter((e) => e.ratingsComplete).map((e) => e.judgeId), + ); + const allSubmittedComplete = input.evaluations.every( + (e) => e.ratingsComplete, + ); + const everyPresentJudged = (input.presentJudgeIds ?? []).every((id) => + completeBy.has(id), + ); + return allSubmittedComplete && everyPresentJudged + ? "complete" + : "incomplete"; + } + if (now >= input.endsAt) return "missed"; + if (now >= input.startsAt) return "pending"; + return "future"; +} diff --git a/packages/api/src/services/judging/calibration.test.ts b/packages/api/src/services/judging/calibration.test.ts new file mode 100644 index 00000000..9314bff3 --- /dev/null +++ b/packages/api/src/services/judging/calibration.test.ts @@ -0,0 +1,757 @@ +import { describe, expect, it } from "vitest"; +import { K_A, K_B, K_D, MIN_DRIFT_GRADES, calibrate } from "./calibration"; +import type { CalibrationEvaluation } from "./calibration"; + +// A small seeded generator (mulberry32), so every dataset below is the same on +// every run. No Math.random. +function seeded(seed: number) { + let state = seed >>> 0; + const uniform = () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + // Roughly normal: the sum of four uniforms, centred and scaled to sd 1. + const normal = () => + (uniform() + uniform() + uniform() + uniform() - 2) * Math.sqrt(3); + const shuffle = (items: readonly T[]) => { + const copy = [...items]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(uniform() * (i + 1)); + [copy[i], copy[j]] = [copy[j] as T, copy[i] as T]; + } + return copy; + }; + return { uniform, normal, shuffle }; +} + +const DAY = Date.UTC(2027, 1, 6, 10, 0, 0); + +const evaluation = ( + id: string, + judgeId: string, + score: number, + over: Partial = {}, +): CalibrationEvaluation => ({ + id, + judgeId, + challengeId: "general", + entryId: `entry-${id}`, + submittedAt: new Date(DAY), + ratingsComplete: true, + ratings: [score], + ...over, +}); + +const clampRating = (value: number) => Math.min(5, Math.max(1, value)); + +type Habit = { + judgeId: string; + /** Added to every score. */ + offset?: number; + /** Multiplies the distance from 3. */ + spread?: number; + /** Points added per grade, in grading order. */ + drift?: number; + noise?: number; +}; + +/** + * A pool of projects with a known quality, dealt out at random to judges with + * known habits. Returns the evaluations and each project's true quality. + */ +function pool(options: { + projects: number; + judges: Habit[]; + seed: number; + challengeId?: string; +}) { + const random = seeded(options.seed); + const quality = Array.from( + { length: options.projects }, + () => 1.8 + 2.4 * random.uniform(), + ); + const order = random.shuffle(quality.map((_, index) => index)); + const evaluations: CalibrationEvaluation[] = []; + const truth = new Map(); + + order.forEach((project, position) => { + const habit = options.judges[position % options.judges.length]!; + const turn = Math.floor(position / options.judges.length); + const raw = + 3 + + (habit.spread ?? 1) * ((quality[project] as number) - 3) + + (habit.offset ?? 0) + + (habit.drift ?? 0) * turn + + (habit.noise ?? 0.2) * random.normal(); + const id = `${options.challengeId ?? "general"}-${habit.judgeId}-${turn}`; + truth.set(id, quality[project] as number); + evaluations.push( + evaluation(id, habit.judgeId, clampRating(raw), { + challengeId: options.challengeId ?? "general", + entryId: `project-${project}`, + submittedAt: new Date(DAY + turn * 60_000), + }), + ); + }); + return { evaluations, truth }; +} + +function ranks(values: readonly number[]) { + const sorted = values + .map((value, index) => ({ value, index })) + .sort((a, b) => a.value - b.value); + const result = new Array(values.length); + sorted.forEach(({ index }, position) => { + result[index] = position; + }); + return result; +} + +/** Spearman rank correlation. */ +function spearman(a: readonly number[], b: readonly number[]) { + const ra = ranks(a); + const rb = ranks(b); + const mean = (ra.length - 1) / 2; + let top = 0; + let sa = 0; + let sb = 0; + for (let i = 0; i < ra.length; i++) { + top += (ra[i]! - mean) * (rb[i]! - mean); + sa += (ra[i]! - mean) ** 2; + sb += (rb[i]! - mean) ** 2; + } + return top / Math.sqrt(sa * sb); +} + +const scoreOf = (e: CalibrationEvaluation) => + e.ratings.reduce((sum, rating) => sum + rating, 0) / e.ratings.length; + +function sdOf(values: readonly number[]) { + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + return Math.sqrt( + values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length, + ); +} + +/** Least-squares slope of the values on their position. */ +function slopeOf(values: readonly number[]) { + const centre = (values.length - 1) / 2; + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + let along = 0; + let across = 0; + values.forEach((value, index) => { + along += (index - centre) * (value - mean); + across += (index - centre) ** 2; + }); + return along / across; +} + +describe("calibrate", () => { + it("uses the documented pseudo-evaluation counts", () => { + expect([K_A, K_B, K_D]).toEqual([3, 6, 150]); + expect(MIN_DRIFT_GRADES).toBe(20); + }); + + it("returns nothing for no evaluations", () => { + const result = calibrate([]); + expect(result.adjusted.size).toBe(0); + expect(result.judges).toEqual([]); + }); + + it("leaves one evaluation as it is", () => { + const result = calibrate([evaluation("only", "judge", 4.25)]); + expect(result.adjusted.get("only")).toBeCloseTo(4.25, 10); + expect(result.judges).toEqual([ + { + judgeId: "judge", + challengeId: "general", + evaluations: 1, + offset: 0, + spread: 1, + drift: 0, + }, + ]); + }); + + it("scores an evaluation as the mean of its ratings", () => { + const result = calibrate([ + evaluation("a", "judge", 0, { ratings: [4, 5, 3, 4] }), + ]); + expect(result.adjusted.get("a")).toBeCloseTo(4, 10); + }); + + it("ignores evaluations short of a rating and ones with no ratings", () => { + const result = calibrate([ + evaluation("done", "judge", 4), + evaluation("short", "judge", 1, { ratingsComplete: false }), + evaluation("blank", "judge", 0, { ratings: [] }), + ]); + expect([...result.adjusted.keys()]).toEqual(["done"]); + expect(result.judges).toHaveLength(1); + expect(result.judges[0]?.evaluations).toBe(1); + }); + + it("does not correct the offset of the only judge in a pool", () => { + const { evaluations } = pool({ + projects: 30, + judges: [{ judgeId: "alone", offset: 0.9 }], + seed: 5, + }); + const result = calibrate(evaluations); + // Nobody to compare them with: no offset and no spread. + expect(result.judges[0]?.offset).toBeCloseTo(0, 10); + expect(result.judges[0]?.spread).toBeCloseTo(1, 10); + for (const e of evaluations) { + expect(result.adjusted.get(e.id)).toBeCloseTo(scoreOf(e), 0); + } + }); + + it("matches the model on a small pool worked by hand", () => { + // Pool scores 2, 4, 4, 5: mu = 3.75. Taking each judge's mean out leaves + // -1, 1 and -0.5, 0.5, so the sd a judge is measured against is + // sqrt(2.5 / 4), not the plain pool sd of sqrt(1.1875). + const mu = 3.75; + const sd = Math.sqrt(2.5 / 4); + // Judge a has 2 and 4: n = 2, so no drift. Mean 3, population sd 1. + const offset = (2 / (2 + K_A)) * (3 - mu); + const spread = 1 + (2 / (2 + K_B)) * (1 / sd - 1); + const result = calibrate([ + evaluation("a1", "a", 2, { submittedAt: new Date(DAY) }), + evaluation("a2", "a", 4, { submittedAt: new Date(DAY + 60_000) }), + evaluation("b1", "b", 4, { submittedAt: new Date(DAY) }), + evaluation("b2", "b", 5, { submittedAt: new Date(DAY + 60_000) }), + ]); + expect(result.adjusted.get("a1")).toBeCloseTo( + mu + (2 - mu - offset) / spread, + 10, + ); + expect(result.adjusted.get("a2")).toBeCloseTo( + mu + (4 - mu - offset) / spread, + 10, + ); + const a = result.judges.find((j) => j.judgeId === "a"); + expect(a?.offset).toBeCloseTo(offset, 10); + expect(a?.spread).toBeCloseTo(spread, 10); + expect(a?.drift).toBe(0); + }); + + describe("strict and lenient judges", () => { + const { evaluations, truth } = pool({ + projects: 80, + judges: [ + { judgeId: "strict", offset: -0.8 }, + { judgeId: "lenient", offset: 0.8 }, + ], + seed: 11, + }); + const result = calibrate(evaluations); + + it("rank projects far closer to their true quality than raw means do", () => { + const quality = evaluations.map((e) => truth.get(e.id) as number); + const raw = spearman(quality, evaluations.map(scoreOf)); + const adjusted = spearman( + quality, + evaluations.map((e) => result.adjusted.get(e.id) as number), + ); + expect(raw).toBeLessThan(0.65); + expect(adjusted).toBeGreaterThan(0.9); + expect(adjusted - raw).toBeGreaterThan(0.25); + }); + + it("moves their scores by roughly the size of their bias", () => { + const moves = evaluations.map((e) => + Math.abs((result.adjusted.get(e.id) as number) - scoreOf(e)), + ); + const average = moves.reduce((sum, move) => sum + move, 0) / moves.length; + expect(average).toBeGreaterThan(0.6); + expect(average).toBeLessThan(1); + }); + + it("reports which one is strict and which lenient", () => { + const strict = result.judges.find((j) => j.judgeId === "strict"); + const lenient = result.judges.find((j) => j.judgeId === "lenient"); + expect(strict?.offset).toBeLessThan(-0.5); + expect(lenient?.offset).toBeGreaterThan(0.5); + expect(strict?.evaluations).toBe(40); + }); + + it("does not mistake the gap between them for bunched scores", () => { + // Both mark with an ordinary spread. The pool's plain spread also holds + // the gap between their means; measured against that, both would look + // bunched and every score would be stretched. + for (const judge of result.judges) { + expect(judge.spread).toBeGreaterThan(0.9); + expect(judge.spread).toBeLessThan(1.1); + } + const quality = evaluations.map((e) => truth.get(e.id) as number); + const adjusted = evaluations.map( + (e) => result.adjusted.get(e.id) as number, + ); + expect(sdOf(adjusted)).toBeLessThan(sdOf(quality) * 1.15); + }); + + it("brings the two judges' average scores together", () => { + const meanOf = ( + judgeId: string, + score: (e: CalibrationEvaluation) => number, + ) => { + const mine = evaluations.filter((e) => e.judgeId === judgeId); + return mine.reduce((sum, e) => sum + score(e), 0) / mine.length; + }; + const adjusted = (e: CalibrationEvaluation) => + result.adjusted.get(e.id) as number; + expect( + meanOf("lenient", scoreOf) - meanOf("strict", scoreOf), + ).toBeGreaterThan(1.3); + expect( + Math.abs(meanOf("lenient", adjusted) - meanOf("strict", adjusted)), + ).toBeLessThan(0.2); + }); + }); + + describe("a bunched judge", () => { + const { evaluations } = pool({ + projects: 80, + judges: [{ judgeId: "bunched", spread: 0.3 }, { judgeId: "normal" }], + seed: 23, + }); + const result = calibrate(evaluations); + const of = (judgeId: string) => + evaluations.filter((e) => e.judgeId === judgeId); + + it("is stretched toward the pool's spread", () => { + const bunched = of("bunched"); + const before = sdOf(bunched.map(scoreOf)); + const after = sdOf( + bunched.map((e) => result.adjusted.get(e.id) as number), + ); + expect( + result.judges.find((j) => j.judgeId === "bunched")?.spread, + ).toBeLessThan(0.7); + expect(after).toBeGreaterThan(before * 1.4); + }); + + it("leaves the normal judge's spread close to where it was", () => { + const normal = of("normal"); + const before = sdOf(normal.map(scoreOf)); + const after = sdOf( + normal.map((e) => result.adjusted.get(e.id) as number), + ); + expect(Math.abs(after - before)).toBeLessThan(before * 0.3); + }); + }); + + describe("a drifting judge", () => { + const { evaluations } = pool({ + projects: 90, + judges: [{ judgeId: "tiring", drift: -0.05 }, { judgeId: "steady" }], + seed: 31, + }); + const result = calibrate(evaluations); + + const mine = evaluations + .filter((e) => e.judgeId === "tiring") + .sort((a, b) => a.submittedAt.getTime() - b.submittedAt.getTime()); + const grades = mine.length; + // What shrink leaves of a fitted trend with this many grades. + const trusted = grades / (grades + K_D); + + it("is detrended in grading order, by the shrunk trend", () => { + const before = slopeOf(mine.map(scoreOf)); + const after = slopeOf( + mine.map((e) => result.adjusted.get(e.id) as number), + ); + expect(before).toBeLessThan(-0.04); + expect(Math.abs(after)).toBeLessThan(Math.abs(before)); + expect(after * before).toBeGreaterThan(0); + // Only the trusted share of the trend is taken out (spread and offset + // rescale what is left, so this is not exact). + expect(Math.abs(after)).toBeGreaterThan( + Math.abs(before) * (1 - trusted) * 0.7, + ); + }); + + it("reports a drift that is negative for a judge who got harsher", () => { + const tiring = result.judges.find((j) => j.judgeId === "tiring"); + const steady = result.judges.find((j) => j.judgeId === "steady"); + // It is the fitted drift over the whole day, times the share trusted. + expect(tiring?.drift).toBeCloseTo( + slopeOf(mine.map(scoreOf)) * (grades - 1) * trusted, + 9, + ); + expect(tiring?.drift).toBeLessThan(-0.3); + expect(Math.abs(steady?.drift ?? 1)).toBeLessThan(0.3); + }); + }); + + it("breaks a tie in submission time by id when ordering a judge's grades", () => { + // Twenty grades at one instant, so the ids alone say which came first. + const day = (idOf: (position: number) => string) => + calibrate( + Array.from({ length: MIN_DRIFT_GRADES }, (_, position) => + evaluation( + idOf(position), + "judge", + 1 + (4 * position) / (MIN_DRIFT_GRADES - 1), + ), + ), + ); + const id = (n: number) => `g${String(n).padStart(2, "0")}`; + const climbing = day((position) => id(position)); + const falling = day((position) => id(MIN_DRIFT_GRADES - 1 - position)); + expect(climbing.judges[0]?.drift).toBeGreaterThan(0); + expect(falling.judges[0]?.drift).toBeLessThan(0); + }); + + describe("drift tuning", () => { + // Simulated days: pools of 80 judges who each grade `grades` projects in + // turn, three pools per size, so every number below is over 240 judges and + // the same on every run. Project quality varies by about 0.7 of a point + // and a judge adds 0.2 of noise (pool above). `reported` is the drift + // calibrate removes from each judge; `raw` is the unshrunk least-squares + // drift from first grade to last, taken from the same scores. + const day = (grades: number, drift: number) => { + const reported: number[] = []; + const raw: number[] = []; + for (const seed of [1, 2, 3]) { + const { evaluations } = pool({ + projects: 80 * grades, + judges: Array.from({ length: 80 }, (_, i) => ({ + judgeId: `j${i}`, + drift: drift / (grades - 1), + })), + seed: 1000 * grades + seed, + }); + const byJudge = new Map(); + for (const e of [...evaluations].sort( + (a, b) => a.submittedAt.getTime() - b.submittedAt.getTime(), + )) { + byJudge.set(e.judgeId, [ + ...(byJudge.get(e.judgeId) ?? []), + scoreOf(e), + ]); + } + for (const judge of calibrate(evaluations).judges) { + reported.push(judge.drift); + raw.push( + slopeOf(byJudge.get(judge.judgeId) as number[]) * (grades - 1), + ); + } + } + return { reported, raw }; + }; + const meanOf = (values: readonly number[]) => + values.reduce((sum, value) => sum + value, 0) / values.length; + + // From the shortest day that is fitted to the longest there is. + const sizes = [MIN_DRIFT_GRADES, 30, 45, 60, 100]; + + it.each(sizes)( + "invents under 0.1 of a point of drift for a judge with none, at %i grades", + (grades) => { + const { reported, raw } = day(grades, 0); + expect(sdOf(reported)).toBeLessThan(0.1); + // The same judges under the old K_D = 10, which the review measured at + // about 0.33. + const old = raw.map((d) => (d * grades) / (grades + 10)); + expect(sdOf(old)).toBeGreaterThan(0.2); + }, + ); + + it.each(sizes)( + "takes out the share n / (n + K_D) of a real drift, at %i grades", + (grades) => { + for (const drift of [0.5, 1]) { + const { reported, raw } = day(grades, drift); + // The fit itself is unbiased: the raw drifts average the real one. + expect(Math.abs(meanOf(raw) - drift)).toBeLessThan(0.1); + const removed = meanOf(reported) / drift; + expect(Math.abs(removed - grades / (grades + K_D))).toBeLessThan( + 0.07, + ); + } + }, + ); + + it("does not fit a drift below the fewest grades", () => { + const grades = MIN_DRIFT_GRADES - 1; + const { evaluations } = pool({ + projects: 20 * grades, + judges: Array.from({ length: 20 }, (_, i) => ({ + judgeId: `j${i}`, + drift: -3 / (grades - 1), + })), + seed: 7, + }); + const result = calibrate(evaluations); + expect(result.judges.every((judge) => judge.drift === 0)).toBe(true); + // Nothing detrended: with one habit shared by every judge, the offset and + // spread are the only other corrections, and they are about 0 here. + const moved = evaluations.map((e) => + Math.abs((result.adjusted.get(e.id) as number) - scoreOf(e)), + ); + expect(meanOf(moved)).toBeLessThan(0.25); + }); + }); + + it("barely corrects a judge with a single grade", () => { + const { evaluations } = pool({ + projects: 40, + judges: [{ judgeId: "a" }, { judgeId: "b", offset: 0.3 }], + seed: 41, + }); + const once = evaluation("once", "visitor", 4.9); + const all = [...evaluations, once]; + const result = calibrate(all); + const visitor = result.judges.find((j) => j.judgeId === "visitor"); + expect(visitor?.evaluations).toBe(1); + expect(visitor?.spread).toBe(1); + expect(visitor?.drift).toBe(0); + + // One grade is mostly the project, not the judge: the pull toward the pool + // is 1 / (1 + K_A) of the distance, a quarter. + const mu = all.reduce((sum, e) => sum + scoreOf(e), 0) / all.length; + expect(visitor?.offset).toBeCloseTo((4.9 - mu) / (1 + K_A), 10); + const moved = Math.abs(4.9 - (result.adjusted.get("once") as number)); + expect(moved).toBeLessThan(Math.abs(4.9 - mu) * 0.3); + }); + + it("leaves unbiased judges almost where they were", () => { + const { evaluations } = pool({ + projects: 90, + judges: [{ judgeId: "a" }, { judgeId: "b" }, { judgeId: "c" }], + seed: 53, + }); + const result = calibrate(evaluations); + const moves = evaluations.map((e) => + Math.abs((result.adjusted.get(e.id) as number) - scoreOf(e)), + ); + const average = moves.reduce((sum, move) => sum + move, 0) / moves.length; + // Chance alone gives each judge's thirty projects a slightly different + // mean, so some movement is expected; a biased judge moves several times as far. + expect(average).toBeLessThan(0.2); + expect(Math.max(...moves)).toBeLessThan(0.6); + expect( + spearman( + evaluations.map(scoreOf), + evaluations.map((e) => result.adjusted.get(e.id) as number), + ), + ).toBeGreaterThan(0.95); + for (const judge of result.judges) { + expect(Math.abs(judge.offset)).toBeLessThan(0.2); + expect(judge.spread).toBeGreaterThan(0.8); + expect(judge.spread).toBeLessThan(1.25); + } + }); + + describe("pools", () => { + const table = pool({ + projects: 40, + judges: [{ judgeId: "t1" }, { judgeId: "t2", offset: 0.5 }], + seed: 61, + challengeId: "general", + }).evaluations; + // A sponsor whose guests are harsh, and one guest who also judges at the tables. + const sponsor = pool({ + projects: 20, + judges: [ + { judgeId: "g1", offset: -1 }, + { judgeId: "t1", offset: -1 }, + ], + seed: 67, + challengeId: "sponsor", + }).evaluations; + + it("never mix: a pool calibrates the same alone or beside another", () => { + const together = calibrate([...table, ...sponsor]); + const alone = calibrate(table); + for (const e of table) { + expect(together.adjusted.get(e.id)).toBe(alone.adjusted.get(e.id)); + } + const sponsorAlone = calibrate(sponsor); + for (const e of sponsor) { + expect(together.adjusted.get(e.id)).toBe( + sponsorAlone.adjusted.get(e.id), + ); + } + }); + + it("give a judge in two challenges separate parameters in each", () => { + const together = calibrate([...table, ...sponsor]); + const both = together.judges.filter((j) => j.judgeId === "t1"); + expect(both.map((j) => j.challengeId).sort()).toEqual([ + "general", + "sponsor", + ]); + const [general, other] = [ + both.find((j) => j.challengeId === "general")!, + both.find((j) => j.challengeId === "sponsor")!, + ]; + expect(general.evaluations).toBe(20); + expect(other.evaluations).toBe(10); + // Stricter than the lenient t2 at the tables; as harsh as the sponsor's + // own guest, so near level there (ten grades each leave some chance in it). + expect(general.offset).toBeLessThan(-0.1); + expect(Math.abs(other.offset)).toBeLessThan(0.25); + expect(other.offset - general.offset).toBeGreaterThan(0.2); + }); + }); + + it("keeps every output between 1 and 5", () => { + const random = seeded(71); + const extreme: CalibrationEvaluation[] = []; + for (let i = 0; i < 60; i++) { + // A judge who only ever gives 5s next to one who only gives 1s and 2s, + // and one who swings between the ends. + const judgeId = ["high", "low", "swing"][i % 3]!; + const score = + judgeId === "high" + ? 5 + : judgeId === "low" + ? 1 + Math.floor(random.uniform() * 2) + : random.uniform() < 0.5 + ? 1 + : 5; + extreme.push( + evaluation(`x-${i}`, judgeId, score, { + submittedAt: new Date(DAY + i * 60_000), + }), + ); + } + const result = calibrate(extreme); + expect(result.adjusted.size).toBe(60); + for (const value of result.adjusted.values()) { + expect(value).toBeGreaterThanOrEqual(1); + expect(value).toBeLessThanOrEqual(5); + } + for (const judge of result.judges) { + expect(judge.spread).toBeGreaterThanOrEqual(0.5); + expect(judge.spread).toBeLessThanOrEqual(2); + } + }); + + describe("a judge who gives every project the same score", () => { + const { evaluations } = pool({ + projects: 40, + judges: [{ judgeId: "varied" }, { judgeId: "flat", spread: 0, noise: 0 }], + seed: 83, + }); + const flat = evaluations.filter((e) => e.judgeId === "flat"); + const result = calibrate(evaluations); + + it("is clamped at a spread of 0.5 and given one sane score throughout", () => { + expect(new Set(flat.map(scoreOf)).size).toBe(1); + const judge = result.judges.find((j) => j.judgeId === "flat"); + expect(judge?.spread).toBe(0.5); + expect(judge?.drift).toBeCloseTo(0, 10); + + const values = flat.map((e) => result.adjusted.get(e.id) as number); + for (const value of values) { + expect(Number.isFinite(value)).toBe(true); + expect(value).toBeGreaterThanOrEqual(1); + expect(value).toBeLessThanOrEqual(5); + expect(value).toBeCloseTo(values[0] as number, 10); + } + }); + + it("does not disturb the other judge", () => { + const varied = evaluations.filter((e) => e.judgeId === "varied"); + for (const e of varied) { + const adjusted = result.adjusted.get(e.id) as number; + expect(Number.isFinite(adjusted)).toBe(true); + expect(Math.abs(adjusted - scoreOf(e))).toBeLessThan(0.5); + } + }); + }); + + it("copes with a pool where every score is the same", () => { + const result = calibrate( + ["a", "b", "c", "d"].map((id, i) => + evaluation(id, i % 2 ? "x" : "y", 3.5, { + submittedAt: new Date(DAY + i * 60_000), + }), + ), + ); + for (const value of result.adjusted.values()) + expect(value).toBeCloseTo(3.5, 10); + for (const judge of result.judges) { + expect(judge.spread).toBe(1); + expect(judge.offset).toBeCloseTo(0, 10); + } + }); + + it("handles projects with several evaluations, as older data has", () => { + const { evaluations } = pool({ + projects: 30, + judges: [ + { judgeId: "a", offset: -0.6 }, + { judgeId: "b", offset: 0.6 }, + { judgeId: "c" }, + ], + seed: 97, + }); + // Every judge also graded the first ten projects. + const repeats = evaluations.slice(0, 10).flatMap((first) => + ["a", "b", "c"] + .filter((judgeId) => judgeId !== first.judgeId) + .map((judgeId) => + evaluation(`again-${first.id}-${judgeId}`, judgeId, scoreOf(first), { + entryId: first.entryId, + submittedAt: new Date(DAY + 3_600_000), + }), + ), + ); + const all = [...evaluations, ...repeats]; + const result = calibrate(all); + expect(result.adjusted.size).toBe(all.length); + expect(new Set(all.map((e) => e.entryId)).size).toBe(30); + for (const value of result.adjusted.values()) { + expect(value).toBeGreaterThanOrEqual(1); + expect(value).toBeLessThanOrEqual(5); + } + }); + + it("gives the same answer whatever order the evaluations come in", () => { + const { evaluations } = pool({ + projects: 60, + judges: [ + { judgeId: "strict", offset: -0.7, drift: -0.03 }, + { judgeId: "lenient", offset: 0.7, spread: 0.6 }, + { judgeId: "plain" }, + ], + seed: 101, + }); + const other = pool({ + projects: 20, + judges: [{ judgeId: "plain", offset: 0.4 }, { judgeId: "guest" }], + seed: 103, + challengeId: "sponsor", + }).evaluations; + const all = [...evaluations, ...other]; + + const forward = calibrate(all); + const shuffled = calibrate(seeded(7).shuffle(all)); + const reversed = calibrate([...all].reverse()); + + for (const result of [shuffled, reversed]) { + expect(result.judges).toEqual(forward.judges); + expect([...result.adjusted.entries()].sort()).toEqual( + [...forward.adjusted.entries()].sort(), + ); + } + }); + + it("does not change its input", () => { + const { evaluations } = pool({ + projects: 12, + judges: [{ judgeId: "a" }, { judgeId: "b" }], + seed: 107, + }); + const before = JSON.stringify(evaluations); + calibrate(evaluations); + expect(JSON.stringify(evaluations)).toBe(before); + }); +}); diff --git a/packages/api/src/services/judging/calibration.ts b/packages/api/src/services/judging/calibration.ts new file mode 100644 index 00000000..4ac284dc --- /dev/null +++ b/packages/api/src/services/judging/calibration.ts @@ -0,0 +1,257 @@ +// Judge calibration. Each project is graded once per challenge, by one judge, +// so a strict, lenient, bunched or drifting judge decides the score of every +// project they happened to grade. This takes that judge out of the score. +// +// It is valid only because the judge queue (queue.ts) hands each judge a random +// sample of the projects: a random sample is as good as the pool, so a judge +// whose mean, spread or trend differs from the pool's is showing their own +// habits, not an easier or harder set of projects. Hand a judge picked projects +// and the correction below would punish them for it. +// +// A pool is one root challenge: sponsor judges are compared only with the other +// judges of that sponsor's challenge, table judges with table judges, and a +// judge who grades in two challenges gets separate parameters in each. Within a +// pool, over evaluations that hold every rating, with a score s (the mean of +// its ratings): +// +// mu the pool's mean +// drift a judge's grades in order (submittedAt, then id), from +// MIN_DRIFT_GRADES of them: the slope of s on the centred order +// index, shrunk, removed: s' = s - slope * (i - mean i) +// sd the pool's population standard deviation of s' with each judge's +// own mean taken out (judges with two or more grades) +// offset a = shrink * (mean(s') - mu): stricter (below 0) or more lenient +// spread b = 1 + shrink * (sd_j(s') / sd - 1), in [0.5, 2]: bunched or wide +// +// adjusted = clamp(mu + (s' - mu - a) / b, 1, 5) +// +// where shrink = n / (n + K) for a judge with n grades (K_A, K_B, K_D below). + +import { evaluationScore } from "./scoring"; + +// Pseudo-evaluations: a judge with n grades is trusted n / (n + K) of the way, +// so one with few grades is pulled toward no correction. K_A is for the offset, +// K_B for the spread, K_D for the drift. +// +// K_D is far the largest because a trend is the hardest habit to see. Each +// grade is also the quality of the project it happened to be, which varies by +// about 0.7 of a point, so a judge with no drift at all still has a fitted one +// from first grade to last, off by an sd of 0.5 of a point over 20 grades, 0.4 +// over 30 and 0.3 over 60. K_D = 10 passed most of that on as correction: the +// sd of the drift taken off judges with none was 0.32 of a point at 30 grades. +// K_D = 150 holds it under 0.1 at every size from 20 to 100 grades (0.07 at 30, +// 0.09 at 60). Both are measured on simulated judges in calibration.test.ts, +// "drift tuning". +// +// The price is that a real drift is taken out by the same share, n / (n + K_D): +// 17% of it at 30 grades, 29% at 60. Half a point of real drift across a day is +// mostly left in. Taking most of it out as well was the aim, and no weight does +// both: at that size a real drift and the one chance invents look alike, so a +// smaller K_D gives the invented correction back as fast as the real one (0.15 +// of a point at K_D = 60). Both would take some 150 grades from each judge. +export const K_A = 3; +export const K_B = 6; +export const K_D = 150; +// Fewest grades before a drift is fitted. Below this chance alone moves the +// fitted drift by half a point or more (0.9 at 6 grades), as much as a real +// one worth finding, so none is fitted. +export const MIN_DRIFT_GRADES = 20; + +const MIN_SPREAD = 0.5; +const MAX_SPREAD = 2; +const MIN_RATING = 1; +const MAX_RATING = 5; +// Below this a standard deviation is float noise, not a spread. +const FLAT = 1e-9; + +export type CalibrationEvaluation = { + id: string; + judgeId: string; + /** The root challenge. It is the pool: nothing is compared across it. */ + challengeId: string; + entryId: string; + submittedAt: Date; + /** Holds a rating for every rating item of the rubric; only these count. */ + ratingsComplete: boolean; + /** The 1-5 rating values it holds. */ + ratings: readonly number[]; +}; + +export type JudgeCalibration = { + judgeId: string; + challengeId: string; + /** Counted, scored evaluations the judge has in this challenge. */ + evaluations: number; + /** Rating points taken off the judge's scores. Positive: more lenient than the pool. */ + offset: number; + /** What the judge's spread was divided by. Below 1: bunched. Above 1: wider than the pool. */ + spread: number; + /** Points gained (positive) or lost (negative) from the judge's first grade to their last. */ + drift: number; +}; + +export type Calibration = { + /** The calibrated score of each counted, scored evaluation, by id. */ + adjusted: Map; + judges: JudgeCalibration[]; +}; + +type Graded = { + id: string; + judgeId: string; + challengeId: string; + at: number; + score: number; +}; + +const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); + +const mean = (values: readonly number[]) => { + let sum = 0; + for (const value of values) sum += value; + return sum / values.length; +}; + +const populationSd = (values: readonly number[]) => { + const centre = mean(values); + let sum = 0; + for (const value of values) sum += (value - centre) ** 2; + return Math.sqrt(sum / values.length); +}; + +const clamp = (value: number, low: number, high: number) => + Math.min(high, Math.max(low, value)); + +const shrink = (n: number, k: number) => n / (n + k); + +function groupBy(items: readonly T[], key: (item: T) => string) { + const groups = new Map(); + for (const item of items) { + const list = groups.get(key(item)) ?? []; + list.push(item); + groups.set(key(item), list); + } + return groups; +} + +/** + * Calibrated scores for every evaluation that holds every rating, and what was + * learned about each judge. Evaluations short of a rating, and ones with no + * ratings at all, are left out of both. The result does not depend on the order + * of the input. + */ +export function calibrate( + evaluations: readonly CalibrationEvaluation[], +): Calibration { + const graded: Graded[] = []; + for (const evaluation of evaluations) { + if (!evaluation.ratingsComplete) continue; + const score = evaluationScore(evaluation.ratings); + if (score === null) continue; + graded.push({ + id: evaluation.id, + judgeId: evaluation.judgeId, + challengeId: evaluation.challengeId, + at: evaluation.submittedAt.getTime(), + score, + }); + } + + // One canonical order, so the sums below add up the same way whatever order + // the caller listed the evaluations in. + graded.sort( + (a, b) => + compareText(a.challengeId, b.challengeId) || + compareText(a.judgeId, b.judgeId) || + a.at - b.at || + compareText(a.id, b.id), + ); + + const adjusted = new Map(); + const judges: JudgeCalibration[] = []; + + for (const pool of groupBy(graded, (g) => g.challengeId).values()) { + const mu = mean(pool.map((g) => g.score)); + + const byJudge = [...groupBy(pool, (g) => g.judgeId).values()].map( + (grades) => { + const n = grades.length; + const scores = grades.map((g) => g.score); + const centre = (n - 1) / 2; + + // Least-squares slope of score on the centred order index. + let slope = 0; + if (n >= MIN_DRIFT_GRADES) { + const average = mean(scores); + let along = 0; + let across = 0; + scores.forEach((score, index) => { + along += (index - centre) * (score - average); + across += (index - centre) ** 2; + }); + slope = (along / across) * shrink(n, K_D); + } + const detrended = scores.map( + (score, index) => score - slope * (index - centre), + ); + return { grades, slope, detrended }; + }, + ); + + // What a judge's spread is measured against: the pool's spread once each + // judge's own mean is taken out. The plain spread of the pool also holds + // the gaps between judges' means, so beside a lenient judge a strict one + // with an ordinary spread would look bunched, and both would be stretched. + // A judge with one grade has no spread of their own and is left out. + let squares = 0; + let counted = 0; + for (const { detrended } of byJudge) { + if (detrended.length < 2) continue; + const centre = mean(detrended); + for (const value of detrended) squares += (value - centre) ** 2; + counted += detrended.length; + } + const sd = counted > 0 ? Math.sqrt(squares / counted) : 0; + + for (const { grades, slope, detrended } of byJudge) { + const n = grades.length; + const offset = shrink(n, K_A) * (mean(detrended) - mu); + + // A judge who gave every project the same score has a spread of 0, which + // would be a divisor near 0 below. The clamp stops the divisor at 0.5 + // (reached once they have 6 or more grades; with fewer, the shrink keeps + // it higher), so what is left of their score after the offset is at most + // doubled. Every project they graded lands on the same value, inside + // 1 to 5: they said nothing about which project was better. + let spread = 1; + if (n >= 2 && sd > FLAT) { + spread = clamp( + 1 + shrink(n, K_B) * (populationSd(detrended) / sd - 1), + MIN_SPREAD, + MAX_SPREAD, + ); + } + + grades.forEach((grade, index) => { + adjusted.set( + grade.id, + clamp( + mu + ((detrended[index] as number) - mu - offset) / spread, + MIN_RATING, + MAX_RATING, + ), + ); + }); + judges.push({ + judgeId: grades[0]!.judgeId, + challengeId: grades[0]!.challengeId, + evaluations: n, + offset, + spread, + drift: slope * (n - 1), + }); + } + } + + return { adjusted, judges }; +} diff --git a/packages/api/src/services/judging/challenges.test.ts b/packages/api/src/services/judging/challenges.test.ts new file mode 100644 index 00000000..93a721b9 --- /dev/null +++ b/packages/api/src/services/judging/challenges.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it } from "vitest"; +import { + STARTER_GROUPS, + chunks, + derivedMemberships, + groupForLabel, + groupingProblem, + idForLabel, + initializeGroups, + planLabels, + resolveLabels, +} from "./challenges"; +import type { ChallengeRow } from "./challenges"; +import type { JudgingConfig, JudgingDb } from "./config"; + +const challenge = ( + id: string, + over: Partial = {}, +): ChallengeRow => ({ + id, + label: id, + parentId: null, + isGroup: false, + isGeneral: false, + importLabelMatch: null, + mode: "scheduled", + ...over, +}); + +const general = challenge("general", { + label: "General", + isGroup: true, + isGeneral: true, +}); +const mlh = challenge("mlh", { + label: "MLH Challenges", + isGroup: true, + importLabelMatch: "MLH", + mode: "unscheduled", +}); +const sponsors = challenge("sponsors", { + label: "Sponsor Prizes", + isGroup: true, + importLabelMatch: "Best Use of", +}); +const snowflake = challenge("snowflake", { label: "Best Use of Snowflake" }); +const mongo = challenge("mongo", { + label: "[MLH] Best Use of MongoDB Atlas", + parentId: "mlh", +}); + +describe("chunks", () => { + it("slices without losing or repeating items", () => { + expect(chunks([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + expect(chunks([], 2)).toEqual([]); + }); +}); + +describe("groupForLabel", () => { + const tree = [general, mlh, sponsors, snowflake, mongo]; + + it("files a label under the first group whose match it contains", () => { + expect(groupForLabel("[MLH] Best Use of Gemini API", tree)?.id).toBe( + "mlh", + ); + expect(groupForLabel("Best Use of Databricks", tree)?.id).toBe("sponsors"); + }); + + it("ignores case", () => { + expect(groupForLabel("mlh: best domain name", tree)?.id).toBe("mlh"); + }); + + it("is null when no group matches, and never picks a group with no match", () => { + expect(groupForLabel("Best Overall", tree)).toBeNull(); + expect( + groupForLabel("General", [challenge("g", { isGroup: true })]), + ).toBeNull(); + }); + + it("skips groups that are blank or not at the top level", () => { + const blank = challenge("blank", { isGroup: true, importLabelMatch: " " }); + const nested = challenge("nested", { + isGroup: true, + importLabelMatch: "Best", + parentId: "x", + }); + expect(groupForLabel("Best Hack", [blank, nested])).toBeNull(); + }); +}); + +describe("planLabels", () => { + it("reuses challenges by label, ignoring case and spacing, never groups", () => { + const plan = planLabels( + ["best use of snowflake", "MLH Challenges", "Best Overall", "best overall"], + [general, mlh, snowflake], + ); + expect([...plan.reuse]).toEqual([["best use of snowflake", "snowflake"]]); + expect(plan.create).toEqual([ + { label: "MLH Challenges", parentId: "mlh" }, + { label: "Best Overall", parentId: null }, + ]); + }); + + it("files new labels under matching groups", () => { + const plan = planLabels( + ["[MLH] Best Use of Gemini API", "Best Use of Databricks"], + [general, mlh, sponsors], + ); + expect(plan.create).toEqual([ + { label: "[MLH] Best Use of Gemini API", parentId: "mlh" }, + { label: "Best Use of Databricks", parentId: "sponsors" }, + ]); + }); + + it("skips blank labels", () => { + expect(planLabels([" ", ""], []).create).toEqual([]); + }); +}); + +describe("derivedMemberships", () => { + const tree = [general, mlh, snowflake, mongo]; + + it("adds each opt-in's group and every general challenge", () => { + const rows = derivedMemberships( + [ + { entryId: "e1", challengeId: "mongo" }, + { entryId: "e1", challengeId: "snowflake" }, + ], + ["e1", "e2"], + tree, + ); + expect(rows).toEqual([ + { entryId: "e1", challengeId: "mlh" }, + { entryId: "e1", challengeId: "general" }, + { entryId: "e2", challengeId: "general" }, + ]); + }); + + it("leaves out rows that are already opt-ins, and repeats nothing", () => { + const rows = derivedMemberships( + [ + { entryId: "e1", challengeId: "general" }, + { entryId: "e1", challengeId: "mongo" }, + { entryId: "e1", challengeId: "mongo" }, + ], + ["e1"], + tree, + ); + expect(rows).toEqual([{ entryId: "e1", challengeId: "mlh" }]); + }); + + it("ignores a parent that is not in the list", () => { + const orphan = challenge("orphan", { parentId: "deleted-group" }); + expect( + derivedMemberships( + [{ entryId: "e1", challengeId: "orphan" }], + ["e1"], + [orphan], + ), + ).toEqual([]); + }); +}); + +describe("groupingProblem", () => { + const tree = [general, mlh, snowflake, mongo]; + const plain = { id: "snowflake", isGroup: false, isGeneral: false }; + + it("allows the top level and a top-level group", () => { + expect(groupingProblem(tree, plain, null)).toBeNull(); + expect(groupingProblem(tree, plain, "mlh")).toBeNull(); + expect( + groupingProblem(tree, { id: null, isGroup: false, isGeneral: false }, "general"), + ).toBeNull(); + }); + + it("refuses a parent that is not a group, missing, or itself", () => { + expect(groupingProblem(tree, plain, "mongo")).toMatch(/Only a group/); + expect(groupingProblem(tree, plain, "nope")).toMatch(/not part of/); + expect( + groupingProblem(tree, { ...plain, id: "mlh", isGroup: true }, "mlh"), + ).toMatch(/inside itself/); + }); + + it("refuses nesting groups and moving a general challenge down", () => { + expect( + groupingProblem(tree, { id: "sponsors", isGroup: true, isGeneral: false }, "mlh"), + ).toMatch(/A group cannot sit inside/); + expect( + groupingProblem(tree, { ...plain, isGeneral: true }, "mlh"), + ).toMatch(/general challenge/); + }); + + it("refuses a group that is itself nested", () => { + const nestedGroup = challenge("nested", { isGroup: true, parentId: "mlh" }); + expect(groupingProblem([...tree, nestedGroup], plain, "nested")).toMatch( + /top level/, + ); + }); +}); + +// Records every query-builder call; awaiting a builder resolves the next +// queued result for its kind of statement. +type Op = "select" | "insert" | "update" | "delete"; +function fakeDb(queue: Partial> = {}) { + const log: { op: Op; calls: [string, unknown[]][] }[] = []; + const start = (op: Op) => { + const entry = { op, calls: [] as [string, unknown[]][] }; + log.push(entry); + const builder: object = new Proxy( + {}, + { + get(_target, prop) { + if (prop === "then") { + const result = queue[op]?.shift() ?? []; + return (resolve: (value: unknown) => void) => resolve(result); + } + return (...args: unknown[]) => { + entry.calls.push([String(prop), args]); + return builder; + }; + }, + }, + ); + return builder; + }; + const db = { + select: () => start("select"), + insert: () => start("insert"), + update: () => start("update"), + delete: () => start("delete"), + }; + const argsOf = (op: Op, method: string) => + log + .filter((entry) => entry.op === op) + .flatMap((entry) => entry.calls) + .filter(([name]) => name === method) + .map(([, args]) => args[0]); + return { db: db as unknown as JudgingDb, log, argsOf }; +} + +const config = (over: Partial = {}) => + ({ + hackathonId: "h1", + groupsInitializedAt: null, + ...over, + }) as JudgingConfig; + +describe("initializeGroups", () => { + it("does nothing once the edition has been seeded", async () => { + const { db, log } = fakeDb(); + await expect( + initializeGroups(db, "h1", config({ groupsInitializedAt: new Date() })), + ).resolves.toBe(false); + expect(log).toEqual([]); + }); + + it("seeds General and MLH Challenges and records that it did", async () => { + const { db, argsOf } = fakeDb({ select: [[]] }); + await expect(initializeGroups(db, "h1", config())).resolves.toBe(true); + + const [values] = argsOf("insert", "values") as Record[][]; + expect(values).toEqual( + STARTER_GROUPS.map((group) => ({ ...group, hackathonId: "h1" })), + ); + expect(values![0]).toMatchObject({ + label: "General", + isGeneral: true, + mode: "scheduled", + }); + expect(values![1]).toMatchObject({ + label: "MLH Challenges", + importLabelMatch: "MLH", + mode: "unscheduled", + }); + const [set] = argsOf("update", "set") as Record[]; + expect(set!.groupsInitializedAt).toBeInstanceOf(Date); + }); + + it("does not compete with a group that already collects MLH labels", async () => { + const { db, argsOf } = fakeDb({ + select: [[challenge("mine", { isGroup: true, importLabelMatch: "mlh" })]], + }); + await initializeGroups(db, "h1", config()); + const [values] = argsOf("insert", "values") as Record[][]; + expect(values!.map((v) => v.importLabelMatch)).toEqual([null, null]); + }); +}); + +describe("resolveLabels", () => { + it("returns existing ids and inserts the rest once", async () => { + const { db, argsOf } = fakeDb({ + select: [[general, mlh, snowflake]], + insert: [[{ id: "new-1", label: "[MLH] Best Use of Gemini API" }]], + }); + const { ids, created } = await resolveLabels(db, "h1", [ + "Best Use of Snowflake", + "[MLH] Best Use of Gemini API", + "[mlh] best use of gemini api", + ]); + + expect(created).toBe(1); + expect(idForLabel(ids, "best use of snowflake")).toBe("snowflake"); + expect(idForLabel(ids, "[MLH] Best Use of Gemini API")).toBe("new-1"); + expect(argsOf("insert", "values")).toEqual([ + [ + { + label: "[MLH] Best Use of Gemini API", + parentId: "mlh", + hackathonId: "h1", + }, + ], + ]); + }); + + it("inserts nothing when every label is known", async () => { + const { db, log } = fakeDb({ select: [[snowflake]] }); + const { created } = await resolveLabels(db, "h1", ["Best Use of Snowflake"]); + expect(created).toBe(0); + expect(log.some((entry) => entry.op === "insert")).toBe(false); + }); +}); diff --git a/packages/api/src/services/judging/challenges.ts b/packages/api/src/services/judging/challenges.ts new file mode 100644 index 00000000..08d4ee8c --- /dev/null +++ b/packages/api/src/services/judging/challenges.ts @@ -0,0 +1,398 @@ +import { TRPCError } from "@trpc/server"; +import { and, asc, eq, inArray } from "drizzle-orm"; +import { + judgingChallenges, + judgingConfigs, + judgingEntries, + judgingEntryChallenges, + judgingEvaluationDrafts, + judgingEvaluations, + judgingSchedules, +} from "@query/db"; +import { assertHackathonExists, ensureJudgingConfig } from "./config"; +import type { JudgingConfig, JudgingDb } from "./config"; +import { normalizeLabel } from "./scope"; +import type { ChallengeMode } from "./scope"; + +// The challenge tree an edition is judged in, and the rows that put projects +// into it. A project opts into challenges (from its Devpost prizes or portal +// tracks); membership of the challenge's group and of every general challenge +// follows from that and is rebuilt, never edited by hand. + +/** Postgres caps a statement at 65535 parameters; inserts go in slices. */ +export function chunks(items: readonly T[], size = 500): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) { + out.push(items.slice(i, i + size)); + } + return out; +} + +const labelKey = (label: string) => normalizeLabel(label).toLowerCase(); + +/** + * Serialises organiser writes for one edition: imports, resets, room and + * challenge changes all take this row lock before reading what they check. + * Returns the edition and its config as they stand under the lock. + */ +export async function lockEdition(tx: JudgingDb, hackathonId: string) { + const hackathon = await assertHackathonExists(tx, hackathonId); + await ensureJudgingConfig(tx, hackathonId); + const [config] = await tx + .select() + .from(judgingConfigs) + .where(eq(judgingConfigs.hackathonId, hackathonId)) + .for("update") + .limit(1); + return { hackathon, config: config! }; +} + +/** What has happened that freezes the shape of judging. */ +export async function readSetupLock(tx: JudgingDb, hackathonId: string) { + const [[schedule], [evaluation], [draft]] = await Promise.all([ + tx + .select({ id: judgingSchedules.id }) + .from(judgingSchedules) + .where(eq(judgingSchedules.hackathonId, hackathonId)) + .limit(1), + tx + .select({ id: judgingEvaluations.id }) + .from(judgingEvaluations) + .where(eq(judgingEvaluations.hackathonId, hackathonId)) + .limit(1), + tx + .select({ id: judgingEvaluationDrafts.id }) + .from(judgingEvaluationDrafts) + .where(eq(judgingEvaluationDrafts.hackathonId, hackathonId)) + .limit(1), + ]); + return { + scheduleSaved: !!schedule, + hasEvaluations: !!evaluation || !!draft, + }; +} + +/** + * Refuses a change to the challenge tree (grouping, mode, general) once a + * schedule is saved or a judge has started scoring: both were built against + * the tree as it was. + */ +export async function assertStructureEditable( + tx: JudgingDb, + hackathonId: string, +) { + const lock = await readSetupLock(tx, hackathonId); + if (lock.scheduleSaved) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "A saved schedule was built on these challenges. Drop the schedule on the Schedule tab before regrouping them.", + }); + } + if (lock.hasEvaluations) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "Judges have started scoring these challenges, so their grouping is fixed. Rename them if you need to; drop evaluations from Resets to regroup.", + }); + } +} + +// --------------------------------------------------------------------------- +// Pure rules + +export type ChallengeRow = { + id: string; + label: string; + parentId: string | null; + isGroup: boolean; + isGeneral: boolean; + importLabelMatch: string | null; + mode: ChallengeMode; +}; + +/** Every edition starts with these two groups. */ +export const STARTER_GROUPS = [ + { + label: "General", + isGroup: true, + isGeneral: true, + mode: "scheduled", + importLabelMatch: null, + displayOrder: 0, + }, + { + label: "MLH Challenges", + isGroup: true, + isGeneral: false, + mode: "unscheduled", + importLabelMatch: "MLH", + displayOrder: 1, + }, +] as const; + +/** + * The group a newly seen label is filed under: the first top-level group + * whose import match appears in the label, ignoring case. `challenges` must + * already be in display order. + */ +export function groupForLabel( + label: string, + challenges: readonly ChallengeRow[], +): ChallengeRow | null { + const haystack = label.toLowerCase(); + return ( + challenges.find( + (c) => + c.isGroup && + c.parentId === null && + !!c.importLabelMatch?.trim() && + haystack.includes(c.importLabelMatch.trim().toLowerCase()), + ) ?? null + ); +} + +export type LabelPlan = { + /** Labels that name a challenge already there, by label key. */ + reuse: Map; + /** Labels to insert, each once, with the group they belong under. */ + create: { label: string; parentId: string | null }[]; +}; + +/** + * Matches labels to existing (non-group) challenges ignoring case and + * spacing, and plans the rest. Groups are never matched: a project opts into + * a prize, and group membership follows from it. + */ +export function planLabels( + labels: readonly string[], + challenges: readonly ChallengeRow[], +): LabelPlan { + const byKey = new Map(); + for (const c of challenges) { + if (!c.isGroup && !byKey.has(labelKey(c.label))) { + byKey.set(labelKey(c.label), c.id); + } + } + + const reuse = new Map(); + const create: LabelPlan["create"] = []; + const planned = new Set(); + for (const raw of labels) { + const label = normalizeLabel(raw); + const key = labelKey(label); + if (!label) continue; + const id = byKey.get(key); + if (id) { + reuse.set(key, id); + } else if (!planned.has(key)) { + planned.add(key); + create.push({ + label, + parentId: groupForLabel(label, challenges)?.id ?? null, + }); + } + } + return { reuse, create }; +} + +type MembershipKey = { entryId: string; challengeId: string }; + +/** + * The membership rows that follow from opt-ins: each opted-in challenge's + * group, and every general challenge for every project. Rows that are + * already opt-ins are left out, since the opt-in row covers them. + */ +export function derivedMemberships( + optIns: readonly MembershipKey[], + entryIds: readonly string[], + challenges: readonly Pick[], +): MembershipKey[] { + const byId = new Map(challenges.map((c) => [c.id, c])); + const taken = new Set(optIns.map((m) => `${m.entryId}:${m.challengeId}`)); + const out: MembershipKey[] = []; + const add = (entryId: string, challengeId: string) => { + const key = `${entryId}:${challengeId}`; + if (taken.has(key)) return; + taken.add(key); + out.push({ entryId, challengeId }); + }; + + for (const optIn of optIns) { + const parentId = byId.get(optIn.challengeId)?.parentId; + if (parentId && byId.has(parentId)) add(optIn.entryId, parentId); + } + const general = challenges.filter((c) => c.isGeneral); + for (const entryId of entryIds) { + for (const challenge of general) add(entryId, challenge.id); + } + return out; +} + +/** + * Why `id` cannot sit under `parentId` with the given shape, or null when it + * can. Groups hold plain challenges one level deep; a general challenge + * covers every project and so stays at the top. + */ +export function groupingProblem( + challenges: readonly Pick[], + child: { id: string | null; isGroup: boolean; isGeneral: boolean }, + parentId: string | null, +): string | null { + if (parentId === null) return null; + if (parentId === child.id) return "A challenge cannot sit inside itself."; + const parent = challenges.find((c) => c.id === parentId); + if (!parent) return "That group is not part of this hackathon."; + if (!parent.isGroup) return "Only a group can hold challenges."; + if (parent.parentId !== null) { + return "Groups sit at the top level; pick a top-level group."; + } + if (child.isGroup) return "A group cannot sit inside another group."; + if (child.isGeneral) { + return "A general challenge covers every project, so it stays at the top level."; + } + return null; +} + +// --------------------------------------------------------------------------- +// Database + +// A function, not a constant: read at module load, the columns break every +// suite that mocks @query/db without the judging tables. +const challengeColumns = () => ({ + id: judgingChallenges.id, + label: judgingChallenges.label, + parentId: judgingChallenges.parentId, + isGroup: judgingChallenges.isGroup, + isGeneral: judgingChallenges.isGeneral, + importLabelMatch: judgingChallenges.importLabelMatch, + mode: judgingChallenges.mode, +}); + +/** The edition's challenges in display order. */ +export function readChallenges(tx: JudgingDb, hackathonId: string) { + return tx + .select(challengeColumns()) + .from(judgingChallenges) + .where(eq(judgingChallenges.hackathonId, hackathonId)) + .orderBy( + asc(judgingChallenges.displayOrder), + asc(judgingChallenges.createdAt), + asc(judgingChallenges.id), + ); +} + +/** + * Creates the General and MLH Challenges groups, once per edition. A second + * call is a no-op even after an organiser deleted them. If a group already + * collects MLH labels, the starter one does not compete with it. + */ +export async function initializeGroups( + tx: JudgingDb, + hackathonId: string, + config?: JudgingConfig, +): Promise { + const current = config ?? (await ensureJudgingConfig(tx, hackathonId)); + if (current.groupsInitializedAt) return false; + + const existing = await readChallenges(tx, hackathonId); + const mlhTaken = existing.some( + (c) => c.isGroup && c.importLabelMatch?.trim().toLowerCase() === "mlh", + ); + + await tx + .insert(judgingChallenges) + .values( + STARTER_GROUPS.map((group) => ({ + ...group, + hackathonId, + importLabelMatch: mlhTaken ? null : group.importLabelMatch, + })), + ) + .onConflictDoNothing(); + + const now = new Date(); + await tx + .update(judgingConfigs) + .set({ groupsInitializedAt: now, updatedAt: now }) + .where(eq(judgingConfigs.hackathonId, hackathonId)); + return true; +} + +/** + * The challenge id for each label, creating the ones not seen before. + * Keyed by the label with case and spacing normalised. + */ +export async function resolveLabels( + tx: JudgingDb, + hackathonId: string, + labels: readonly string[], +): Promise<{ ids: Map; created: number }> { + const plan = planLabels(labels, await readChallenges(tx, hackathonId)); + const ids = new Map(plan.reuse); + if (plan.create.length > 0) { + const inserted = await tx + .insert(judgingChallenges) + .values(plan.create.map((c) => ({ ...c, hackathonId }))) + .returning({ id: judgingChallenges.id, label: judgingChallenges.label }); + for (const row of inserted) ids.set(labelKey(row.label), row.id); + } + return { ids, created: plan.create.length }; +} + +/** Looks a label up in the map resolveLabels returned. */ +export function idForLabel(ids: Map, label: string) { + return ids.get(labelKey(label)); +} + +/** + * Recomputes the derived memberships (isOptIn = false) for some entries, or + * every entry of the edition. Opt-in rows are never touched. + */ +export async function rebuildDerivedMemberships( + tx: JudgingDb, + hackathonId: string, + entryIds?: readonly string[], +) { + if (entryIds && entryIds.length === 0) return; + + const scopes = entryIds ? chunks(entryIds, 1000) : [null]; + const challenges = await readChallenges(tx, hackathonId); + + for (const scope of scopes) { + const where = and( + eq(judgingEntryChallenges.hackathonId, hackathonId), + scope ? inArray(judgingEntryChallenges.entryId, scope) : undefined, + ); + await tx + .delete(judgingEntryChallenges) + .where(and(where, eq(judgingEntryChallenges.isOptIn, false))); + + const optIns = await tx + .select({ + entryId: judgingEntryChallenges.entryId, + challengeId: judgingEntryChallenges.challengeId, + }) + .from(judgingEntryChallenges) + .where(where); + + const ids = + scope ?? + ( + await tx + .select({ id: judgingEntries.id }) + .from(judgingEntries) + .where(eq(judgingEntries.hackathonId, hackathonId)) + ).map((row) => row.id); + + const derived = derivedMemberships(optIns, ids, challenges); + for (const slice of chunks(derived, 1000)) { + await tx + .insert(judgingEntryChallenges) + .values( + slice.map((row) => ({ ...row, hackathonId, isOptIn: false })), + ) + .onConflictDoNothing(); + } + } +} diff --git a/packages/api/src/services/judging/checklist.test.ts b/packages/api/src/services/judging/checklist.test.ts new file mode 100644 index 00000000..e66fff62 --- /dev/null +++ b/packages/api/src/services/judging/checklist.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest"; +import { + buildChecklist, + rubricLockReason, + stateChangeProblem, +} from "./checklist"; +import type { ChecklistFacts } from "./checklist"; + +const ready: ChecklistFacts = { + entries: { devpost: 180, portal: 12 }, + ratingItems: 4, + groupsInitialized: true, + scheduledRoots: [ + { + label: "General", + rooms: [ + { name: "Klaus 1116", kind: "room", hasBuilding: true, hasLink: true }, + { name: "Klaus 1447", kind: "room", hasBuilding: true, hasLink: true }, + ], + }, + ], + scheduleSaved: true, + state: "open", + claimsStarted: true, + itineraryPublished: true, +}; + +const item = (facts: ChecklistFacts, key: string) => + buildChecklist(facts).find((i) => i.key === key)!; + +describe("buildChecklist", () => { + it("lists every step, in order, done when everything is in place", () => { + const items = buildChecklist(ready); + expect(items.map((i) => i.key)).toEqual([ + "projects", + "rubric", + "rooms", + "schedule", + "open", + "claims", + "itinerary", + ]); + expect(items.every((i) => i.done)).toBe(true); + expect(item(ready, "projects").hint).toBe( + "192 projects: 180 from Devpost, 12 from portal submissions.", + ); + expect(item(ready, "rooms").hint).toBe( + "2 rooms across 1 scheduled challenge, each with a building and a QR link.", + ); + }); + + it("counts tables apart from rooms", () => { + const withTables: ChecklistFacts = { + ...ready, + scheduledRoots: [ + { + label: "General", + rooms: [1, 2, 3].map((n) => ({ + name: `Table ${n}`, + kind: "table" as const, + hasBuilding: true, + hasLink: true, + })), + }, + ...ready.scheduledRoots.map((root) => ({ ...root, label: "Sponsor" })), + ], + }; + expect(item(withTables, "rooms").hint).toBe( + "2 rooms and 3 tables across 2 scheduled challenges, each with a building and a QR link.", + ); + }); + + it("starts with nothing done on a new edition", () => { + const fresh: ChecklistFacts = { + entries: { devpost: 0, portal: 0 }, + ratingItems: 0, + groupsInitialized: false, + scheduledRoots: [], + scheduleSaved: false, + state: "draft", + claimsStarted: false, + itineraryPublished: false, + }; + const items = buildChecklist(fresh); + expect(items.filter((i) => i.done).map((i) => i.key)).toEqual([ + // Nothing scheduled has projects, and no Devpost projects need claiming. + "rooms", + "claims", + ]); + expect(item(fresh, "rubric").hint).toMatch(/starter groups/); + }); + + it("asks for a rating once the groups exist", () => { + const facts = { ...ready, ratingItems: 0 }; + expect(item(facts, "rubric")).toMatchObject({ + done: false, + hint: "Add at least one rating question to the rubric.", + }); + }); + + it("names the first scheduled challenge or room that is not ready", () => { + const noRoom = { + ...ready, + scheduledRoots: [...ready.scheduledRoots, { label: "Best Use of Snowflake", rooms: [] }], + }; + expect(item(noRoom, "rooms")).toMatchObject({ + done: false, + hint: "Best Use of Snowflake has no room or table. Add one on the Rooms tab.", + }); + + const noBuilding = { + ...ready, + scheduledRoots: [ + { + label: "General", + rooms: [ + { + name: "Klaus 1116", + kind: "room" as const, + hasBuilding: true, + hasLink: false, + }, + { + name: "Annex", + kind: "room" as const, + hasBuilding: false, + hasLink: true, + }, + ], + }, + ], + }; + // A missing building comes first: the schedule cannot be built without it. + expect(item(noBuilding, "rooms").hint).toMatch(/^Annex has no building/); + + const noLink = { + ...ready, + scheduledRoots: [ + { + label: "General", + rooms: [ + { + name: "Klaus 1116", + kind: "room" as const, + hasBuilding: true, + hasLink: false, + }, + ], + }, + ], + }; + expect(item(noLink, "rooms").hint).toBe( + "Klaus 1116 has no QR link. Generate one on the Rooms tab.", + ); + }); + + it("treats claims as done when every project came from the portal", () => { + const portalOnly = { + ...ready, + entries: { devpost: 0, portal: 40 }, + claimsStarted: false, + }; + expect(item(portalOnly, "claims").done).toBe(true); + expect( + item({ ...ready, claimsStarted: false }, "claims"), + ).toMatchObject({ done: false, hint: expect.stringMatching(/claim links/) }); + }); + + it("says when judging was closed rather than never opened", () => { + expect(item({ ...ready, state: "closed" }, "open")).toMatchObject({ + done: false, + hint: expect.stringMatching(/closed. Reopen/), + }); + }); +}); + +describe("stateChangeProblem", () => { + const facts = { ratingItems: 3, hasEvaluations: false }; + + it("allows draft to open, open to closed and back", () => { + expect(stateChangeProblem("draft", "open", facts)).toBeNull(); + expect(stateChangeProblem("open", "closed", facts)).toBeNull(); + expect(stateChangeProblem("closed", "open", facts)).toBeNull(); + expect(stateChangeProblem("open", "open", facts)).toBeNull(); + }); + + it("needs a rating question to open", () => { + expect( + stateChangeProblem("draft", "open", { ...facts, ratingItems: 0 }), + ).toMatch(/rating question/); + }); + + it("cannot close what was never opened", () => { + expect(stateChangeProblem("draft", "closed", facts)).toMatch(/not been opened/); + }); + + it("goes back to draft only before anyone scores", () => { + expect(stateChangeProblem("open", "draft", facts)).toBeNull(); + expect(stateChangeProblem("closed", "draft", facts)).toBeNull(); + expect( + stateChangeProblem("closed", "draft", { ...facts, hasEvaluations: true }), + ).toMatch(/cannot go back to draft/); + }); +}); + +describe("rubricLockReason", () => { + const draft = { state: "draft" as const, scheduleSaved: false, hasEvaluations: false }; + + it("is editable in draft with nothing saved", () => { + expect(rubricLockReason(draft)).toBeNull(); + }); + + it("locks once judging opens, scores exist or a schedule is saved", () => { + expect(rubricLockReason({ ...draft, state: "open" })).toMatch(/opened/); + expect(rubricLockReason({ ...draft, hasEvaluations: true })).toMatch( + /scored/, + ); + expect(rubricLockReason({ ...draft, scheduleSaved: true })).toMatch( + /schedule is saved/, + ); + }); +}); diff --git a/packages/api/src/services/judging/checklist.ts b/packages/api/src/services/judging/checklist.ts new file mode 100644 index 00000000..45882e78 --- /dev/null +++ b/packages/api/src/services/judging/checklist.ts @@ -0,0 +1,323 @@ +import { and, count, countDistinct, eq, isNull, sql } from "drizzle-orm"; +import { + judgingChallenges, + judgingEntries, + judgingEntryChallenges, + judgingRoomLinks, + judgingRooms, + judgingRubricItems, + judgingSchedules, +} from "@query/db"; +import type { JudgingRoomKind } from "@query/db"; +import { ensureJudgingConfig } from "./config"; +import type { JudgingDb } from "./config"; +import { outerColumn } from "./outer-column"; + +// The organiser's launch checklist: what has to be true before judges take +// their rooms and tables and hackers look up their times. Reads every area's +// tables directly; nothing here writes. + +export type ChecklistFacts = { + entries: { devpost: number; portal: number }; + ratingItems: number; + groupsInitialized: boolean; + /** Top-level scheduled challenges that have projects in them. */ + scheduledRoots: { + label: string; + rooms: { + name: string; + kind: JudgingRoomKind; + hasBuilding: boolean; + hasLink: boolean; + }[]; + }[]; + scheduleSaved: boolean; + state: "draft" | "open" | "closed"; + claimsStarted: boolean; + itineraryPublished: boolean; +}; + +export type ChecklistItem = { + key: + | "projects" + | "rubric" + | "rooms" + | "schedule" + | "open" + | "claims" + | "itinerary"; + label: string; + done: boolean; + /** One line: what is there, or what to do next. */ + hint: string; +}; + +const plural = (n: number, one: string, many = `${one}s`) => + `${n} ${n === 1 ? one : many}`; + +export type JudgingState = ChecklistFacts["state"]; + +/** + * Why judging cannot move from `from` to `to`, or null when it can. Draft + * opens once the rubric has a rating to give; open and closed swap freely; + * going back to draft is only for before anyone has scored, drafts + * included. + */ +export function stateChangeProblem( + from: JudgingState, + to: JudgingState, + facts: { ratingItems: number; hasEvaluations: boolean }, +): string | null { + if (from === to) return null; + if (to === "open") { + return from === "draft" && facts.ratingItems === 0 + ? "Add at least one rating question to the rubric before opening judging." + : null; + } + if (to === "closed") { + return from === "draft" + ? "Judging has not been opened yet, so there is nothing to close." + : null; + } + return facts.hasEvaluations + ? "Judges have started scoring, so judging cannot go back to draft. Close it instead, or drop evaluations from Resets." + : null; +} + +/** + * Why the rubric cannot be edited now, or null when it can. Evaluations are + * stored against its items, so it is fixed from the moment judging opens. + */ +export function rubricLockReason(facts: { + state: JudgingState; + scheduleSaved: boolean; + hasEvaluations: boolean; +}): string | null { + if (facts.hasEvaluations) { + return "Judges have scored with this rubric. Drop evaluations from Resets to change it."; + } + if (facts.state !== "draft") { + return "Judging has been opened, so the rubric is fixed. Set judging back to draft to edit it."; + } + if (facts.scheduleSaved) { + return "A schedule is saved. Drop it on the Schedule tab to change the rubric."; + } + return null; +} + +function roomsItem(facts: ChecklistFacts): Pick { + if (facts.scheduledRoots.length === 0) { + return { + done: true, + hint: "No scheduled challenge has projects yet, so no room or table is needed.", + }; + } + for (const root of facts.scheduledRoots) { + if (root.rooms.length === 0) { + return { + done: false, + hint: `${root.label} has no room or table. Add one on the Rooms tab.`, + }; + } + const room = + root.rooms.find((r) => !r.hasBuilding) ?? + root.rooms.find((r) => !r.hasLink); + if (room) { + return { + done: false, + hint: room.hasBuilding + ? `${room.name} has no QR link. Generate one on the Rooms tab.` + : `${room.name} has no building. The schedule needs it to plan the walk between presentations.`, + }; + } + } + const stations = facts.scheduledRoots.flatMap((r) => r.rooms); + const tables = stations.filter((s) => s.kind === "table").length; + const rooms = stations.length - tables; + const counted = [ + rooms > 0 ? plural(rooms, "room") : null, + tables > 0 ? plural(tables, "table") : null, + ] + .filter(Boolean) + .join(" and "); + return { + done: true, + hint: `${counted} across ${plural(facts.scheduledRoots.length, "scheduled challenge")}, each with a building and a QR link.`, + }; +} + +/** The checklist, in the order an organiser works through it. */ +export function buildChecklist(facts: ChecklistFacts): ChecklistItem[] { + const total = facts.entries.devpost + facts.entries.portal; + const rubricReady = facts.ratingItems > 0 && facts.groupsInitialized; + const rooms = roomsItem(facts); + + return [ + { + key: "projects", + label: "Projects imported", + done: total > 0, + hint: + total > 0 + ? `${plural(total, "project")}: ${facts.entries.devpost} from Devpost, ${facts.entries.portal} from portal submissions.` + : "Import the Devpost submissions export, or promote portal submissions.", + }, + { + key: "rubric", + label: "Rubric and challenge groups ready", + done: rubricReady, + hint: !facts.groupsInitialized + ? "Create the starter groups on the Challenges tab." + : facts.ratingItems === 0 + ? "Add at least one rating question to the rubric." + : `${plural(facts.ratingItems, "rating question")}; starter groups are in place.`, + }, + { + key: "rooms", + label: "Rooms or tables set up for scheduled challenges", + ...rooms, + }, + { + key: "schedule", + label: "Schedule saved", + done: facts.scheduleSaved, + hint: facts.scheduleSaved + ? "Judges and hackers see the saved times." + : "Preview a schedule on the Schedule tab and save it.", + }, + { + key: "open", + label: "Judging open", + done: facts.state === "open", + hint: + facts.state === "open" + ? "Judges can submit and edit evaluations." + : facts.state === "closed" + ? "Judging is closed. Reopen it to let judges submit again." + : "Open judging when judges are ready to score.", + }, + { + key: "claims", + label: "Claims started", + done: facts.claimsStarted || facts.entries.devpost === 0, + hint: facts.claimsStarted + ? "Hackers have started claiming their Devpost projects." + : facts.entries.devpost === 0 + ? "Every project came from the portal, so hackers are already linked." + : "Email claim links so hackers can find their projects.", + }, + { + key: "itinerary", + label: "Itinerary published", + done: facts.itineraryPublished, + hint: facts.itineraryPublished + ? "Hackers can see where and when they present." + : "Publish the itinerary once the schedule is final.", + }, + ]; +} + +/** Gathers the checklist's facts for one edition. */ +export async function readChecklistFacts( + db: JudgingDb, + hackathonId: string, +): Promise { + const config = await ensureJudgingConfig(db, hackathonId); + + const [entryCounts, [ratings], [schedule], roots, rooms] = await Promise.all([ + db + .select({ source: judgingEntries.source, value: count() }) + .from(judgingEntries) + .where( + and( + eq(judgingEntries.hackathonId, hackathonId), + isNull(judgingEntries.deletedAt), + ), + ) + .groupBy(judgingEntries.source), + db + .select({ value: count() }) + .from(judgingRubricItems) + .where( + and( + eq(judgingRubricItems.hackathonId, hackathonId), + eq(judgingRubricItems.kind, "rating"), + ), + ), + db + .select({ id: judgingSchedules.id }) + .from(judgingSchedules) + .where(eq(judgingSchedules.hackathonId, hackathonId)) + .limit(1), + // A root's projects arrive as opt-ins (plain challenges) or derived rows + // (groups), so a membership row on the root is enough. + db + .select({ + id: judgingChallenges.id, + label: judgingChallenges.label, + entries: countDistinct(judgingEntries.id), + }) + .from(judgingChallenges) + .innerJoin( + judgingEntryChallenges, + eq(judgingEntryChallenges.challengeId, judgingChallenges.id), + ) + .innerJoin( + judgingEntries, + and( + eq(judgingEntries.id, judgingEntryChallenges.entryId), + isNull(judgingEntries.deletedAt), + ), + ) + .where( + and( + eq(judgingChallenges.hackathonId, hackathonId), + isNull(judgingChallenges.parentId), + eq(judgingChallenges.mode, "scheduled"), + ), + ) + .groupBy(judgingChallenges.id, judgingChallenges.label), + db + .select({ + name: judgingRooms.name, + kind: judgingRooms.kind, + challengeId: judgingRooms.challengeId, + hasBuilding: sql`${judgingRooms.buildingId} is not null`, + hasLink: sql`exists (select 1 from ${judgingRoomLinks} where ${judgingRoomLinks.roomId} = ${outerColumn(judgingRooms, judgingRooms.id)} and ${judgingRoomLinks.revokedAt} is null)`, + }) + .from(judgingRooms) + .where( + and( + eq(judgingRooms.hackathonId, hackathonId), + isNull(judgingRooms.archivedAt), + ), + ) + .orderBy(judgingRooms.displayOrder, judgingRooms.name), + ]); + + const entries = { devpost: 0, portal: 0 }; + for (const row of entryCounts) entries[row.source] = row.value; + + return { + entries, + ratingItems: ratings?.value ?? 0, + groupsInitialized: !!config.groupsInitializedAt, + scheduledRoots: roots + .sort((a, b) => a.label.localeCompare(b.label)) + .map((root) => ({ + label: root.label, + rooms: rooms + .filter((room) => room.challengeId === root.id) + .map(({ name, kind, hasBuilding, hasLink }) => ({ + name, + kind, + hasBuilding, + hasLink, + })), + })), + scheduleSaved: !!schedule, + state: config.state, + claimsStarted: !!config.claimsStartedAt, + itineraryPublished: config.itineraryPublished, + }; +} diff --git a/packages/api/src/services/judging/claims.test.ts b/packages/api/src/services/judging/claims.test.ts new file mode 100644 index 00000000..a4664a47 --- /dev/null +++ b/packages/api/src/services/judging/claims.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + MAX_TEAM_SIZE, + claimLinkPath, + claimLinkUsable, + decideClaim, + decideInvite, + newClaimToken, + portalOrigin, + sameEmail, + teamSize, +} from "./claims"; + +const HACK = "6f1d2c3b-4a5e-4f60-8a7b-9c0d1e2f3a4b"; +const now = new Date("2027-02-08T15:00:00Z"); +const later = new Date("2027-02-09T18:00:00Z"); +const earlier = new Date("2027-02-07T09:00:00Z"); + +describe("teamSize", () => { + it("takes the larger of Devpost's count and the roster", () => { + expect(teamSize(3, 2)).toBe(3); + expect(teamSize(2, 4)).toBe(4); + expect(teamSize(4, 4)).toBe(MAX_TEAM_SIZE); + }); +}); + +describe("sameEmail", () => { + it("ignores case and surrounding space", () => { + expect(sameEmail(" Ada@Example.com", "ada@example.com ")).toBe(true); + expect(sameEmail("ada@example.com", "ada@example.org")).toBe(false); + }); +}); + +describe("newClaimToken", () => { + it("is 32 random bytes in URL-safe base64", () => { + const token = newClaimToken(); + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(newClaimToken()).not.toBe(token); + }); +}); + +describe("claimLinkPath", () => { + it("points at the edition's claim page and escapes the token", () => { + expect(claimLinkPath(HACK, "abc")).toBe( + `/hackathons/${HACK}/claim?token=abc`, + ); + expect(claimLinkPath(HACK, "a+b/c")).toBe( + `/hackathons/${HACK}/claim?token=a%2Bb%2Fc`, + ); + }); +}); + +describe("portalOrigin", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("uses the auth URL without a trailing slash", () => { + vi.stubEnv("NEXTAUTH_URL", "https://portal.example.org/"); + expect(portalOrigin()).toBe("https://portal.example.org"); + }); + + it("falls back to the public site", () => { + vi.stubEnv("NEXTAUTH_URL", ""); + vi.stubEnv("AUTH_URL", ""); + expect(portalOrigin()).toBe("https://datasciencegt.org"); + }); +}); + +describe("claimLinkUsable", () => { + const live = { token: "t", consumedAt: null, expiresAt: later }; + + it("accepts an unused link before the event ends", () => { + expect(claimLinkUsable(live, later, now)).toBe(true); + }); + + it("refuses a used link", () => { + expect(claimLinkUsable({ ...live, token: null }, later, now)).toBe(false); + expect(claimLinkUsable({ ...live, consumedAt: earlier }, later, now)).toBe( + false, + ); + }); + + it("refuses once the link or the event has ended", () => { + expect(claimLinkUsable({ ...live, expiresAt: now }, later, now)).toBe( + false, + ); + // The end date was moved earlier after the link went out. + expect(claimLinkUsable(live, earlier, now)).toBe(false); + }); +}); + +describe("decideClaim", () => { + const open = { userId: null, entryDeleted: false, teamSize: 3 }; + + it("lets a hacker take an open place", () => { + expect(decideClaim(open, "u1", null)).toEqual({ + ok: true, + alreadyYours: false, + }); + }); + + it("treats a repeat claim of the same place as done", () => { + expect(decideClaim({ ...open, userId: "u1" }, "u1", null)).toEqual({ + ok: true, + alreadyYours: true, + }); + }); + + it("refuses a missing or pulled project", () => { + expect(decideClaim(null, "u1", null)).toMatchObject({ + ok: false, + code: "NOT_FOUND", + }); + expect( + decideClaim({ ...open, entryDeleted: true }, "u1", null), + ).toMatchObject({ ok: false, code: "NOT_FOUND" }); + }); + + it("refuses a place someone else holds", () => { + expect(decideClaim({ ...open, userId: "u2" }, "u1", null)).toMatchObject({ + ok: false, + code: "CONFLICT", + }); + }); + + it("allows one project per person, naming the one they hold", () => { + const decision = decideClaim(open, "u1", "Tide Pool"); + expect(decision).toMatchObject({ ok: false, code: "CONFLICT" }); + expect(!decision.ok && decision.message).toContain("Tide Pool"); + }); + + it("blocks teams over four", () => { + expect(decideClaim({ ...open, teamSize: 4 }, "u1", null)).toMatchObject({ + ok: true, + }); + expect(decideClaim({ ...open, teamSize: 5 }, "u1", null)).toMatchObject({ + ok: false, + code: "PRECONDITION_FAILED", + }); + }); +}); + +describe("decideInvite", () => { + const ready = { checkedIn: true, heldProject: null }; + + it("adds a checked-in hacker with no project to a team with room", () => { + expect(decideInvite({ teamSize: 3, invitee: ready, listed: null })).toEqual( + { action: "add" }, + ); + }); + + it("resends to someone already listed who has not claimed", () => { + expect( + decideInvite({ teamSize: 4, invitee: ready, listed: { claimed: false } }), + ).toEqual({ action: "resend" }); + }); + + it("says when they are already on the team", () => { + expect( + decideInvite({ teamSize: 2, invitee: ready, listed: { claimed: true } }), + ).toMatchObject({ ok: false, code: "CONFLICT" }); + expect( + decideInvite({ + teamSize: 2, + invitee: { checkedIn: true, heldProject: "this" }, + listed: null, + }), + ).toMatchObject({ ok: false, message: "They are already on your team." }); + }); + + it("gives one answer for no account, not checked in and another team", () => { + const answers = [ + decideInvite({ teamSize: 2, invitee: null, listed: null }), + decideInvite({ + teamSize: 2, + invitee: { checkedIn: false, heldProject: null }, + listed: null, + }), + decideInvite({ + teamSize: 2, + invitee: { checkedIn: true, heldProject: "other" }, + listed: null, + }), + ]; + for (const answer of answers) { + expect(answer).toMatchObject({ ok: false, code: "BAD_REQUEST" }); + } + expect( + new Set(answers.map((a) => ("message" in a ? a.message : ""))).size, + ).toBe(1); + }); + + it("stops at four, counting places not yet claimed", () => { + expect( + decideInvite({ teamSize: 4, invitee: ready, listed: null }), + ).toMatchObject({ ok: false, code: "CONFLICT" }); + }); +}); diff --git a/packages/api/src/services/judging/claims.ts b/packages/api/src/services/judging/claims.ts new file mode 100644 index 00000000..d286f23d --- /dev/null +++ b/packages/api/src/services/judging/claims.ts @@ -0,0 +1,539 @@ +import { randomBytes } from "node:crypto"; +import { TRPCError } from "@trpc/server"; +import { and, asc, count, eq, isNull } from "drizzle-orm"; +import { + hackathonParticipants, + judgingClaimLinks, + judgingConfigs, + judgingEntries, + judgingEntryMembers, +} from "@query/db"; +import { isUniqueViolation } from "../../middleware/db-errors"; +import { ensureJudgingConfig, lockInventory } from "./config"; +import type { JudgingConfig, JudgingDb, JudgingTx } from "./config"; + +// Claiming ties a judged project's team list to portal accounts. A Devpost +// export names each teammate by the email they used on Devpost; a hacker whose +// portal address is the same claims their place in one click, and anyone else +// opens a one-time link an organiser emails to the Devpost address. Portal +// submissions arrive already linked. Whoever holds a place sees the project's +// rooms, times and feedback. + +/** Judging seats at most four people on a team. */ +export const MAX_TEAM_SIZE = 4; + +/** + * A team's size as judging counts it: the larger of what Devpost reported and + * the rows on the roster, so neither a short export nor a pending invite lets a + * fifth person through. + */ +export function teamSize(participantCount: number, rosterSize: number): number { + return Math.max(participantCount, rosterSize); +} + +export function sameEmail(a: string, b: string): boolean { + return a.trim().toLowerCase() === b.trim().toLowerCase(); +} + +/** A fresh claim token: 32 random bytes, URL-safe. */ +export function newClaimToken(): string { + return randomBytes(32).toString("base64url"); +} + +/** Where a claim link lands, relative to the portal origin. */ +export function claimLinkPath(hackathonId: string, token: string): string { + return `/hackathons/${hackathonId}/claim?token=${encodeURIComponent(token)}`; +} + +/** The public portal origin, for links that leave the site in an email. */ +export function portalOrigin(): string { + const origin = + process.env.NEXTAUTH_URL || + process.env.AUTH_URL || + "https://datasciencegt.org"; + return origin.replace(/\/+$/, ""); +} + +export type ClaimLinkState = { + token: string | null; + consumedAt: Date | null; + expiresAt: Date; +}; + +/** + * Whether a link still claims anything: it holds a token, nobody used it, and + * the event has not ended. The event's end is checked as well as the stored + * expiry because organisers can move the end date after links went out. + */ +export function claimLinkUsable( + link: ClaimLinkState, + eventEnd: Date, + now: Date, +): boolean { + return ( + !!link.token && !link.consumedAt && link.expiresAt > now && eventEnd > now + ); +} + +type Refusal = { + ok: false; + code: "BAD_REQUEST" | "NOT_FOUND" | "CONFLICT" | "PRECONDITION_FAILED"; + message: string; +}; + +export type ClaimDecision = { ok: true; alreadyYours: boolean } | Refusal; + +/** + * Whether `callerId` may take a place on a team. `target` is null when the + * place does not exist; `heldElsewhere` is the title of a live project the + * caller already holds a place on, if any. + */ +export function decideClaim( + target: { + userId: string | null; + entryDeleted: boolean; + teamSize: number; + } | null, + callerId: string, + heldElsewhere: string | null, +): ClaimDecision { + if (!target || target.entryDeleted) { + return { + ok: false, + code: "NOT_FOUND", + message: + "That project is no longer in judging. Ask an organiser at the help desk.", + }; + } + // A second tap on the same button, or a link opened twice. + if (target.userId === callerId) return { ok: true, alreadyYours: true }; + if (target.userId) { + return { + ok: false, + code: "CONFLICT", + message: + "Someone has already claimed this place. If that was not you, tell an organiser.", + }; + } + if (heldElsewhere) { + return { + ok: false, + code: "CONFLICT", + message: `You already claimed ${heldElsewhere}. Each person is on one project per event; an organiser can move you.`, + }; + } + if (target.teamSize > MAX_TEAM_SIZE) { + return { + ok: false, + code: "PRECONDITION_FAILED", + message: + "This team lists more than four people. An organiser has to fix the team list before anyone can claim it.", + }; + } + return { ok: true, alreadyYours: false }; +} + +// One answer for every way an address can fail, so the invite box cannot be +// used to find out who has an account, who checked in or who is on a team. +const INVITE_UNAVAILABLE = + "That email cannot be added. Use the address your teammate signs in to the portal with, and check they have been checked in at the event."; + +export type InviteDecision = + | { action: "add" } + /** Already on the team list and not yet claimed: send them the link again. */ + | { action: "resend" } + | Refusal; + +export function decideInvite(input: { + teamSize: number; + /** + * The one account with that email, or null. `heldProject` is the live + * project it holds a place on: "this" team, "other", or null for none. + */ + invitee: { checkedIn: boolean; heldProject: "this" | "other" | null } | null; + /** A place on this team listed under that email, or null. */ + listed: { claimed: boolean } | null; +}): InviteDecision { + if (input.listed?.claimed || input.invitee?.heldProject === "this") { + return { + ok: false, + code: "CONFLICT", + message: "They are already on your team.", + }; + } + if (!input.invitee?.checkedIn || input.invitee.heldProject === "other") { + return { ok: false, code: "BAD_REQUEST", message: INVITE_UNAVAILABLE }; + } + if (input.listed) return { action: "resend" }; + if (input.teamSize >= MAX_TEAM_SIZE) { + return { + ok: false, + code: "CONFLICT", + message: + "Your team already has four people, counting anyone added who has not claimed yet.", + }; + } + return { action: "add" }; +} + +export function refuse(decision: Refusal): never { + throw new TRPCError({ code: decision.code, message: decision.message }); +} + +export async function isCheckedIn( + db: JudgingDb, + hackathonId: string, + userId: string, +): Promise { + const [participant] = await db + .select({ status: hackathonParticipants.registrationStatus }) + .from(hackathonParticipants) + .where( + and( + eq(hackathonParticipants.hackathonId, hackathonId), + eq(hackathonParticipants.userId, userId), + ), + ) + .limit(1); + return participant?.status === "checked_in"; +} + +/** Judging is for hackers in the building, the same bar as submitting. */ +export async function requireCheckedIn( + db: JudgingDb, + hackathonId: string, + userId: string, +): Promise { + if (await isCheckedIn(db, hackathonId, userId)) return; + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Check in at the event first. Find a volunteer and have your badge scanned, then come back to this page.", + }); +} + +/** + * Takes the edition's config row lock. Every claim and invite write takes it + * first, so they run one at a time per edition and cannot interleave with an + * organiser replacing the project list underneath them. The edition must + * exist: call assertHackathonExists before this. + */ +export async function lockJudgingConfig( + tx: JudgingTx, + hackathonId: string, +): Promise { + await ensureJudgingConfig(tx, hackathonId); + const [config] = await tx + .select() + .from(judgingConfigs) + .where(eq(judgingConfigs.hackathonId, hackathonId)) + .for("update"); + return config!; +} + +/** The config as it stands, without creating one: hacker reads never write. */ +export async function readJudgingConfig(db: JudgingDb, hackathonId: string) { + const [config] = await db + .select({ + itineraryPublished: judgingConfigs.itineraryPublished, + emergencyMode: judgingConfigs.emergencyMode, + claimsStartedAt: judgingConfigs.claimsStartedAt, + }) + .from(judgingConfigs) + .where(eq(judgingConfigs.hackathonId, hackathonId)) + .limit(1); + return ( + config ?? { + itineraryPublished: false, + emergencyMode: false, + claimsStartedAt: null, + } + ); +} + +/** + * The place a user holds in an edition, with its project. A project an + * organiser pulled still holds the row (the unique index counts it), so the + * caller decides what a pulled project means. + */ +export async function heldPlace( + db: JudgingDb, + hackathonId: string, + userId: string, +) { + const [held] = await db + .select({ + memberId: judgingEntryMembers.id, + entryId: judgingEntries.id, + title: judgingEntries.title, + participantCount: judgingEntries.participantCount, + deletedAt: judgingEntries.deletedAt, + }) + .from(judgingEntryMembers) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEntryMembers.entryId), + ) + .where( + and( + eq(judgingEntryMembers.hackathonId, hackathonId), + eq(judgingEntryMembers.userId, userId), + ), + ) + .limit(1); + return held ?? null; +} + +/** + * A project's team list in display order. Carries emails: never send it to + * hackers as is. + */ +export async function loadRoster(db: JudgingDb, entryId: string) { + return db + .select({ + id: judgingEntryMembers.id, + name: judgingEntryMembers.name, + email: judgingEntryMembers.email, + userId: judgingEntryMembers.userId, + invitedById: judgingEntryMembers.invitedById, + displayOrder: judgingEntryMembers.displayOrder, + }) + .from(judgingEntryMembers) + .where(eq(judgingEntryMembers.entryId, entryId)) + .orderBy( + asc(judgingEntryMembers.displayOrder), + asc(judgingEntryMembers.createdAt), + ); +} + +export async function rosterSize(db: JudgingDb, entryId: string) { + const [row] = await db + .select({ n: count() }) + .from(judgingEntryMembers) + .where(eq(judgingEntryMembers.entryId, entryId)); + return row?.n ?? 0; +} + +/** + * Gives `userId` the place `memberId` on its team, inside the caller's + * transaction (which must already hold lockJudgingConfig). Consumes any live + * claim link for the place, marks claims as started and locks the project + * inventory: from here on a Devpost import may only add. + */ +export async function claimPlace( + tx: JudgingTx, + input: { hackathonId: string; memberId: string; userId: string; now: Date }, +): Promise<{ entryId: string; title: string }> { + const { hackathonId, memberId, userId, now } = input; + + const [place] = await tx + .select({ + id: judgingEntryMembers.id, + userId: judgingEntryMembers.userId, + entryId: judgingEntries.id, + title: judgingEntries.title, + participantCount: judgingEntries.participantCount, + deletedAt: judgingEntries.deletedAt, + }) + .from(judgingEntryMembers) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEntryMembers.entryId), + ) + .where( + and( + eq(judgingEntryMembers.id, memberId), + eq(judgingEntryMembers.hackathonId, hackathonId), + ), + ) + .for("update", { of: judgingEntryMembers }); + + const held = await heldPlace(tx, hackathonId, userId); + const heldOther = held && held.memberId !== memberId ? held : null; + + const decision = decideClaim( + place + ? { + userId: place.userId, + entryDeleted: !!place.deletedAt, + teamSize: teamSize( + place.participantCount, + await rosterSize(tx, place.entryId), + ), + } + : null, + userId, + heldOther && !heldOther.deletedAt ? heldOther.title : null, + ); + if (!decision.ok) refuse(decision); + const claimed = place!; + if (decision.alreadyYours) { + return { entryId: claimed.entryId, title: claimed.title }; + } + + // A place on a project an organiser pulled would otherwise pin the hacker + // to it for the rest of the event: one place per person per edition. + if (heldOther?.deletedAt) { + await tx + .update(judgingEntryMembers) + .set({ userId: null, claimedAt: null }) + .where(eq(judgingEntryMembers.id, heldOther.memberId)); + } + + try { + await tx + .update(judgingEntryMembers) + .set({ userId, claimedAt: now }) + .where(eq(judgingEntryMembers.id, claimed.id)); + } catch (error) { + // Promoting portal submissions links users without taking the config + // lock, so the unique index is the last word on one place per person. + if (isUniqueViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: + "You are already on a project for this event. Refresh the page to see it.", + }); + } + throw error; + } + + // Claimed by email or by someone else's forwarded link, the place's own link + // must stop working either way. + await tx + .update(judgingClaimLinks) + .set({ token: null, consumedAt: now, consumedById: userId }) + .where( + and( + eq(judgingClaimLinks.memberId, claimed.id), + isNull(judgingClaimLinks.consumedAt), + ), + ); + + await markClaimsStarted(tx, hackathonId, now); + await lockInventory(tx, hackathonId, userId); + + return { entryId: claimed.entryId, title: claimed.title }; +} + +/** First claim or first claim email, whichever comes first; later calls keep it. */ +export async function markClaimsStarted( + tx: JudgingTx, + hackathonId: string, + now: Date, +): Promise { + await tx + .update(judgingConfigs) + .set({ claimsStartedAt: now, updatedAt: now }) + .where( + and( + eq(judgingConfigs.hackathonId, hackathonId), + isNull(judgingConfigs.claimsStartedAt), + ), + ); +} + +/** + * Locks an unclaimed place for a claim link to be issued or sent. Refuses a + * place that is taken, on a pulled project, or on a team too big to claim. + */ +export async function lockPlaceForLink( + tx: JudgingTx, + hackathonId: string, + memberId: string, +) { + const [place] = await tx + .select({ + id: judgingEntryMembers.id, + name: judgingEntryMembers.name, + email: judgingEntryMembers.email, + userId: judgingEntryMembers.userId, + entryId: judgingEntries.id, + title: judgingEntries.title, + participantCount: judgingEntries.participantCount, + deletedAt: judgingEntries.deletedAt, + }) + .from(judgingEntryMembers) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEntryMembers.entryId), + ) + .where( + and( + eq(judgingEntryMembers.id, memberId), + eq(judgingEntryMembers.hackathonId, hackathonId), + ), + ) + .for("update", { of: judgingEntryMembers }); + + if (!place || place.deletedAt) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That team member is not on a project in judging any more.", + }); + } + if (place.userId) { + throw new TRPCError({ + code: "CONFLICT", + message: `${place.name} has already claimed their place, so there is no link to send.`, + }); + } + const size = teamSize( + place.participantCount, + await rosterSize(tx, place.entryId), + ); + if (size > MAX_TEAM_SIZE) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `${place.title} lists ${size} people. Cut the team to four before sending claim links.`, + }); + } + return place; +} + +/** + * The place's live claim token, issued if it has none. A link that was used + * while the place is still unclaimed (its claimer's account was deleted) is + * replaced with a fresh token. The expiry follows the event's current end. + * Call with the place locked (lockPlaceForLink). + */ +export async function issueClaimToken( + tx: JudgingTx, + input: { hackathonId: string; memberId: string; expiresAt: Date }, +): Promise { + const { hackathonId, memberId, expiresAt } = input; + + await tx + .insert(judgingClaimLinks) + .values({ memberId, hackathonId, token: newClaimToken(), expiresAt }) + .onConflictDoNothing({ target: judgingClaimLinks.memberId }); + + const [link] = await tx + .select() + .from(judgingClaimLinks) + .where(eq(judgingClaimLinks.memberId, memberId)) + .for("update"); + + if (link?.token && !link.consumedAt) { + if (link.expiresAt.getTime() !== expiresAt.getTime()) { + await tx + .update(judgingClaimLinks) + .set({ expiresAt }) + .where(eq(judgingClaimLinks.id, link.id)); + } + return link.token; + } + + const token = newClaimToken(); + await tx + .update(judgingClaimLinks) + .set({ + token, + expiresAt, + consumedAt: null, + consumedById: null, + sentAt: null, + }) + .where(eq(judgingClaimLinks.memberId, memberId)); + return token; +} diff --git a/packages/api/src/services/judging/config.test.ts b/packages/api/src/services/judging/config.test.ts new file mode 100644 index 00000000..08f1fe80 --- /dev/null +++ b/packages/api/src/services/judging/config.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { + assertHackathonExists, + ensureJudgingConfig, + lockInventory, +} from "./config"; +import type { JudgingDb } from "./config"; + +// A stand-in for the handful of query-builder calls these helpers make. Each +// builder resolves to whatever the matching queue holds next. +function fakeDb(results: { + insert?: unknown[][]; + select?: unknown[][]; + update?: unknown[][]; +}) { + const calls = { insert: 0, select: 0, update: 0 }; + const chain = (kind: keyof typeof calls) => { + const next = () => { + calls[kind] += 1; + return Promise.resolve(results[kind]?.shift() ?? []); + }; + const builder: Record = {}; + for (const method of [ + "values", + "onConflictDoNothing", + "from", + "where", + "set", + ]) { + builder[method] = () => builder; + } + builder.returning = next; + builder.limit = next; + return builder; + }; + const db = { + insert: () => chain("insert"), + select: () => chain("select"), + update: () => chain("update"), + }; + return { db: db as unknown as JudgingDb, calls }; +} + +const HACK = "6f1d2c3b-4a5e-4f60-8a7b-9c0d1e2f3a4b"; +const config = (over: Record = {}) => ({ + hackathonId: HACK, + state: "draft", + inventoryLockedAt: null, + inventoryLockedById: null, + ...over, +}); + +describe("ensureJudgingConfig", () => { + it("returns the row it just created", async () => { + const { db, calls } = fakeDb({ insert: [[config()]] }); + await expect(ensureJudgingConfig(db, HACK)).resolves.toMatchObject({ + hackathonId: HACK, + state: "draft", + }); + expect(calls.select).toBe(0); + }); + + it("reads the existing row when one is already there", async () => { + const existing = config({ state: "open" }); + const { db, calls } = fakeDb({ insert: [[]], select: [[existing]] }); + await expect(ensureJudgingConfig(db, HACK)).resolves.toBe(existing); + expect(calls.select).toBe(1); + }); + + it("is NOT_FOUND when neither insert nor read finds a row", async () => { + const { db } = fakeDb({ insert: [[]], select: [[]] }); + await expect(ensureJudgingConfig(db, HACK)).rejects.toBeInstanceOf( + TRPCError, + ); + }); +}); + +describe("lockInventory", () => { + it("keeps the first lock", async () => { + const lockedAt = new Date("2026-02-21T15:00:00Z"); + const existing = config({ inventoryLockedAt: lockedAt }); + const { db, calls } = fakeDb({ insert: [[]], select: [[existing]] }); + await expect(lockInventory(db, HACK, "user-2")).resolves.toBe(existing); + expect(calls.update).toBe(0); + }); + + it("locks an unlocked inventory", async () => { + const locked = config({ + inventoryLockedAt: new Date(), + inventoryLockedById: "user-1", + }); + const { db, calls } = fakeDb({ insert: [[config()]], update: [[locked]] }); + await expect(lockInventory(db, HACK, "user-1")).resolves.toBe(locked); + expect(calls.update).toBe(1); + }); + + it("returns the winner's lock when another caller got there first", async () => { + const theirs = config({ + inventoryLockedAt: new Date(), + inventoryLockedById: "user-9", + }); + const { db } = fakeDb({ + insert: [[config()], []], + update: [[]], + select: [[theirs]], + }); + await expect(lockInventory(db, HACK, "user-1")).resolves.toBe(theirs); + }); +}); + +describe("assertHackathonExists", () => { + it("returns the edition", async () => { + const row = { + id: HACK, + name: "Hacklytics 2027", + startDate: new Date(), + endDate: new Date(), + }; + const { db } = fakeDb({ select: [[row]] }); + await expect(assertHackathonExists(db, HACK)).resolves.toBe(row); + }); + + it("is NOT_FOUND for a missing edition", async () => { + const { db } = fakeDb({ select: [[]] }); + await expect(assertHackathonExists(db, HACK)).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +}); diff --git a/packages/api/src/services/judging/config.ts b/packages/api/src/services/judging/config.ts new file mode 100644 index 00000000..0a58b968 --- /dev/null +++ b/packages/api/src/services/judging/config.ts @@ -0,0 +1,102 @@ +import { TRPCError } from "@trpc/server"; +import { and, eq, isNull } from "drizzle-orm"; +import { hackathons, judgingConfigs } from "@query/db"; +import type { DrizzleDB } from "@query/db"; + +/** A transaction handle from `db.transaction`. */ +export type JudgingTx = Parameters[0]>[0]; + +/** Either the pool or a transaction: every helper here works inside one. */ +export type JudgingDb = DrizzleDB | JudgingTx; + +export type JudgingConfig = typeof judgingConfigs.$inferSelect; + +/** + * The edition, or a NOT_FOUND an organiser can act on. Returns the columns + * judging needs: the name (typed to confirm a reset) and the dates (claim links + * expire when the event ends). + */ +export async function assertHackathonExists(db: JudgingDb, hackathonId: string) { + const [hackathon] = await db + .select({ + id: hackathons.id, + name: hackathons.name, + startDate: hackathons.startDate, + endDate: hackathons.endDate, + }) + .from(hackathons) + .where(eq(hackathons.id, hackathonId)) + .limit(1); + + if (!hackathon) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "That hackathon does not exist. Pick an edition from the judging page and try again.", + }); + } + + return hackathon; +} + +/** + * The edition's judging config, created in its default state (draft, nothing + * locked) the first time anything asks. Safe under concurrency: a second + * caller's insert is a no-op and it reads the row the first one wrote. + */ +export async function ensureJudgingConfig( + db: JudgingDb, + hackathonId: string, +): Promise { + const [created] = await db + .insert(judgingConfigs) + .values({ hackathonId }) + .onConflictDoNothing({ target: judgingConfigs.hackathonId }) + .returning(); + if (created) return created; + + const [existing] = await db + .select() + .from(judgingConfigs) + .where(eq(judgingConfigs.hackathonId, hackathonId)) + .limit(1); + if (!existing) { + // Only reachable if the hackathon row is gone: the insert above would have + // hit its foreign key otherwise. + throw new TRPCError({ + code: "NOT_FOUND", + message: "That hackathon does not exist.", + }); + } + return existing; +} + +/** + * Locks the project inventory: from now on a Devpost import only adds, and + * replacing it needs typed confirmation. Set by the first room link or the + * first claim; later calls keep the first lock's time and author. Returns the + * config as it stands after the call. + */ +export async function lockInventory( + tx: JudgingDb, + hackathonId: string, + userId: string | null, +): Promise { + const config = await ensureJudgingConfig(tx, hackathonId); + if (config.inventoryLockedAt) return config; + + const now = new Date(); + const [locked] = await tx + .update(judgingConfigs) + .set({ inventoryLockedAt: now, inventoryLockedById: userId, updatedAt: now }) + .where( + and( + eq(judgingConfigs.hackathonId, hackathonId), + isNull(judgingConfigs.inventoryLockedAt), + ), + ) + .returning(); + + // Someone else locked it between the read and the update. + return locked ?? (await ensureJudgingConfig(tx, hackathonId)); +} diff --git a/packages/api/src/services/judging/deliberation.test.ts b/packages/api/src/services/judging/deliberation.test.ts new file mode 100644 index 00000000..6b2f2bc5 --- /dev/null +++ b/packages/api/src/services/judging/deliberation.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { groupSections, isReordering, nextOrder } from "./deliberation"; + +describe("isReordering", () => { + it("accepts the same ids in any order", () => { + expect(isReordering(["a", "b", "c"], ["c", "a", "b"])).toBe(true); + expect(isReordering([], [])).toBe(true); + }); + + it("refuses a list missing a row or naming an extra one", () => { + expect(isReordering(["a", "b", "c"], ["a", "b"])).toBe(false); + expect(isReordering(["a", "b"], ["a", "b", "c"])).toBe(false); + expect(isReordering(["a", "b"], ["a", "x"])).toBe(false); + }); + + it("refuses a repeated id", () => { + expect(isReordering(["a", "b"], ["a", "a"])).toBe(false); + }); +}); + +describe("nextOrder", () => { + it("starts at zero and goes one past the highest", () => { + expect(nextOrder([])).toBe(0); + expect(nextOrder([0, 1, 2])).toBe(3); + expect(nextOrder([4, 0, 7])).toBe(8); + }); +}); + +describe("groupSections", () => { + it("nests entries under their section, both in display order", () => { + const grouped = groupSections( + [ + { id: "maybe", displayOrder: 1 }, + { id: "top", displayOrder: 0 }, + { id: "empty", displayOrder: 2 }, + ], + [ + { id: "e1", sectionId: "top", displayOrder: 1 }, + { id: "e2", sectionId: "maybe", displayOrder: 0 }, + { id: "e3", sectionId: "top", displayOrder: 0 }, + ], + ); + expect(grouped.map((s) => [s.id, s.entries.map((e) => e.id)])).toEqual([ + ["top", ["e3", "e1"]], + ["maybe", ["e2"]], + ["empty", []], + ]); + }); + + it("keeps input order for ties and leaves the input alone", () => { + const sections = [ + { id: "b", displayOrder: 0 }, + { id: "a", displayOrder: 0 }, + ]; + expect(groupSections(sections, []).map((s) => s.id)).toEqual(["b", "a"]); + expect(sections.map((s) => s.id)).toEqual(["b", "a"]); + }); +}); diff --git a/packages/api/src/services/judging/deliberation.ts b/packages/api/src/services/judging/deliberation.ts new file mode 100644 index 00000000..d22d7b85 --- /dev/null +++ b/packages/api/src/services/judging/deliberation.ts @@ -0,0 +1,42 @@ +// A judge's private shortlist: sections they name ("Top three", "Maybe"), +// each holding projects they have finished judging, with a note apiece. Pure +// helpers for ordering; the desk router does the reads and writes. + +/** + * Whether `requested` is a reordering of `current`: the same ids, each once. + * A list from a stale screen (a row added or removed in another tab) is not, + * and is refused rather than half-applied. + */ +export function isReordering( + current: readonly string[], + requested: readonly string[], +): boolean { + if (current.length !== requested.length) return false; + const remaining = new Set(current); + for (const id of requested) { + if (!remaining.delete(id)) return false; + } + return remaining.size === 0; +} + +/** The display order for a row appended after rows holding `orders`. */ +export function nextOrder(orders: readonly number[]): number { + let highest = -1; + for (const order of orders) if (order > highest) highest = order; + return highest + 1; +} + +/** Sections, each with its entries, both by display order; ties keep input order. */ +export function groupSections< + S extends { id: string; displayOrder: number }, + E extends { sectionId: string; displayOrder: number }, +>(sections: readonly S[], entries: readonly E[]): (S & { entries: E[] })[] { + const byOrder = (a: T, b: T) => + a.displayOrder - b.displayOrder; + return [...sections].sort(byOrder).map((section) => ({ + ...section, + entries: entries + .filter((entry) => entry.sectionId === section.id) + .sort(byOrder), + })); +} diff --git a/packages/api/src/services/judging/devpost.test.ts b/packages/api/src/services/judging/devpost.test.ts new file mode 100644 index 00000000..506d2907 --- /dev/null +++ b/packages/api/src/services/judging/devpost.test.ts @@ -0,0 +1,558 @@ +import { describe, expect, it } from "vitest"; +import { + DevpostFileError, + httpUrl, + parseCsv, + parseDevpostDate, + readDevpostExport, + splitList, + submissionKey, +} from "./devpost"; + +describe("parseCsv", () => { + it("splits plain rows and cells", () => { + expect(parseCsv("a,b,c\n1,2,3\n")).toEqual([ + ["a", "b", "c"], + ["1", "2", "3"], + ]); + }); + + it("keeps commas, doubled quotes and line breaks inside quoted cells", () => { + const text = + 'title,about\n"Tidewater, v2","We said ""ship it""\r\nand did"\n'; + expect(parseCsv(text)).toEqual([ + ["title", "about"], + ["Tidewater, v2", 'We said "ship it"\r\nand did'], + ]); + }); + + it("reads CRLF, LF and bare CR line endings alike", () => { + expect(parseCsv("a,b\r\n1,2\r3,4\n5,6")).toEqual([ + ["a", "b"], + ["1", "2"], + ["3", "4"], + ["5", "6"], + ]); + }); + + it("drops a byte-order mark", () => { + const [header] = parseCsv("\uFEFFProject Title,Submission Url\n"); + expect(header![0]).toBe("Project Title"); + }); + + it("drops blank lines and rows of bare commas", () => { + expect(parseCsv("a,b\n\n1,2\r\n,,\n\r\n\n")).toEqual([ + ["a", "b"], + ["1", "2"], + ]); + }); + + it("keeps an empty last cell and a final row with no newline", () => { + expect(parseCsv("a,b\n1,")).toEqual([ + ["a", "b"], + ["1", ""], + ]); + }); + + it("reads an empty quoted cell as empty", () => { + expect(parseCsv('a,"",c\n')).toEqual([["a", "", "c"]]); + }); + + it("keeps a stray quote in the middle of an unquoted cell", () => { + expect(parseCsv('a,6" monitor\n')).toEqual([["a", '6" monitor']]); + }); + + it("refuses a quote that is never closed", () => { + expect(() => parseCsv('a,b\n"open,2\n3,4\n')).toThrow(DevpostFileError); + }); +}); + +describe("helpers", () => { + it("httpUrl keeps http and https only, without the fragment", () => { + expect(httpUrl(" https://tidewater.vercel.app/#demo ")).toBe( + "https://tidewater.vercel.app/", + ); + expect(httpUrl("http://example.org/a?b=1")).toBe("http://example.org/a?b=1"); + expect(httpUrl("javascript:alert(1)")).toBeNull(); + expect(httpUrl("ftp://files.example.org/x")).toBeNull(); + expect(httpUrl("github.com/team/repo")).toBeNull(); + expect(httpUrl("")).toBeNull(); + }); + + it("submissionKey treats a trailing slash and host case as the same URL", () => { + expect(submissionKey("https://Devpost.com/software/tidewater/")).toBe( + submissionKey("https://devpost.com/software/tidewater"), + ); + expect(submissionKey("devpost.com/software/tidewater")).toBeNull(); + }); + + it("splitList splits on semicolons, commas and newlines, once each", () => { + expect(splitList("python; react, Snowflake\nreact\r\n")).toEqual([ + "python", + "react", + "Snowflake", + ]); + expect(splitList("")).toEqual([]); + expect(splitList(undefined)).toEqual([]); + }); + + describe("parseDevpostDate", () => { + const expected = new Date("2027-02-08T14:03:22Z"); + + it("reads ISO-style and US-style timestamps without a zone as UTC", () => { + expect(parseDevpostDate("2027-02-08 14:03:22")).toEqual(expected); + expect(parseDevpostDate("02/08/2027 14:03:22")).toEqual(expected); + expect(parseDevpostDate("2/8/2027 2:03:22 PM")).toEqual(expected); + }); + + it("applies an explicit offset", () => { + expect(parseDevpostDate("2027-02-08 09:03:22 -0500")).toEqual(expected); + expect(parseDevpostDate("2027-02-08T14:03:22Z")).toEqual(expected); + }); + + it("handles midnight and noon on a 12-hour clock", () => { + expect(parseDevpostDate("02/08/2027 12:00 AM")).toEqual( + new Date("2027-02-08T00:00:00Z"), + ); + expect(parseDevpostDate("02/08/2027 12:00 PM")).toEqual( + new Date("2027-02-08T12:00:00Z"), + ); + }); + + it("is null for blank or unreadable values", () => { + expect(parseDevpostDate("")).toBeNull(); + expect(parseDevpostDate("sometime Saturday")).toBeNull(); + expect(parseDevpostDate("13/40/2027 10:00")).toBeNull(); + }); + }); +}); + +// A Devpost submissions export, written the way Devpost writes it: one row +// per opt-in prize, team members as triples after the count. +const COLUMNS = [ + "Project Title", + "Submission Url", + "Project Status", + "Judging Status", + "Highest Step Completed", + "Project Created At", + "About The Project", + '"Try it out" Links', + "Video Demo Link", + "Opt-In Prize", + "Built With", + "Notes", + "Team Colleges/Universities", + "Submitter Screen Name", + "Submitter First Name", + "Submitter Last Name", + "Submitter Email", + "Project Submitted At", + "Additional Team Member Count", + "Team Member 1 First Name", + "Team Member 1 Last Name", + "Team Member 1 Email", + "Team Member 2 First Name", + "Team Member 2 Last Name", + "Team Member 2 Email", + "Team Member 3 First Name", + "Team Member 3 Last Name", + "Team Member 3 Email", +] as const; + +type Teammate = [first: string, last: string, email: string]; + +type Fixture = { + title: string; + url: string; + status?: string; + about?: string; + links?: string; + video?: string; + prize?: string; + builtWith?: string; + universities?: string; + first: string; + last: string; + email: string; + createdAt?: string; + submittedAt?: string; + count?: string; + team?: Teammate[]; +}; + +const quote = (value: string) => + /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; + +function row(f: Fixture): string[] { + const team = f.team ?? []; + return [ + f.title, + f.url, + f.status ?? "Submitted (Gallery/Visible)", + "Pending", + "Submit", + f.createdAt ?? "2027-02-07 20:15:00", + f.about ?? "", + f.links ?? "", + f.video ?? "", + f.prize ?? "", + f.builtWith ?? "", + "", + f.universities ?? "", + f.first.toLowerCase(), + f.first, + f.last, + f.email, + f.submittedAt ?? "2027-02-08 09:41:07", + f.count ?? String(team.length), + ...team.flat(), + ]; +} + +function exportOf( + fixtures: Fixture[], + columns: readonly string[] = COLUMNS, +): string { + const lines = [columns, ...fixtures.map(row)].map((cells) => + cells.map(quote).join(","), + ); + return `\uFEFF${lines.join("\r\n")}\r\n`; +} + +const tidewater: Fixture = { + title: "Tidewater", + url: "https://devpost.com/software/tidewater", + about: + "Flood risk from street-level photos.\n\nWe fine-tuned a segmentation model on 4,000 images of Atlanta storm drains.", + links: + "https://tidewater.vercel.app, https://github.com/tidewater-gt/tidewater; javascript:alert(1)", + video: "https://youtu.be/dQw4w9WgXcQ", + prize: "Best Use of Snowflake", + builtWith: "python, pytorch, react, snowflake", + universities: "Georgia Institute of Technology; Emory University", + first: "Priya", + last: "Raman", + email: "Priya.Raman@gatech.edu", + team: [ + ["Marcus", "Hill", "mhill42@gatech.edu"], + ["Ana", "Souza", "ana.souza@emory.edu"], + ], +}; + +const ledgerLens: Fixture = { + title: "LedgerLens", + url: "https://devpost.com/software/ledgerlens", + about: "Explains a 10-K in plain English.", + prize: "[MLH] Best Use of MongoDB Atlas", + builtWith: "next.js;mongodb", + first: "Jordan", + last: "Lee", + email: "jlee@gatech.edu", +}; + +const courtVision: Fixture = { + title: "Court Vision", + url: "https://devpost.com/software/court-vision", + about: "Shot charts from broadcast footage.", + prize: "", + first: "Sam", + last: "Okafor", + email: "sokafor3@gatech.edu", + team: [["Lena", "Park", "lpark@gsu.edu"]], +}; + +describe("readDevpostExport", () => { + it("reads a clean export into projects", () => { + const result = readDevpostExport( + exportOf([tidewater, ledgerLens, courtVision]), + ); + + expect(result.rejected).toEqual([]); + expect(result.drafts).toBe(0); + expect(result.collapsedRows).toBe(0); + expect(result.projects.map((p) => p.title)).toEqual([ + "Tidewater", + "LedgerLens", + "Court Vision", + ]); + + const [first] = result.projects; + expect(first).toEqual({ + submissionUrl: "https://devpost.com/software/tidewater", + title: "Tidewater", + description: tidewater.about, + demoLinks: [ + "https://tidewater.vercel.app/", + "https://github.com/tidewater-gt/tidewater", + ], + videoUrl: "https://youtu.be/dQw4w9WgXcQ", + technologies: ["python", "pytorch", "react", "snowflake"], + universities: ["Georgia Institute of Technology", "Emory University"], + prizeCategories: ["Best Use of Snowflake"], + participantCount: 3, + members: [ + { name: "Priya Raman", email: "priya.raman@gatech.edu", order: 0 }, + { name: "Marcus Hill", email: "mhill42@gatech.edu", order: 1 }, + { name: "Ana Souza", email: "ana.souza@emory.edu", order: 2 }, + ], + projectCreatedAt: new Date("2027-02-07T20:15:00Z"), + submittedAt: new Date("2027-02-08T09:41:07Z"), + }); + expect(result.projects[2]!.prizeCategories).toEqual([]); + expect(result.labels).toEqual([ + "[MLH] Best Use of MongoDB Atlas", + "Best Use of Snowflake", + ]); + }); + + it("folds one row per prize into one project and unions the prizes", () => { + const result = readDevpostExport( + exportOf([ + tidewater, + { ...tidewater, url: `${tidewater.url}/`, prize: "Best Sustainability Hack" }, + { ...tidewater, prize: "best use of snowflake" }, + { ...ledgerLens, prize: "Best Sustainability Hack" }, + ]), + ); + + expect(result.collapsedRows).toBe(2); + expect(result.projects).toHaveLength(2); + expect(result.projects[0]!.prizeCategories).toEqual([ + "Best Sustainability Hack", + "Best Use of Snowflake", + ]); + expect(result.labels).toEqual([ + "Best Sustainability Hack", + "Best Use of Snowflake", + ]); + }); + + it("counts draft-only projects once and leaves them out", () => { + const draft: Fixture = { + ...courtVision, + title: "Half-built", + url: "https://devpost.com/software/half-built", + status: "Draft", + }; + const result = readDevpostExport( + exportOf([ + tidewater, + draft, + { ...draft, prize: "Best Use of Snowflake" }, + { ...ledgerLens, status: "Draft" }, + ledgerLens, + { ...draft, url: "", title: "Unnamed draft", status: "Draft" }, + ]), + ); + + expect(result.projects.map((p) => p.title)).toEqual([ + "Tidewater", + "LedgerLens", + ]); + // Half-built and the URL-less draft; LedgerLens was also submitted. + expect(result.drafts).toBe(2); + }); + + it("rejects submitted rows with a missing or non-web submission URL", () => { + const result = readDevpostExport( + exportOf([ + tidewater, + { ...ledgerLens, url: "" }, + { ...courtVision, url: "devpost.com/software/court-vision" }, + ]), + ); + + expect(result.projects).toHaveLength(1); + expect(result.rejected).toEqual([ + { + project: "LedgerLens", + reason: "It is marked submitted but has no submission URL.", + }, + { + project: "Court Vision", + reason: expect.stringContaining("is not an http or https link"), + }, + ]); + }); + + it("rejects a project whose rows disagree about the team", () => { + const result = readDevpostExport( + exportOf([ + tidewater, + { + ...tidewater, + prize: "Best Sustainability Hack", + team: [["Marcus", "Hill", "marcus.hill@gatech.edu"], tidewater.team![1]!], + }, + ledgerLens, + ]), + ); + + expect(result.projects.map((p) => p.title)).toEqual(["LedgerLens"]); + expect(result.rejected).toHaveLength(1); + expect(result.rejected[0]).toMatchObject({ project: "Tidewater" }); + expect(result.rejected[0]!.reason).toContain("lines 2 and 3"); + }); + + it("rejects a project whose rows disagree about the description", () => { + const result = readDevpostExport( + exportOf([ + ledgerLens, + { ...ledgerLens, about: "A different pitch.", prize: "Best Fintech" }, + courtVision, + ]), + ); + expect(result.rejected.map((r) => r.project)).toEqual(["LedgerLens"]); + }); + + it("rejects more than three teammates", () => { + const result = readDevpostExport( + exportOf([tidewater, { ...ledgerLens, count: "4" }]), + ); + expect(result.rejected).toEqual([ + { + project: "LedgerLens", + reason: "It lists 4 teammates besides the submitter; teams are at most 4 people.", + }, + ]); + }); + + it("rejects a teammate count that is not a whole number", () => { + const result = readDevpostExport( + exportOf([tidewater, { ...ledgerLens, count: "two" }]), + ); + expect(result.rejected[0]!.reason).toContain('"two" is not a whole number'); + }); + + it("skips blank teammate triples but still counts the seat", () => { + const result = readDevpostExport( + exportOf([ + { + ...courtVision, + team: [ + ["", "", ""], + ["Lena", "Park", "lpark@gsu.edu"], + ], + }, + ]), + ); + const [project] = result.projects; + expect(project!.participantCount).toBe(3); + expect(project!.members.map((m) => [m.name, m.order])).toEqual([ + ["Sam Okafor", 0], + ["Lena Park", 1], + ]); + }); + + it("rejects a teammate with a name but no email, or the reverse", () => { + const result = readDevpostExport( + exportOf([ + { ...courtVision, team: [["Lena", "Park", ""]] }, + { ...ledgerLens, team: [["", "", "ghost@gsu.edu"]] }, + tidewater, + ]), + ); + expect(result.rejected).toEqual([ + { project: "Court Vision", reason: "Teammate 1 has a name but no email." }, + { project: "LedgerLens", reason: "Teammate 1 has an email but no name." }, + ]); + }); + + it("rejects an address that is not an email", () => { + const result = readDevpostExport( + exportOf([tidewater, { ...ledgerLens, email: "jlee at gatech" }]), + ); + expect(result.rejected[0]!.reason).toContain("is not an email address"); + }); + + it("rejects a project with no submitter name", () => { + const result = readDevpostExport( + exportOf([tidewater, { ...ledgerLens, first: "", last: "" }]), + ); + expect(result.rejected[0]!.reason).toBe( + "It is missing the submitter's name or email.", + ); + }); + + it("rejects a title over 255 characters", () => { + const result = readDevpostExport( + exportOf([tidewater, { ...ledgerLens, title: "L".repeat(256) }]), + ); + expect(result.rejected[0]!.reason).toBe( + "The title is longer than 255 characters.", + ); + }); + + it("keeps only web links and drops a video link that is not one", () => { + const result = readDevpostExport( + exportOf([ + { + ...ledgerLens, + links: + "https://ledgerlens.app\nhttps://ledgerlens.app\ndata:text/html,hi;not a url", + video: "javascript:void(0)", + }, + ]), + ); + expect(result.projects[0]!.demoLinks).toEqual(["https://ledgerlens.app/"]); + expect(result.projects[0]!.videoUrl).toBeNull(); + }); + + it("keeps a project whose dates cannot be read, without them", () => { + const result = readDevpostExport( + exportOf([{ ...ledgerLens, createdAt: "", submittedAt: "Saturday" }]), + ); + expect(result.projects[0]).toMatchObject({ + projectCreatedAt: null, + submittedAt: null, + }); + }); + + it("accepts the long universities heading and loosely spelled headings", () => { + const columns = COLUMNS.map((name) => + name === "Team Colleges/Universities" + ? "List All Of The Universities Or Schools That Your Team Members Currently Attend." + : name === '"Try it out" Links' + ? "Try it out links" + : name === "Project Title" + ? " project title " + : name, + ); + const result = readDevpostExport(exportOf([tidewater], columns)); + expect(result.projects[0]!.universities).toEqual([ + "Georgia Institute of Technology", + "Emory University", + ]); + expect(result.projects[0]!.demoLinks).toHaveLength(2); + }); + + it("names every missing required column", () => { + const columns = COLUMNS.filter( + (name) => name !== "Submitter Email" && name !== "Project Status", + ); + expect(() => readDevpostExport(`${columns.join(",")}\n`)).toThrow( + /no Project Status, Submitter Email columns/, + ); + }); + + it("refuses an empty file and a file with headings only", () => { + expect(() => readDevpostExport("")).toThrow(DevpostFileError); + expect(() => readDevpostExport("\uFEFF\r\n\r\n")).toThrow(/empty/); + expect(() => readDevpostExport(exportOf([]))).toThrow( + /no projects under them/, + ); + }); + + it("refuses a file of drafts only", () => { + expect(() => + readDevpostExport(exportOf([{ ...tidewater, status: "Draft" }])), + ).toThrow(/only drafts/); + }); + + it("refuses a file where every submitted project is rejected", () => { + expect(() => + readDevpostExport(exportOf([{ ...tidewater, count: "5" }])), + ).toThrow(/first problem: Tidewater: It lists 5 teammates/); + }); +}); diff --git a/packages/api/src/services/judging/devpost.ts b/packages/api/src/services/judging/devpost.ts new file mode 100644 index 00000000..4165f86c --- /dev/null +++ b/packages/api/src/services/judging/devpost.ts @@ -0,0 +1,578 @@ +import { normalizeLabel, sameLabel } from "./scope"; + +// Reads Devpost's "submissions" export. Pure: the file arrives as text, and +// what comes out is a list of projects ready to insert plus everything that +// was left out and why. Nothing here touches the database. +// +// The export has one row per (project, opt-in prize), so a project entered +// for three prizes appears three times. Rows are grouped by submission URL, +// and the copies must agree on who made the project and what it is, or the +// project is held back rather than guessed at. + +/** The file itself cannot be used: not CSV, a missing column, no projects. */ +export class DevpostFileError extends Error { + constructor(message: string) { + super(message); + this.name = "DevpostFileError"; + } +} + +// --------------------------------------------------------------------------- +// CSV (RFC 4180) + +/** + * Splits CSV text into rows of cells. Handles a leading byte-order mark, + * quoted cells holding commas, line breaks and doubled quotes, and CRLF, LF + * or bare CR line endings. Rows with nothing in them are dropped. A quote + * that is never closed makes the whole file unreadable, since everything + * after it would be read into one cell. + */ +export function parseCsv(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let cell = ""; + let quoted = false; + let i = text.charCodeAt(0) === 0xfeff ? 1 : 0; + + const endRow = () => { + row.push(cell); + // A blank line, or a line of bare commas a spreadsheet left behind, is + // not a row. + if (row.some((value) => value.trim() !== "")) rows.push(row); + row = []; + cell = ""; + }; + + while (i < text.length) { + const ch = text[i]!; + + if (quoted) { + if (ch === '"') { + if (text[i + 1] === '"') { + cell += '"'; + i += 2; + continue; + } + quoted = false; + } else { + cell += ch; + } + i += 1; + continue; + } + + if (ch === '"' && cell === "") { + quoted = true; + } else if (ch === ",") { + row.push(cell); + cell = ""; + } else if (ch === "\r" || ch === "\n") { + endRow(); + if (ch === "\r" && text[i + 1] === "\n") i += 1; + } else { + // A stray quote inside an unquoted cell is kept as text, the way + // spreadsheet programs read it. + cell += ch; + } + i += 1; + } + + if (quoted) { + throw new DevpostFileError( + "The file is not valid CSV: a quoted cell is never closed. Export it from Devpost again and upload that file unchanged.", + ); + } + if (cell !== "" || row.length > 0) endRow(); + + return rows; +} + +// --------------------------------------------------------------------------- +// Devpost export + +const COLUMNS = { + title: "Project Title", + url: "Submission Url", + status: "Project Status", + createdAt: "Project Created At", + submittedAt: "Project Submitted At", + firstName: "Submitter First Name", + lastName: "Submitter Last Name", + email: "Submitter Email", + teammates: "Additional Team Member Count", + about: "About The Project", + prize: "Opt-In Prize", + tryItOut: '"Try it out" Links', + builtWith: "Built With", + video: "Video Demo Link", + universities: "Team Colleges/Universities", +} as const; + +const REQUIRED = [ + COLUMNS.title, + COLUMNS.url, + COLUMNS.status, + COLUMNS.createdAt, + COLUMNS.submittedAt, + COLUMNS.firstName, + COLUMNS.lastName, + COLUMNS.email, + COLUMNS.teammates, +] as const; + +// Older exports name the universities column after the registration question. +const LONG_UNIVERSITIES_PREFIX = "list all of the universities"; + +/** Devpost allows teams of four: the submitter and three more. */ +export const MAX_ADDITIONAL_MEMBERS = 3; + +const MAX_TITLE = 255; +const MAX_DESCRIPTION = 50_000; +const MAX_NAME = 255; +const MAX_EMAIL = 320; +const MAX_LABEL = 255; + +export type DevpostMember = { name: string; email: string; order: number }; + +export type DevpostProject = { + submissionUrl: string; + title: string; + description: string; + demoLinks: string[]; + videoUrl: string | null; + technologies: string[]; + universities: string[]; + /** Opt-in prize labels across every row of the project, deduplicated. */ + prizeCategories: string[]; + participantCount: number; + /** Submitter first, then teammates in column order. */ + members: DevpostMember[]; + projectCreatedAt: Date | null; + submittedAt: Date | null; +}; + +export type DevpostRejection = { project: string; reason: string }; + +export type DevpostImport = { + projects: DevpostProject[]; + /** Every prize label any accepted project opted into, deduplicated. */ + labels: string[]; + rejected: DevpostRejection[]; + /** Projects only ever seen as drafts. */ + drafts: number; + /** Extra rows folded into a project already seen. */ + collapsedRows: number; +}; + +// Header text is matched loosely: Devpost has changed capitalisation and the +// quoting around "Try it out" between exports. +const headerKey = (header: string) => + header.replace(/"/g, "").trim().replace(/\s+/g, " ").toLowerCase(); + +const clean = (value: string | undefined) => (value ?? "").trim(); + +/** + * An http(s) URL in one canonical spelling, or null for anything else. + * Demo and video links become public hrefs, so `javascript:` and friends + * never get through. + */ +export function httpUrl(raw: string): string | null { + let url: URL; + try { + url = new URL(raw.trim()); + } catch { + return null; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + url.hash = ""; + return url.toString(); +} + +/** + * The key a submission is grouped and matched by. A trailing slash is the + * same project, and re-imports must find the rows earlier imports wrote. + */ +export function submissionKey(raw: string): string | null { + const url = httpUrl(raw); + if (!url) return null; + const parsed = new URL(url); + parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/"; + return parsed.toString(); +} + +/** Splits a list cell on semicolons, commas and line breaks. */ +export function splitList(value: string | undefined): string[] { + const seen = new Set(); + for (const item of clean(value).split(/[;,\r\n]+/)) { + const trimmed = item.trim(); + if (trimmed) seen.add(trimmed); + } + return [...seen]; +} + +const DATE_YMD = + /^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?\s*(Z|UTC|GMT|[+-]\d{2}:?\d{2})?$/i; +const DATE_MDY = + /^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?\s*(Z|UTC|GMT|[+-]\d{2}:?\d{2})?$/i; + +function offsetMinutes(zone: string | undefined): number { + if (!zone || /^(Z|UTC|GMT)$/i.test(zone)) return 0; + const digits = zone.replace(":", ""); + const sign = digits.startsWith("-") ? -1 : 1; + return sign * (Number(digits.slice(1, 3)) * 60 + Number(digits.slice(3, 5))); +} + +function utc( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, + zone: string | undefined, +): Date | null { + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + if (hour > 23 || minute > 59 || second > 59) return null; + const ms = + Date.UTC(year, month - 1, day, hour, minute, second) - + offsetMinutes(zone) * 60_000; + return new Date(ms); +} + +/** + * A Devpost timestamp. A time written without a zone is read as UTC, so one + * file always gives the same instants whatever the server's zone. Null when + * blank or unreadable: the dates only order projects, and losing one should + * not keep a project out of judging. + */ +export function parseDevpostDate(value: string | undefined): Date | null { + const text = clean(value); + if (!text) return null; + + const ymd = DATE_YMD.exec(text); + if (ymd) { + return utc( + Number(ymd[1]), + Number(ymd[2]), + Number(ymd[3]), + Number(ymd[4]), + Number(ymd[5]), + Number(ymd[6] ?? 0), + ymd[7], + ); + } + + const mdy = DATE_MDY.exec(text); + if (mdy) { + let hour = Number(mdy[4]); + const meridiem = mdy[7]?.toUpperCase(); + if (meridiem) { + if (hour < 1 || hour > 12) return null; + hour = (hour % 12) + (meridiem === "PM" ? 12 : 0); + } + return utc( + Number(mdy[3]), + Number(mdy[1]), + Number(mdy[2]), + hour, + Number(mdy[5]), + Number(mdy[6] ?? 0), + mdy[8], + ); + } + + // ISO strings with an explicit zone, mostly. + const parsed = new Date(text); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +// eslint-disable-next-line no-control-regex +const CONTROL = /[\u0000-\u001f\u007f]/; + +/** A problem with one project. Caught per project and reported. */ +class ProjectProblem extends Error {} + +function bounded(value: string, max: number, what: string): string { + if (value.length > max) { + throw new ProjectProblem(`${what} is longer than ${max} characters.`); + } + return value; +} + +function email(value: string, what: string): string { + bounded(value, MAX_EMAIL, what); + if (!EMAIL.test(value)) { + throw new ProjectProblem(`${what} "${value}" is not an email address.`); + } + return value.toLowerCase(); +} + +type Row = { cells: string[]; line: number }; + +/** + * Turns the export's text into projects. Throws DevpostFileError when the + * file as a whole is unusable; a bad project is skipped and listed in + * `rejected` instead. + */ +export function readDevpostExport(text: string): DevpostImport { + const rows = parseCsv(text); + const header = rows[0]; + if (!header) { + throw new DevpostFileError( + "The file is empty. Export the submissions CSV from Devpost and upload it here.", + ); + } + + const index = new Map(); + header.forEach((name, i) => { + const key = headerKey(name); + if (!index.has(key)) index.set(key, i); + }); + + const missing = REQUIRED.filter((name) => !index.has(headerKey(name))); + if (missing.length > 0) { + throw new DevpostFileError( + `This is not a Devpost submissions export: it has no ${missing.join(", ")} column${missing.length === 1 ? "" : "s"}. In Devpost, use Manage hackathon, Metrics, Export submissions.`, + ); + } + if (rows.length === 1) { + throw new DevpostFileError( + "The file has column headings but no projects under them.", + ); + } + + const universitiesAt = + index.get(headerKey(COLUMNS.universities)) ?? + header.findIndex((name) => + headerKey(name).startsWith(LONG_UNIVERSITIES_PREFIX), + ); + const teammatesAt = index.get(headerKey(COLUMNS.teammates))!; + + const at = (row: string[], column: string) => { + const i = index.get(headerKey(column)); + return i === undefined ? "" : clean(row[i]); + }; + + // Teammate cells come in (first name, last name, email) triples straight + // after the count. Reads as many triples as the row's own count says. + const teamCells = (row: string[]) => { + const count = Number(clean(row[teammatesAt]) || "0"); + const triples = + Number.isInteger(count) && count >= 0 && count <= 99 ? count : 0; + return Array.from({ length: triples * 3 }, (_, k) => + clean(row[teammatesAt + 1 + k]), + ); + }; + + // What every row of one project must agree on. + const identity = (row: string[]) => + JSON.stringify([ + at(row, COLUMNS.title), + at(row, COLUMNS.about), + at(row, COLUMNS.firstName), + at(row, COLUMNS.lastName), + at(row, COLUMNS.email).toLowerCase(), + clean(row[teammatesAt]), + ...teamCells(row), + ]); + + const groups = new Map(); + const draftKeys = new Set(); + const rejected: DevpostRejection[] = []; + let collapsedRows = 0; + + rows.slice(1).forEach((cells, offset) => { + // Line numbers as a spreadsheet shows them: the header is line 1. + const line = offset + 2; + const title = at(cells, COLUMNS.title); + const rawUrl = at(cells, COLUMNS.url); + const key = rawUrl ? submissionKey(rawUrl) : null; + + if (!at(cells, COLUMNS.status).toLowerCase().startsWith("submitted")) { + draftKeys.add(key ?? `line:${line}`); + return; + } + if (!key) { + rejected.push({ + project: title || `Line ${line}`, + reason: rawUrl + ? `Its submission URL "${rawUrl}" is not an http or https link.` + : "It is marked submitted but has no submission URL.", + }); + return; + } + + const group = groups.get(key); + if (group) { + group.push({ cells, line }); + collapsedRows += 1; + } else { + groups.set(key, [{ cells, line }]); + } + }); + + const projects: DevpostProject[] = []; + for (const [submissionUrl, group] of groups) { + const first = group[0]!.cells; + const title = at(first, COLUMNS.title); + try { + projects.push( + readProject(submissionUrl, group, { + at, + identity, + teammatesAt, + universitiesAt, + }), + ); + } catch (error) { + if (!(error instanceof ProjectProblem)) throw error; + rejected.push({ project: title || submissionUrl, reason: error.message }); + } + } + + if (projects.length === 0) { + const firstProblem = rejected[0]; + throw new DevpostFileError( + firstProblem + ? `None of the submitted projects in this file could be read. The first problem: ${firstProblem.project}: ${firstProblem.reason}` + : "This file has no submitted projects, only drafts. Export again after the submission deadline.", + ); + } + + const labels: string[] = []; + for (const project of projects) { + for (const label of project.prizeCategories) { + if (!labels.some((known) => sameLabel(known, label))) labels.push(label); + } + } + labels.sort((a, b) => a.localeCompare(b)); + + return { + projects, + labels, + rejected, + drafts: [...draftKeys].filter((key) => !groups.has(key)).length, + collapsedRows, + }; +} + +function readProject( + submissionUrl: string, + group: Row[], + read: { + at: (row: string[], column: string) => string; + identity: (row: string[]) => string; + teammatesAt: number; + universitiesAt: number; + }, +): DevpostProject { + const { at, identity, teammatesAt, universitiesAt } = read; + const first = group[0]!.cells; + + const title = bounded(at(first, COLUMNS.title), MAX_TITLE, "The title"); + if (!title) throw new ProjectProblem("It has no project title."); + + const signature = identity(first); + const disagreeing = group.find((row) => identity(row.cells) !== signature); + if (disagreeing) { + throw new ProjectProblem( + `Its rows disagree (lines ${group[0]!.line} and ${disagreeing.line} give a different title, description, submitter or team for the same URL). Fix the export and import again.`, + ); + } + + const description = bounded( + at(first, COLUMNS.about), + MAX_DESCRIPTION, + "The description", + ); + + const countCell = clean(first[teammatesAt]); + const additional = Number(countCell || "0"); + if (!Number.isInteger(additional) || additional < 0) { + throw new ProjectProblem( + `Its Additional Team Member Count "${countCell}" is not a whole number.`, + ); + } + if (additional > MAX_ADDITIONAL_MEMBERS) { + throw new ProjectProblem( + `It lists ${additional} teammates besides the submitter; teams are at most ${MAX_ADDITIONAL_MEMBERS + 1} people.`, + ); + } + + const submitterName = [at(first, COLUMNS.firstName), at(first, COLUMNS.lastName)] + .filter(Boolean) + .join(" "); + const submitterEmail = at(first, COLUMNS.email); + if (!submitterName || !submitterEmail) { + throw new ProjectProblem("It is missing the submitter's name or email."); + } + + const members: DevpostMember[] = [ + { + name: bounded(submitterName, MAX_NAME, "The submitter's name"), + email: email(submitterEmail, "The submitter's email"), + order: 0, + }, + ]; + for (let k = 0; k < additional; k += 1) { + const start = teammatesAt + 1 + k * 3; + const name = [clean(first[start]), clean(first[start + 1])] + .filter(Boolean) + .join(" "); + const address = clean(first[start + 2]); + // Devpost leaves the cells of a teammate who never accepted blank. + if (!name && !address) continue; + if (!name || !address) { + throw new ProjectProblem( + `Teammate ${k + 1} has ${name ? "a name but no email" : "an email but no name"}.`, + ); + } + members.push({ + name: bounded(name, MAX_NAME, `Teammate ${k + 1}'s name`), + email: email(address, `Teammate ${k + 1}'s email`), + order: members.length, + }); + } + + const prizeCategories: string[] = []; + for (const row of group) { + const label = normalizeLabel(at(row.cells, COLUMNS.prize)); + if (!label) continue; + bounded(label, MAX_LABEL, "An opt-in prize name"); + if (CONTROL.test(label)) { + throw new ProjectProblem( + "An opt-in prize name holds control characters.", + ); + } + if (!prizeCategories.some((known) => sameLabel(known, label))) { + prizeCategories.push(label); + } + } + prizeCategories.sort((a, b) => a.localeCompare(b)); + + const demoLinks = [ + ...new Set( + splitList(at(first, COLUMNS.tryItOut)) + .map(httpUrl) + .filter((url): url is string => url !== null), + ), + ]; + const video = at(first, COLUMNS.video); + + return { + submissionUrl, + title, + description, + demoLinks, + videoUrl: video ? httpUrl(video) : null, + technologies: splitList(at(first, COLUMNS.builtWith)), + universities: + universitiesAt >= 0 ? splitList(first[universitiesAt]) : [], + prizeCategories, + participantCount: additional + 1, + members, + projectCreatedAt: parseDevpostDate(at(first, COLUMNS.createdAt)), + submittedAt: parseDevpostDate(at(first, COLUMNS.submittedAt)), + }; +} diff --git a/packages/api/src/services/judging/evaluation-store.test.ts b/packages/api/src/services/judging/evaluation-store.test.ts new file mode 100644 index 00000000..d41bf2bf --- /dev/null +++ b/packages/api/src/services/judging/evaluation-store.test.ts @@ -0,0 +1,589 @@ +import { describe, expect, it } from "vitest"; +import { getTableName } from "drizzle-orm"; +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { + judgingConfigs, + judgingEntries, + judgingEntryChallenges, + judgingEvaluationDrafts, + judgingEvaluations, + judgingJudges, + judgingRubricItems, +} from "@query/db"; +import { + claimGrading, + evaluatedChallenge, + guestScope, + inJudgeScope, + memberScope, + reconcileDueDrafts, +} from "./evaluation-store"; +import type { ChallengeRow, Seat } from "./evaluation-store"; +import type { Answers } from "./evaluation"; +import { GRADING_HOLD_MS } from "./grading-hold"; +import type { Judge } from "./principal"; +import type { DrizzleDB } from "@query/db"; + +// General (scheduled) +// Sponsor prize (scheduled) +// MLH Challenges (unscheduled group) +// ├── Best Use of MongoDB +// └── Best Domain Name +const row = (id: string, over: Partial = {}): ChallengeRow => ({ + id, + parentId: null, + mode: "scheduled", + label: id, + isGeneral: false, + isGroup: false, + ...over, +}); +const challenges: ChallengeRow[] = [ + row("sponsor"), + row("general", { isGeneral: true }), + row("mlh", { isGroup: true, mode: "unscheduled" }), + row("mongo", { parentId: "mlh" }), + row("domain", { parentId: "mlh" }), +]; + +const member: Judge = { + kind: "member", + hackathonId: "h", + userId: "u", + judgeId: "j", + displayName: "Ada", +}; +const guest: Judge = { + kind: "guest", + hackathonId: "h", + sessionId: "s", + linkId: "l", + roomId: "r", + roomChallengeId: "mlh", + judgeId: "g", + displayName: "Grace", +}; +const seat = (challengeId: string): Seat => ({ + roomId: "room", + roomName: "Room", + challengeId, +}); + +describe("guest scope", () => { + it("is the room's challenge and everything below it", () => { + expect([...guestScope(challenges, "mlh")].sort()).toEqual([ + "domain", + "mlh", + "mongo", + ]); + }); + + it("lets members see every challenge, guests only their room's", () => { + expect(inJudgeScope(member, challenges, "sponsor")).toBe(true); + expect(inJudgeScope(member, challenges, "missing")).toBe(false); + expect(inJudgeScope(guest, challenges, "mongo")).toBe(true); + expect(inJudgeScope(guest, challenges, "general")).toBe(false); + }); +}); + +describe("member scope", () => { + it("is General's challenges alone for a judge with no seat", () => { + expect([...memberScope(challenges, null)]).toEqual(["general"]); + }); + + it("adds a sponsor's challenge, and what is below it, from its room", () => { + expect([...memberScope(challenges, seat("sponsor"))].sort()).toEqual([ + "general", + "sponsor", + ]); + expect([...memberScope(challenges, seat("mlh"))].sort()).toEqual([ + "domain", + "general", + "mlh", + "mongo", + ]); + }); + + it("does not let a seat at General open a sponsor's challenge", () => { + expect([...memberScope(challenges, seat("general"))]).toEqual(["general"]); + }); +}); + +describe("evaluatedChallenge", () => { + it("evaluates a child challenge under its root", () => { + expect( + evaluatedChallenge(member, challenges, "mongo", seat("mlh")).id, + ).toBe("mlh"); + }); + + it("defaults a member to their room's challenge, then General", () => { + expect( + evaluatedChallenge(member, challenges, undefined, seat("sponsor")).id, + ).toBe("sponsor"); + expect(evaluatedChallenge(member, challenges, undefined, null).id).toBe( + "general", + ); + }); + + it("has nothing for a member to default to when there is only sponsors'", () => { + const noGeneral = challenges.filter((c) => c.id !== "general"); + expect(() => + evaluatedChallenge(member, noGeneral, undefined, null), + ).toThrowError(expect.objectContaining({ code: "FORBIDDEN" })); + expect( + evaluatedChallenge(member, noGeneral, undefined, seat("sponsor")).id, + ).toBe("sponsor"); + }); + + describe("sponsor challenges", () => { + const refused = (label: string) => + expect.objectContaining({ + code: "FORBIDDEN", + message: `Sponsor judges grade ${label}. Sit in its room to grade it.`, + }); + + it("refuses a table judge with no seat", () => { + expect(() => + evaluatedChallenge(member, challenges, "sponsor", null), + ).toThrowError(refused("sponsor")); + }); + + it("refuses a table judge seated at another challenge's room", () => { + expect(() => + evaluatedChallenge(member, challenges, "sponsor", seat("general")), + ).toThrowError(refused("sponsor")); + expect(() => + evaluatedChallenge(member, challenges, "sponsor", seat("mlh")), + ).toThrowError(refused("sponsor")); + }); + + it("names the root when the judge asked for a prize inside a group", () => { + expect(() => + evaluatedChallenge(member, challenges, "mongo", null), + ).toThrowError(refused("mlh")); + }); + + it("lets a table judge grade it from a seat in its room", () => { + expect( + evaluatedChallenge(member, challenges, "sponsor", seat("sponsor")).id, + ).toBe("sponsor"); + }); + + it("keeps General open whatever the seat", () => { + expect(evaluatedChallenge(member, challenges, "general", null).id).toBe( + "general", + ); + expect( + evaluatedChallenge(member, challenges, "general", seat("sponsor")).id, + ).toBe("general"); + }); + + it("leaves a guest on their room's challenge, seat or none", () => { + expect(evaluatedChallenge(guest, challenges, "mongo", null).id).toBe( + "mlh", + ); + }); + }); + + it("keeps a guest on their room's challenge", () => { + expect(evaluatedChallenge(guest, challenges, undefined, null).id).toBe( + "mlh", + ); + expect(evaluatedChallenge(guest, challenges, "domain", null).id).toBe( + "mlh", + ); + }); + + it("refuses a guest a challenge outside their room", () => { + expect(() => + evaluatedChallenge(guest, challenges, "general", null), + ).toThrowError(expect.objectContaining({ code: "FORBIDDEN" })); + }); + + it("refuses a member an unknown challenge", () => { + expect(() => + evaluatedChallenge(member, challenges, "missing", null), + ).toThrowError(expect.objectContaining({ code: "NOT_FOUND" })); + }); + + it("explains an edition with no challenges yet", () => { + expect(() => evaluatedChallenge(member, [], undefined, null)).toThrowError( + expect.objectContaining({ code: "PRECONDITION_FAILED" }), + ); + }); +}); + +describe("reconcileDueDrafts", () => { + it("opens no transaction when nothing is due", async () => { + let transactions = 0; + const builder: Record = {}; + for (const method of ["from", "innerJoin", "where"]) { + builder[method] = () => builder; + } + builder.limit = () => Promise.resolve([]); + const db = { + select: () => builder, + transaction: () => { + transactions += 1; + return Promise.resolve(); + }, + } as unknown as DrizzleDB; + + await reconcileDueDrafts(db, "h"); + expect(transactions).toBe(0); + }); +}); + +type Rows = unknown[] | ((call: number) => unknown[]); +type Event = { + kind: "select" | "execute" | "insert" | "update" | "delete"; + table?: string; + where?: SQL; + query?: SQL; +}; + +/** + * A database whose selects answer by the tables they read (and join), that + * keeps every statement in order. `answers` may be a function of how many times + * that key was read, for a table read more than once in a run. + */ +function fakeDb(answers: Record) { + const events: Event[] = []; + const reads = new Map(); + const via = (...tables: unknown[]) => + tables.map((table) => getTableName(table as never)).join(">"); + const rowsFor = (key: string) => { + const call = reads.get(key) ?? 0; + reads.set(key, call + 1); + const answer = answers[key] ?? []; + return typeof answer === "function" ? answer(call) : answer; + }; + + const select = () => { + const joined: unknown[] = []; + let from: unknown; + let where: SQL | undefined; + const chain: Record = { + from: (table: unknown) => { + from = table; + return chain; + }, + where: (clause: SQL) => { + where = clause; + return chain; + }, + then: ( + ok: (rows: unknown[]) => unknown, + fail: (e: unknown) => unknown, + ) => { + const key = via(from, ...joined); + events.push({ kind: "select", table: key, where }); + return Promise.resolve(rowsFor(key)).then(ok, fail); + }, + }; + for (const join of ["innerJoin", "leftJoin"]) { + chain[join] = (table: unknown) => { + joined.push(table); + return chain; + }; + } + for (const method of ["orderBy", "limit", "for"]) + chain[method] = () => chain; + return chain; + }; + + const db = { + select, + execute: (query: SQL) => { + events.push({ kind: "execute", query }); + return Promise.resolve(); + }, + insert: (table: unknown) => ({ + values: (values: unknown) => { + events.push({ kind: "insert", table: via(table) }); + const done = Promise.resolve([{ id: "new", values }]); + return Object.assign(done, { returning: () => done }); + }, + }), + update: (table: unknown) => ({ + set: () => ({ + where: (where: SQL) => { + events.push({ kind: "update", table: via(table), where }); + return Promise.resolve(); + }, + }), + }), + delete: (table: unknown) => ({ + where: (where: SQL) => { + events.push({ kind: "delete", table: via(table), where }); + return Promise.resolve(); + }, + }), + transaction: (run: (tx: unknown) => Promise) => run(db), + }; + return { db: db as never, events, via }; +} + +const dialect = new PgDialect(); +const textOf = (clause: SQL) => dialect.sqlToQuery(clause).sql; +const lockKeys = (events: Event[]) => + events + .filter((event) => event.kind === "execute") + .map((event) => dialect.sqlToQuery(event.query as SQL).params[0]); + +const RATING = "10000000-0000-4000-8000-000000000001"; +const RESPONSE = "10000000-0000-4000-8000-000000000002"; +const due = ( + id: string, + judgeId: string, + entryId: string, + challengeId = "c1", + answers: Answers = { ratings: { [RATING]: 4 }, responses: {} }, +) => ({ + id, + judgeId, + entryId, + challengeId, + appointmentId: "a1", + ...answers, + deadlineAt: new Date("2026-04-18T15:00:00Z"), +}); + +describe("reconcileDueDrafts and graded once", () => { + const dueKey = `${getTableName(judgingEvaluationDrafts)}>${getTableName(judgingConfigs)}`; + const answersFor = ( + drafts: ReturnType[], + evaluations: Rows = [], + ): Record => { + const name = (table: unknown) => getTableName(table as never); + return { + [dueKey]: drafts, + [name(judgingRubricItems)]: [ + { + id: RATING, + kind: "rating", + label: "Impact", + description: "", + required: true, + guestVisibility: null, + }, + { + id: RESPONSE, + kind: "response", + label: "Feedback", + description: "", + required: false, + guestVisibility: null, + }, + ], + [name(judgingJudges)]: [ + ...new Set(drafts.map((draft) => draft.judgeId)), + ].map((id) => ({ id, kind: "member" })), + [`${name(judgingEntries)}>${name(judgingEntryChallenges)}`]: [ + { id: "entry" }, + ], + [name(judgingEvaluations)]: evaluations, + }; + }; + const evaluationReads = (events: Event[]) => + events.filter( + (e) => + e.kind === "select" && e.table === getTableName(judgingEvaluations), + ); + const written = (events: Event[], table: unknown) => + events.filter( + (e) => e.kind === "insert" && e.table === getTableName(table as never), + ); + + it("submits a due draft for a project nobody has graded", async () => { + const { db, events } = fakeDb(answersFor([due("d1", "j1", "e1")])); + + await reconcileDueDrafts(db, "h", new Date("2026-04-18T15:05:00Z")); + + expect(written(events, judgingEvaluations)).toHaveLength(1); + expect(events.some((e) => e.kind === "delete")).toBe(true); + }); + + it("keeps a draft when another judge already graded the project", async () => { + const { db, events } = fakeDb( + answersFor([due("d1", "j1", "e1")], [{ id: "theirs" }]), + ); + + await reconcileDueDrafts(db, "h", new Date("2026-04-18T15:05:00Z")); + + expect(written(events, judgingEvaluations)).toHaveLength(0); + // The answers stay; only the deadline goes. + expect(events.some((e) => e.kind === "delete")).toBe(false); + expect(events.some((e) => e.kind === "update")).toBe(true); + }); + + it("keeps a due draft with nothing in it, from a judge who only opened the project", async () => { + const opened = due("d1", "j1", "e1", "c1", { ratings: {}, responses: {} }); + const { db, events } = fakeDb(answersFor([opened])); + + await reconcileDueDrafts(db, "h", new Date("2026-04-18T15:05:00Z")); + + expect(written(events, judgingEvaluations)).toHaveLength(0); + expect(events.some((e) => e.kind === "delete")).toBe(false); + expect(events.some((e) => e.kind === "update")).toBe(true); + }); + + it("still submits a due draft with only a written response in it", async () => { + const answered = due("d1", "j1", "e1", "c1", { + ratings: {}, + responses: { [RESPONSE]: { value: "Clear demo.", isPublic: false } }, + }); + const { db, events } = fakeDb(answersFor([answered])); + + await reconcileDueDrafts(db, "h", new Date("2026-04-18T15:05:00Z")); + + expect(written(events, judgingEvaluations)).toHaveLength(1); + }); + + it("looks for a grade from any judge, not just the draft's", async () => { + const { db, events } = fakeDb(answersFor([due("d1", "j1", "e1")])); + + await reconcileDueDrafts(db, "h", new Date("2026-04-18T15:05:00Z")); + + const [read] = evaluationReads(events); + const where = textOf(read.where as SQL); + expect(where).toContain('"entry_id"'); + expect(where).toContain('"challenge_id"'); + expect(where).not.toContain('"judge_id"'); + }); + + it("submits only the first of two drafts for one project", async () => { + // The second draft sees the evaluation the first just wrote. + const { db, events } = fakeDb( + answersFor([due("d1", "j1", "e1"), due("d2", "j2", "e1")], (call) => + call === 0 ? [] : [{ id: "new" }], + ), + ); + + await reconcileDueDrafts(db, "h", new Date("2026-04-18T15:05:00Z")); + + expect(written(events, judgingEvaluations)).toHaveLength(1); + expect(events.filter((e) => e.kind === "delete")).toHaveLength(1); + }); + + it("locks each project once, in key order, before it writes anything", async () => { + const { db, events } = fakeDb( + answersFor([ + due("d1", "j1", "e9"), + due("d2", "j2", "e1"), + due("d3", "j3", "e9"), + ]), + ); + + await reconcileDueDrafts(db, "h", new Date("2026-04-18T15:05:00Z")); + + expect(lockKeys(events)).toEqual(["grading:e1:c1", "grading:e9:c1"]); + const firstWrite = events.findIndex((e) => + ["insert", "update", "delete"].includes(e.kind), + ); + const lastLock = events.map((e) => e.kind).lastIndexOf("execute"); + expect(lastLock).toBeLessThan(firstWrite); + }); +}); + +describe("claimGrading", () => { + const judge = "me"; + const challenge: ChallengeRow = row("general", { + label: "General", + isGeneral: true, + }); + const NOW = new Date("2026-04-18T15:00:00Z"); + const evaluationsKey = getTableName(judgingEvaluations); + const draftsKey = getTableName(judgingEvaluationDrafts); + const claim = (answers: Record) => { + const fake = fakeDb(answers); + return { + ...fake, + run: () => + claimGrading( + fake.db, + { judgeId: judge, entryId: "e1", challenge }, + NOW, + ), + }; + }; + + it("lets a judge take a project nobody has", async () => { + const { run, events } = claim({}); + await expect(run()).resolves.toBeUndefined(); + expect(lockKeys(events)).toEqual(["grading:e1:general"]); + }); + + it("takes the lock before it reads", async () => { + const { run, events } = claim({}); + await run(); + expect(events[0].kind).toBe("execute"); + expect(events.slice(1).every((e) => e.kind === "select")).toBe(true); + }); + + it("refuses a project another judge already graded", async () => { + const { run } = claim({ [evaluationsKey]: [{ judgeId: "them" }] }); + await expect(run()).rejects.toMatchObject({ + code: "CONFLICT", + message: + "Another judge has already graded this project for General. Each project is graded once.", + }); + }); + + it("refuses a project another judge is grading", async () => { + const { run } = claim({ + [draftsKey]: [ + { + judgeId: "them", + updatedAt: new Date(NOW.getTime() - 60_000), + deadlineAt: null, + }, + ], + }); + await expect(run()).rejects.toMatchObject({ + code: "CONFLICT", + message: + "Another judge is grading this project right now. Pick another one.", + }); + }); + + it("refuses while a presentation's draft is before its deadline", async () => { + const { run } = claim({ + [draftsKey]: [ + { + judgeId: "them", + updatedAt: new Date(NOW.getTime() - GRADING_HOLD_MS * 3), + deadlineAt: new Date(NOW.getTime() + 60_000), + }, + ], + }); + await expect(run()).rejects.toMatchObject({ code: "CONFLICT" }); + }); + + it("lets a judge take over a draft left alone past the hold", async () => { + const { run } = claim({ + [draftsKey]: [ + { + judgeId: "them", + updatedAt: new Date(NOW.getTime() - GRADING_HOLD_MS - 1), + deadlineAt: null, + }, + ], + }); + await expect(run()).resolves.toBeUndefined(); + }); + + it("only looks at other judges' rows", async () => { + const { run, events } = claim({}); + await run(); + const reads = events.filter((e) => e.kind === "select"); + expect(reads).toHaveLength(2); + for (const read of reads) { + const where = textOf(read.where as SQL); + expect(where).toContain('"judge_id" <> $'); + expect(where).toContain('"entry_id" = $'); + expect(where).toContain('"challenge_id" = $'); + } + }); +}); diff --git a/packages/api/src/services/judging/evaluation-store.ts b/packages/api/src/services/judging/evaluation-store.ts new file mode 100644 index 00000000..d1ce21ec --- /dev/null +++ b/packages/api/src/services/judging/evaluation-store.ts @@ -0,0 +1,833 @@ +import { TRPCError } from "@trpc/server"; +import { + and, + asc, + eq, + gt, + inArray, + isNull, + lte, + ne, + or, + sql, +} from "drizzle-orm"; +import { + judgingAppointments, + judgingChallenges, + judgingConfigs, + judgingEntries, + judgingEntryChallenges, + judgingEvaluationDrafts, + judgingEvaluationRatings, + judgingEvaluationResponses, + judgingEvaluationRevisions, + judgingEvaluations, + judgingGuestSessions, + judgingJudges, + judgingPresence, + judgingRoomLinks, + judgingRooms, + judgingRubricItems, + judgingSchedules, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import type { JudgingDb, JudgingTx } from "./config"; +import { checkAnswers, snapshotOf } from "./evaluation"; +import type { CheckedAnswers } from "./evaluation"; +import { gradingLockKey, gradingRefusal, gradingStateOf } from "./grading-hold"; +import { GUEST_SESSION_ENDED } from "./principal"; +import type { Judge } from "./principal"; +import { + descendantsOf, + effectiveMode, + isSponsorChallenge, + rootOf, +} from "./scope"; +import type { ChallengeMode } from "./scope"; +import { + DEADLINE_GRACE_MS, + evaluationTiming, + reservedUntil as reservedUntilOf, +} from "./timing"; +import type { EvaluationTiming } from "./timing"; + +// The judge desk's reads and writes around one evaluation: which challenge a +// judge is evaluating, whether they may write now, the write itself, and +// submitting drafts whose time ran out. +// +// A judge evaluates a project for a root challenge: General, a sponsor prize, +// or a group such as "MLH Challenges". Children of a group are what the team +// opted into, shown as tags; the presentation, the room and the evaluation all +// belong to the root. + +export type ChallengeRow = { + id: string; + parentId: string | null; + mode: ChallengeMode; + label: string; + isGeneral: boolean; + isGroup: boolean; +}; + +export async function loadChallenges( + db: JudgingDb, + hackathonId: string, +): Promise { + return db + .select({ + id: judgingChallenges.id, + parentId: judgingChallenges.parentId, + mode: judgingChallenges.mode, + label: judgingChallenges.label, + isGeneral: judgingChallenges.isGeneral, + isGroup: judgingChallenges.isGroup, + }) + .from(judgingChallenges) + .where(eq(judgingChallenges.hackathonId, hackathonId)) + .orderBy(asc(judgingChallenges.displayOrder), asc(judgingChallenges.label)); +} + +/** The challenges a guest may see: their room's, and everything below it. */ +export function guestScope( + challenges: readonly ChallengeRow[], + roomChallengeId: string, +): Set { + return new Set([ + roomChallengeId, + ...descendantsOf(challenges, roomChallengeId).map((c) => c.id), + ]); +} + +/** Whether `judge` may see `challengeId` at all. */ +export function inJudgeScope( + judge: Judge, + challenges: readonly ChallengeRow[], + challengeId: string, +): boolean { + if (judge.kind === "member") { + return challenges.some((c) => c.id === challengeId); + } + return guestScope(challenges, judge.roomChallengeId).has(challengeId); +} + +export type Seat = { + roomId: string; + roomName: string; + /** The root of the challenge the room serves. */ + challengeId: string; +}; + +/** The room a judge is sitting in right now, if it is still open. */ +export async function seatOf( + db: JudgingDb, + judgeId: string, + challenges: readonly ChallengeRow[], +): Promise { + const [row] = await db + .select({ + roomId: judgingRooms.id, + roomName: judgingRooms.name, + challengeId: judgingRooms.challengeId, + }) + .from(judgingPresence) + .innerJoin(judgingRooms, eq(judgingRooms.id, judgingPresence.roomId)) + .where( + and( + eq(judgingPresence.judgeId, judgeId), + isNull(judgingPresence.leftAt), + isNull(judgingRooms.archivedAt), + ), + ) + .limit(1); + if (!row) return null; + return { + roomId: row.roomId, + roomName: row.roomName, + challengeId: rootOf(challenges, row.challengeId)?.id ?? row.challengeId, + }; +} + +/** + * Sponsor challenges belong to the sponsor's own judges: a member (table) + * judge grades one only while seated in a room that serves it. + */ +function memberMayGrade(root: ChallengeRow, seat: Seat | null): boolean { + return !isSponsorChallenge(root) || seat?.challengeId === root.id; +} + +/** + * The challenges a member judge may pick right now: the general ones, and a + * sponsor's (with everything below it) only from a seat in its room. + */ +export function memberScope( + challenges: readonly ChallengeRow[], + seat: Seat | null, +): Set { + return new Set( + challenges + .filter((c) => { + const root = rootOf(challenges, c.id); + return !!root && memberMayGrade(root, seat); + }) + .map((c) => c.id), + ); +} + +/** + * The root challenge a judge is evaluating. A guest always evaluates their + * room's; a filter they pass has to sit inside it. A member evaluates the root + * of what they picked, or else the room they sit in, or else General, and + * only a sponsor's challenge from a seat in the room that serves it. + */ +export function evaluatedChallenge( + judge: Judge, + challenges: readonly ChallengeRow[], + requestedId: string | null | undefined, + seat: Seat | null, +): ChallengeRow { + if (requestedId && !inJudgeScope(judge, challenges, requestedId)) { + throw new TRPCError({ + code: judge.kind === "guest" ? "FORBIDDEN" : "NOT_FOUND", + message: + judge.kind === "guest" + ? "That challenge is not judged at your room or table." + : "That challenge is not part of this hackathon.", + }); + } + + const pick = + judge.kind === "guest" + ? judge.roomChallengeId + : (requestedId ?? + seat?.challengeId ?? + challenges.find((c) => c.isGeneral && !c.parentId)?.id ?? + challenges.find((c) => !c.parentId)?.id); + const root = pick ? rootOf(challenges, pick) : null; + if (!root) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "This hackathon has no judging challenges yet. Ask an organiser to import projects first.", + }); + } + if (judge.kind === "member" && !memberMayGrade(root, seat)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `Sponsor judges grade ${root.label}. Sit in its room to grade it.`, + }); + } + return root; +} + +export type JudgingSwitches = { + state: "draft" | "open" | "closed"; + showAllScores: boolean; + closedAt: Date | null; +}; + +/** The edition's judging state, read only: a missing row is a draft. */ +export async function judgingSwitches( + db: JudgingDb, + hackathonId: string, +): Promise { + const [config] = await db + .select({ + state: judgingConfigs.state, + showAllScores: judgingConfigs.showAllScores, + closedAt: judgingConfigs.closedAt, + }) + .from(judgingConfigs) + .where(eq(judgingConfigs.hackathonId, hackathonId)) + .limit(1); + return config ?? { state: "draft", showAllScores: false, closedAt: null }; +} + +/** + * Refuses a write unless judging is open. Holds a share lock on the config + * row, so an organiser closing judging waits for writes already under way and + * none start after. + */ +export async function assertJudgingOpen(tx: JudgingTx, hackathonId: string) { + const [config] = await tx + .select({ state: judgingConfigs.state }) + .from(judgingConfigs) + .where(eq(judgingConfigs.hackathonId, hackathonId)) + .for("share") + .limit(1); + if (config?.state === "open") return; + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + config?.state === "closed" + ? "Judging is closed. Your submitted evaluations are read-only now." + : "Judging has not opened yet. Your answers can be saved once organisers open it.", + }); +} + +/** + * For a guest's write: their session must still be live. Share-locked so an + * organiser revoking the room's link waits for this write rather than racing + * it. + */ +export async function assertGuestSeated(tx: JudgingTx, judge: Judge) { + if (judge.kind !== "guest") return; + const [session] = await tx + .select({ id: judgingGuestSessions.id }) + .from(judgingGuestSessions) + .innerJoin( + judgingRoomLinks, + eq(judgingRoomLinks.id, judgingGuestSessions.linkId), + ) + .innerJoin(judgingRooms, eq(judgingRooms.id, judgingGuestSessions.roomId)) + .where( + and( + eq(judgingGuestSessions.id, judge.sessionId), + isNull(judgingGuestSessions.revokedAt), + gt(judgingGuestSessions.expiresAt, new Date()), + isNull(judgingRoomLinks.revokedAt), + isNull(judgingRooms.archivedAt), + ), + ) + .for("share", { of: judgingGuestSessions }) + .limit(1); + if (!session) { + throw new TRPCError({ code: "UNAUTHORIZED", message: GUEST_SESSION_ENDED }); + } +} + +/** A live project entered in the challenge, or NOT_FOUND. */ +export async function loadEntryInChallenge( + db: JudgingDb, + input: { hackathonId: string; entryId: string; challenge: ChallengeRow }, +) { + const [entry] = await db + .select({ + id: judgingEntries.id, + title: judgingEntries.title, + description: judgingEntries.description, + submissionUrl: judgingEntries.submissionUrl, + demoLinks: judgingEntries.demoLinks, + videoUrl: judgingEntries.videoUrl, + technologies: judgingEntries.technologies, + participantCount: judgingEntries.participantCount, + }) + .from(judgingEntries) + .innerJoin( + judgingEntryChallenges, + and( + eq(judgingEntryChallenges.entryId, judgingEntries.id), + eq(judgingEntryChallenges.challengeId, input.challenge.id), + ), + ) + .where( + and( + eq(judgingEntries.id, input.entryId), + eq(judgingEntries.hackathonId, input.hackathonId), + isNull(judgingEntries.deletedAt), + ), + ) + .limit(1); + if (!entry) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `That project is not judged in ${input.challenge.label}. It may have been withdrawn; go back to the project list.`, + }); + } + return entry; +} + +export async function loadRubric(db: JudgingDb, hackathonId: string) { + return db + .select({ + id: judgingRubricItems.id, + kind: judgingRubricItems.kind, + label: judgingRubricItems.label, + description: judgingRubricItems.description, + required: judgingRubricItems.required, + guestVisibility: judgingRubricItems.guestVisibility, + }) + .from(judgingRubricItems) + .where(eq(judgingRubricItems.hackathonId, hackathonId)) + .orderBy( + asc(judgingRubricItems.displayOrder), + asc(judgingRubricItems.createdAt), + ); +} + +export type Presentation = { + id: string; + roomId: string; + startsAt: Date; + deadlineAt: Date; + endsAt: Date; + /** For the timing rules only: never send it to a judge. */ + assignedJudgeId: string | null; +}; + +/** + * Whether `judge` may write their evaluation of an entry for a challenge now, + * with the presentation it is tied to. Reads the saved schedule and where the + * judge sits; the rule itself is services/judging/timing.ts. A presentation + * assigned to another judge is refused until its takeover time, which is how a + * claim honours the assignment: every claim runs this inside the transaction + * that holds the grading lock (claimGrading), and the assigned judge's draft + * keeps others off once they have started. + * + * `reservedUntil` is when the project opens to this judge if it is held back + * for another judge until then, for the grading state a read reports. + */ +export async function evaluationAccess( + db: JudgingDb, + input: { + hackathonId: string; + judgeId: string; + entryId: string; + challenge: ChallengeRow; + challenges: readonly ChallengeRow[]; + evaluation: { appointmentId: string | null } | null; + /** DEADLINE_GRACE_MS when the caller is writing. */ + graceMs?: number; + }, + now = new Date(), +): Promise<{ + timing: EvaluationTiming; + presentation: Presentation | null; + reservedUntil: Date | null; +}> { + const [schedule] = await db + .select({ id: judgingSchedules.id }) + .from(judgingSchedules) + .where(eq(judgingSchedules.hackathonId, input.hackathonId)) + .limit(1); + + const [presentation] = schedule + ? await db + .select({ + id: judgingAppointments.id, + roomId: judgingAppointments.roomId, + startsAt: judgingAppointments.startsAt, + deadlineAt: judgingAppointments.deadlineAt, + endsAt: judgingAppointments.endsAt, + assignedJudgeId: judgingAppointments.assignedJudgeId, + }) + .from(judgingAppointments) + .where( + and( + eq(judgingAppointments.scheduleId, schedule.id), + eq(judgingAppointments.entryId, input.entryId), + eq(judgingAppointments.challengeId, input.challenge.id), + ), + ) + .limit(1) + : []; + + const seat = await seatOf(db, input.judgeId, input.challenges); + const mode = + effectiveMode(input.challenges, input.challenge.id) ?? "scheduled"; + const timing = evaluationTiming( + { + judgeId: input.judgeId, + hasSchedule: !!schedule, + mode, + challengeId: input.challenge.id, + evaluation: input.evaluation, + appointment: presentation ?? null, + presenceChallengeId: seat?.challengeId ?? null, + graceMs: input.graceMs, + }, + now, + ); + // A judge's own evaluation is theirs whoever the presentation was assigned to. + const reservedUntil = + schedule && mode === "scheduled" && !input.evaluation + ? reservedUntilOf(presentation ?? null, input.judgeId, now) + : null; + return { timing, presentation: presentation ?? null, reservedUntil }; +} + +/** + * Serialises the writers of one project's grade for one challenge until the + * transaction ends: a judge's submit, another judge's first save, and the + * deadline submission each check that nobody else has the project and then + * write, which two of them cannot do at once. Transaction-level, so it is safe + * behind the connection pooler. Take a draft's row lock before this one, and + * take several in key order (gradingLockKey), so the locks cannot deadlock. + */ +export async function lockGrading( + tx: JudgingTx, + entryId: string, + challengeId: string, +) { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${gradingLockKey(entryId, challengeId)}::text, 0))`, + ); +} + +/** + * For a judge who has not graded a project yet and is about to write to it: + * takes the project's grading lock, then refuses if another judge has graded + * it or is grading it now (see grading-hold.ts). Run it after the judge's own + * rows are locked, and skip it for a judge editing their own evaluation. + * + * This is also what keeps a late takeover off a presentation whose assigned + * judge has started: their draft holds it. Whether the judge may claim a + * presentation assigned to someone else yet is evaluationAccess's call, run + * next under this lock. + */ +export async function claimGrading( + tx: JudgingTx, + input: { judgeId: string; entryId: string; challenge: ChallengeRow }, + now = new Date(), +) { + await lockGrading(tx, input.entryId, input.challenge.id); + + const evaluations = await tx + .select({ judgeId: judgingEvaluations.judgeId }) + .from(judgingEvaluations) + .where( + and( + eq(judgingEvaluations.entryId, input.entryId), + eq(judgingEvaluations.challengeId, input.challenge.id), + ne(judgingEvaluations.judgeId, input.judgeId), + ), + ); + const drafts = await tx + .select({ + judgeId: judgingEvaluationDrafts.judgeId, + updatedAt: judgingEvaluationDrafts.updatedAt, + deadlineAt: judgingEvaluationDrafts.deadlineAt, + }) + .from(judgingEvaluationDrafts) + .where( + and( + eq(judgingEvaluationDrafts.entryId, input.entryId), + eq(judgingEvaluationDrafts.challengeId, input.challenge.id), + ne(judgingEvaluationDrafts.judgeId, input.judgeId), + ), + ); + + const refusal = gradingRefusal( + gradingStateOf(input.judgeId, evaluations, drafts, now), + input.challenge.label, + ); + if (refusal) throw new TRPCError({ code: "CONFLICT", message: refusal }); +} + +/** + * Writes one evaluation: the row (new, or the next revision of the existing + * one), its ratings and responses, and a revision snapshot. The first result + * of a scheduled presentation also stamps the schedule, which from then on + * cannot be dropped. The caller holds the locks and has checked access. + */ +export async function writeEvaluation( + tx: JudgingTx, + input: { + hackathonId: string; + judgeId: string; + entryId: string; + challengeId: string; + appointmentId: string | null; + answers: CheckedAnswers; + existing: { id: string; revision: number } | null; + /** When it counts as submitted: now, or the deadline that passed. */ + at: Date; + auto: boolean; + }, +): Promise<{ evaluationId: string; revision: number }> { + const { answers, existing, at } = input; + const revision = (existing?.revision ?? 0) + 1; + + let evaluationId: string; + if (existing) { + evaluationId = existing.id; + await tx + .update(judgingEvaluations) + .set({ revision, isComplete: answers.isComplete, updatedAt: at }) + .where(eq(judgingEvaluations.id, evaluationId)); + await tx + .delete(judgingEvaluationRatings) + .where(eq(judgingEvaluationRatings.evaluationId, evaluationId)); + await tx + .delete(judgingEvaluationResponses) + .where(eq(judgingEvaluationResponses.evaluationId, evaluationId)); + } else { + const [created] = await tx + .insert(judgingEvaluations) + .values({ + hackathonId: input.hackathonId, + judgeId: input.judgeId, + entryId: input.entryId, + challengeId: input.challengeId, + appointmentId: input.appointmentId, + isComplete: answers.isComplete, + revision, + submittedAt: at, + autoSubmittedAt: input.auto ? at : null, + createdAt: at, + updatedAt: at, + }) + .returning({ id: judgingEvaluations.id }); + evaluationId = created.id; + } + + if (answers.ratings.length > 0) { + await tx.insert(judgingEvaluationRatings).values( + answers.ratings.map((rating) => ({ + evaluationId, + itemId: rating.itemId, + value: rating.value, + })), + ); + } + if (answers.responses.length > 0) { + await tx.insert(judgingEvaluationResponses).values( + answers.responses.map((response) => ({ + evaluationId, + itemId: response.itemId, + value: response.value, + isPublic: response.isPublic, + })), + ); + } + + await tx.insert(judgingEvaluationRevisions).values({ + evaluationId, + revision, + snapshot: { + ...snapshotOf(answers), + isComplete: answers.isComplete, + auto: input.auto, + }, + createdAt: at, + }); + + if (input.appointmentId) { + await tx + .update(judgingSchedules) + .set({ firstResultAt: at }) + .where( + and( + eq(judgingSchedules.hackathonId, input.hackathonId), + isNull(judgingSchedules.firstResultAt), + ), + ); + } + + return { evaluationId, revision }; +} + +// Drafts submitted per read. The rest wait for the next one: a desk poll is +// never more than fifteen seconds away. +const RECONCILE_BATCH = 50; + +/** + * Submits drafts whose deadline has passed, as of their deadline, marked as + * submitted for the judge, complete or not. Run at the top of desk reads (and + * anything else that reports on evaluations): there is no background worker. + * + * Only drafts that could have been submitted count: judging open, or closed + * after the deadline passed. Concurrent readers lock with SKIP LOCKED, so a + * draft is submitted once, and a judge's own submit (which locks the draft + * first) wins over this. A draft that can no longer be submitted (the guest's + * session was revoked before the deadline, the project was withdrawn, the + * schedule was dropped, another judge already graded the project) keeps its + * answers and loses its deadline. So does a draft with no answers in it: the + * judge only opened the project (desk.startGrading), and that is not a grade. + * + * Each project is graded once, so the batch takes the grading lock of every + * project it touches before it writes anything: in key order, and ahead of any + * write, so it cannot deadlock with another batch or with a judge's submit. + */ +export async function reconcileDueDrafts( + db: DrizzleDB, + hackathonId: string, + now = new Date(), +) { + // Waits out the grace a judge's own submit gets, so the two never race. + const due = and( + eq(judgingEvaluationDrafts.hackathonId, hackathonId), + lte( + judgingEvaluationDrafts.deadlineAt, + new Date(now.getTime() - DEADLINE_GRACE_MS), + ), + or( + eq(judgingConfigs.state, "open"), + and( + eq(judgingConfigs.state, "closed"), + lte(judgingEvaluationDrafts.deadlineAt, judgingConfigs.closedAt), + ), + ), + ); + + // Cheap check first: on almost every read nothing is due. + const [any] = await db + .select({ id: judgingEvaluationDrafts.id }) + .from(judgingEvaluationDrafts) + .innerJoin( + judgingConfigs, + eq(judgingConfigs.hackathonId, judgingEvaluationDrafts.hackathonId), + ) + .where(due) + .limit(1); + if (!any) return; + + await db.transaction(async (tx) => { + const drafts = await tx + .select({ + id: judgingEvaluationDrafts.id, + judgeId: judgingEvaluationDrafts.judgeId, + entryId: judgingEvaluationDrafts.entryId, + challengeId: judgingEvaluationDrafts.challengeId, + appointmentId: judgingEvaluationDrafts.appointmentId, + ratings: judgingEvaluationDrafts.ratings, + responses: judgingEvaluationDrafts.responses, + deadlineAt: judgingEvaluationDrafts.deadlineAt, + }) + .from(judgingEvaluationDrafts) + .innerJoin( + judgingConfigs, + eq(judgingConfigs.hackathonId, judgingEvaluationDrafts.hackathonId), + ) + .where(due) + .orderBy(asc(judgingEvaluationDrafts.deadlineAt)) + .limit(RECONCILE_BATCH) + .for("update", { of: judgingEvaluationDrafts, skipLocked: true }); + if (drafts.length === 0) return; + + const rubric = await loadRubric(tx, hackathonId); + const kinds = await tx + .select({ id: judgingJudges.id, kind: judgingJudges.kind }) + .from(judgingJudges) + .where( + inArray( + judgingJudges.id, + drafts.map((draft) => draft.judgeId), + ), + ); + const kindOf = new Map(kinds.map((judge) => [judge.id, judge.kind])); + + const locks = new Map( + drafts.map((draft) => [ + gradingLockKey(draft.entryId, draft.challengeId), + draft, + ]), + ); + for (const key of [...locks.keys()].sort()) { + const draft = locks.get(key)!; + await lockGrading(tx, draft.entryId, draft.challengeId); + } + + const retain = (draftId: string) => + tx + .update(judgingEvaluationDrafts) + .set({ deadlineAt: null }) + .where(eq(judgingEvaluationDrafts.id, draftId)); + + for (const draft of drafts) { + const deadlineAt = draft.deadlineAt as Date; + const kind = kindOf.get(draft.judgeId); + + if ( + !kind || + !draft.appointmentId || + (kind === "guest" && + !(await guestSeatedAt(tx, draft.judgeId, deadlineAt))) || + !(await entryStillJudged(tx, draft.entryId, draft.challengeId)) + ) { + await retain(draft.id); + continue; + } + + const [existing] = await tx + .select({ id: judgingEvaluations.id }) + .from(judgingEvaluations) + .where( + and( + eq(judgingEvaluations.entryId, draft.entryId), + eq(judgingEvaluations.challengeId, draft.challengeId), + ), + ) + .limit(1); + if (existing) { + // Never overwrite a result the judge submitted themselves, and a + // project another judge graded is graded. + await retain(draft.id); + continue; + } + + const check = checkAnswers(rubric, draft, kind, "lenient"); + if ( + !check.ok || + (check.answers.ratings.length === 0 && + check.answers.responses.length === 0) + ) { + await retain(draft.id); + continue; + } + + await writeEvaluation(tx, { + hackathonId, + judgeId: draft.judgeId, + entryId: draft.entryId, + challengeId: draft.challengeId, + appointmentId: draft.appointmentId, + answers: check.answers, + existing: null, + at: deadlineAt, + auto: true, + }); + await tx + .delete(judgingEvaluationDrafts) + .where(eq(judgingEvaluationDrafts.id, draft.id)); + } + }); +} + +/** Whether a guest judge still held a live session when `at` came. */ +async function guestSeatedAt(tx: JudgingTx, judgeId: string, at: Date) { + const [session] = await tx + .select({ id: judgingGuestSessions.id }) + .from(judgingGuestSessions) + .innerJoin( + judgingRoomLinks, + eq(judgingRoomLinks.id, judgingGuestSessions.linkId), + ) + .where( + and( + eq(judgingGuestSessions.judgeId, judgeId), + gt(judgingGuestSessions.expiresAt, at), + or( + isNull(judgingGuestSessions.revokedAt), + gt(judgingGuestSessions.revokedAt, at), + ), + or( + isNull(judgingRoomLinks.revokedAt), + gt(judgingRoomLinks.revokedAt, at), + ), + ), + ) + .limit(1); + return !!session; +} + +async function entryStillJudged( + tx: JudgingTx, + entryId: string, + challengeId: string, +) { + const [row] = await tx + .select({ id: judgingEntries.id }) + .from(judgingEntries) + .innerJoin( + judgingEntryChallenges, + and( + eq(judgingEntryChallenges.entryId, judgingEntries.id), + eq(judgingEntryChallenges.challengeId, challengeId), + ), + ) + .where( + and(eq(judgingEntries.id, entryId), isNull(judgingEntries.deletedAt)), + ) + .limit(1); + return !!row; +} diff --git a/packages/api/src/services/judging/evaluation.test.ts b/packages/api/src/services/judging/evaluation.test.ts new file mode 100644 index 00000000..54392dd8 --- /dev/null +++ b/packages/api/src/services/judging/evaluation.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "vitest"; +import { + checkAnswers, + isPublicResponse, + responseVisibility, + snapshotOf, +} from "./evaluation"; +import type { RubricItem } from "./evaluation"; + +const rubric: RubricItem[] = [ + { + id: "impact", + kind: "rating", + label: "Impact", + required: true, + guestVisibility: null, + }, + { + id: "craft", + kind: "rating", + label: "Craft", + required: true, + guestVisibility: null, + }, + { + id: "praise", + kind: "response", + label: "What worked", + required: true, + guestVisibility: "public", + }, + { + id: "advice", + kind: "response", + label: "What to try next", + required: false, + guestVisibility: "optional", + }, + { + id: "notes", + kind: "response", + label: "Notes for organisers", + required: false, + guestVisibility: "private", + }, +]; + +const full = { + ratings: { impact: 4, craft: 5 }, + responses: { + praise: { value: " Clear demo. ", isPublic: false }, + advice: { value: "Ship the API.", isPublic: true }, + notes: { value: "Strong team.", isPublic: true }, + }, +}; + +describe("responseVisibility", () => { + it("shows a member's responses to the team, always", () => { + expect(responseVisibility({ guestVisibility: "private" }, "member")).toBe( + "public", + ); + }); + + it("follows the item for guests, private when unset", () => { + expect(responseVisibility({ guestVisibility: "optional" }, "guest")).toBe( + "optional", + ); + expect(responseVisibility({ guestVisibility: null }, "guest")).toBe( + "private", + ); + }); + + it("lets the judge choose only when optional", () => { + expect(isPublicResponse("optional", true)).toBe(true); + expect(isPublicResponse("optional", false)).toBe(false); + expect(isPublicResponse("public", false)).toBe(true); + expect(isPublicResponse("private", true)).toBe(false); + }); +}); + +describe("checkAnswers", () => { + it("accepts a complete set, trimmed, in rubric order", () => { + const result = checkAnswers(rubric, full, "guest", "strict"); + expect(result).toEqual({ + ok: true, + answers: { + ratings: [ + { itemId: "impact", value: 4 }, + { itemId: "craft", value: 5 }, + ], + responses: [ + { itemId: "praise", value: "Clear demo.", isPublic: true }, + { itemId: "advice", value: "Ship the API.", isPublic: true }, + { itemId: "notes", value: "Strong team.", isPublic: false }, + ], + isComplete: true, + missing: [], + }, + }); + }); + + it("makes every member response public", () => { + const result = checkAnswers(rubric, full, "member", "strict"); + expect(result.ok && result.answers.responses.every((r) => r.isPublic)).toBe( + true, + ); + }); + + it("names what is missing: unrated criteria and blank required responses", () => { + const result = checkAnswers( + rubric, + { + ratings: { impact: 3 }, + responses: { praise: { value: " ", isPublic: true } }, + }, + "member", + "strict", + ); + expect(result).toMatchObject({ + ok: true, + answers: { + isComplete: false, + missing: ["Craft", "What worked"], + responses: [], + }, + }); + }); + + it("does not need optional responses", () => { + const result = checkAnswers( + rubric, + { + ratings: { impact: 1, craft: 2 }, + responses: { praise: { value: "Good", isPublic: false } }, + }, + "guest", + "strict", + ); + expect(result.ok && result.answers.isComplete).toBe(true); + }); + + it("refuses answers to items that are not on the rubric", () => { + expect( + checkAnswers( + rubric, + { ratings: { gone: 3 }, responses: {} }, + "member", + "strict", + ), + ).toMatchObject({ ok: false }); + expect( + checkAnswers( + rubric, + { ratings: { praise: 3 }, responses: {} }, + "member", + "strict", + ), + ).toMatchObject({ ok: false }); + expect( + checkAnswers( + rubric, + { ratings: {}, responses: { impact: { value: "x", isPublic: true } } }, + "member", + "strict", + ), + ).toMatchObject({ ok: false }); + }); + + it("refuses ratings outside 1–5 or not whole", () => { + for (const value of [0, 6, 2.5]) { + expect( + checkAnswers( + rubric, + { ratings: { impact: value }, responses: {} }, + "member", + "strict", + ), + ).toEqual({ + ok: false, + message: "Ratings are whole numbers from 1 to 5.", + }); + } + }); + + it("drops what it cannot use when lenient", () => { + const result = checkAnswers( + rubric, + { + ratings: { impact: 4, craft: 9, gone: 2 }, + responses: { gone: { value: "old question", isPublic: true } }, + }, + "guest", + "lenient", + ); + expect(result).toMatchObject({ + ok: true, + answers: { + ratings: [{ itemId: "impact", value: 4 }], + responses: [], + isComplete: false, + missing: ["Craft", "What worked"], + }, + }); + }); +}); + +describe("snapshotOf", () => { + it("keys the checked answers by item", () => { + const result = checkAnswers(rubric, full, "guest", "strict"); + if (!result.ok) throw new Error("expected valid answers"); + expect(snapshotOf(result.answers)).toEqual({ + ratings: { impact: 4, craft: 5 }, + responses: { + praise: { value: "Clear demo.", isPublic: true }, + advice: { value: "Ship the API.", isPublic: true }, + notes: { value: "Strong team.", isPublic: false }, + }, + }); + }); +}); diff --git a/packages/api/src/services/judging/evaluation.ts b/packages/api/src/services/judging/evaluation.ts new file mode 100644 index 00000000..9b77a666 --- /dev/null +++ b/packages/api/src/services/judging/evaluation.ts @@ -0,0 +1,149 @@ +import type { JudgingDraftRatings, JudgingDraftResponses } from "@query/db"; + +// Checking a judge's answers against the rubric. Pure: the desk runs it on +// every draft save, on submit, and when a draft is submitted for a judge whose +// time ran out. +// +// Ratings are 1–5 and always required. A short response is required or not +// per item, and who may read it depends on the judge: a member judge's +// responses are always shown to the team; a guest's follow the item's guest +// visibility, where "optional" lets the guest choose. + +export type Visibility = "public" | "optional" | "private"; +export type JudgeKind = "member" | "guest"; + +/** The columns these checks need from a judging_rubric_item row. */ +export type RubricItem = { + id: string; + kind: "rating" | "response"; + label: string; + required: boolean; + guestVisibility: Visibility | null; +}; + +export type Answers = { + ratings: JudgingDraftRatings; + responses: JudgingDraftResponses; +}; + +export type CheckedAnswers = { + ratings: { itemId: string; value: number }[]; + /** Non-blank responses, trimmed, with who may read them decided. */ + responses: { itemId: string; value: string; isPublic: boolean }[]; + isComplete: boolean; + /** Labels of the items still to answer, in rubric order. */ + missing: string[]; +}; + +export type AnswerCheck = + { ok: true; answers: CheckedAnswers } | { ok: false; message: string }; + +/** Who may read a judge's response to `item`. */ +export function responseVisibility( + item: Pick, + kind: JudgeKind, +): Visibility { + if (kind === "member") return "public"; + // An item saved without a guest setting is kept from the team rather than + // shown by accident. + return item.guestVisibility ?? "private"; +} + +export function isPublicResponse(visibility: Visibility, requested: boolean) { + if (visibility === "optional") return requested; + return visibility === "public"; +} + +const isRating = (value: unknown): value is number => + typeof value === "number" && + Number.isInteger(value) && + value >= 1 && + value <= 5; + +/** + * Checks answers against the rubric, in rubric order. `strict` refuses + * anything that is not on the rubric, which is right for what a judge just + * sent. Lenient drops it instead: a draft saved under an older rubric is still + * worth submitting for its judge. + */ +export function checkAnswers( + rubric: readonly RubricItem[], + answers: Answers, + kind: JudgeKind, + mode: "strict" | "lenient", +): AnswerCheck { + const byId = new Map(rubric.map((item) => [item.id, item])); + + if (mode === "strict") { + for (const [itemId, value] of Object.entries(answers.ratings)) { + if (byId.get(itemId)?.kind !== "rating") return mismatch(); + if (!isRating(value)) { + return { ok: false, message: "Ratings are whole numbers from 1 to 5." }; + } + } + for (const itemId of Object.keys(answers.responses)) { + if (byId.get(itemId)?.kind !== "response") return mismatch(); + } + } + + const checked: CheckedAnswers = { + ratings: [], + responses: [], + isComplete: true, + missing: [], + }; + + for (const item of rubric) { + if (item.kind === "rating") { + const value = answers.ratings[item.id]; + if (isRating(value)) { + checked.ratings.push({ itemId: item.id, value }); + } else { + checked.missing.push(item.label); + } + continue; + } + + const response = answers.responses[item.id]; + const value = + typeof response?.value === "string" ? response.value.trim() : ""; + if (value) { + checked.responses.push({ + itemId: item.id, + value, + isPublic: isPublicResponse( + responseVisibility(item, kind), + response?.isPublic === true, + ), + }); + } else if (item.required) { + checked.missing.push(item.label); + } + } + + checked.isComplete = checked.missing.length === 0; + return { ok: true, answers: checked }; +} + +function mismatch(): AnswerCheck { + return { + ok: false, + message: + "These answers do not match the current rubric. Reload the page to get the latest questions.", + }; +} + +/** The answers as a revision snapshot stores them. */ +export function snapshotOf(checked: CheckedAnswers): Answers { + return { + ratings: Object.fromEntries( + checked.ratings.map((rating) => [rating.itemId, rating.value]), + ), + responses: Object.fromEntries( + checked.responses.map((response) => [ + response.itemId, + { value: response.value, isPublic: response.isPublic }, + ]), + ), + }; +} diff --git a/packages/api/src/services/judging/grading-hold.test.ts b/packages/api/src/services/judging/grading-hold.test.ts new file mode 100644 index 00000000..0fdf9ef7 --- /dev/null +++ b/packages/api/src/services/judging/grading-hold.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "vitest"; +import { + GRADING_HOLD_MS, + gradingLockKey, + gradingRefusal, + gradingStateOf, + gradingStates, + holdsGrade, +} from "./grading-hold"; + +const NOW = new Date("2026-04-18T15:00:00Z"); +const ago = (ms: number) => new Date(NOW.getTime() - ms); +const ahead = (ms: number) => new Date(NOW.getTime() + ms); + +const draft = ( + judgeId: string, + over: { updatedAt?: Date; deadlineAt?: Date | null } = {}, +) => ({ + judgeId, + updatedAt: over.updatedAt ?? NOW, + deadlineAt: over.deadlineAt ?? null, +}); + +describe("GRADING_HOLD_MS", () => { + it("is ten minutes", () => { + expect(GRADING_HOLD_MS).toBe(10 * 60 * 1000); + }); +}); + +describe("holdsGrade", () => { + it("holds a project while its draft was saved inside the hold", () => { + expect(holdsGrade(draft("a"), NOW)).toBe(true); + expect( + holdsGrade( + { updatedAt: ago(GRADING_HOLD_MS - 1), deadlineAt: null }, + NOW, + ), + ).toBe(true); + }); + + it("lets go once the draft has sat for the whole hold", () => { + expect( + holdsGrade({ updatedAt: ago(GRADING_HOLD_MS), deadlineAt: null }, NOW), + ).toBe(false); + expect( + holdsGrade( + { updatedAt: ago(GRADING_HOLD_MS * 3), deadlineAt: null }, + NOW, + ), + ).toBe(false); + }); + + it("holds a stale draft until its presentation's deadline", () => { + const stale = ago(GRADING_HOLD_MS * 3); + expect(holdsGrade({ updatedAt: stale, deadlineAt: ahead(1000) }, NOW)).toBe( + true, + ); + expect(holdsGrade({ updatedAt: stale, deadlineAt: ago(1) }, NOW)).toBe( + false, + ); + expect(holdsGrade({ updatedAt: stale, deadlineAt: NOW }, NOW)).toBe(false); + }); +}); + +describe("gradingStateOf", () => { + it("is open when nobody has the project", () => { + expect(gradingStateOf("me", [], [], NOW)).toBe("open"); + }); + + it("is mine for a judge's own evaluation", () => { + expect(gradingStateOf("me", [{ judgeId: "me" }], [], NOW)).toBe("mine"); + }); + + it("is graded once another judge has submitted", () => { + expect(gradingStateOf("me", [{ judgeId: "them" }], [], NOW)).toBe("graded"); + }); + + it("keeps a project mine when old duplicates exist", () => { + expect( + gradingStateOf("me", [{ judgeId: "them" }, { judgeId: "me" }], [], NOW), + ).toBe("mine"); + }); + + it("is graded even if the judge has a draft of their own", () => { + expect( + gradingStateOf("me", [{ judgeId: "them" }], [draft("me")], NOW), + ).toBe("graded"); + }); + + it("is mine while the judge holds it with a draft", () => { + expect(gradingStateOf("me", [], [draft("me")], NOW)).toBe("mine"); + }); + + it("is in progress while another judge holds it", () => { + expect(gradingStateOf("me", [], [draft("them")], NOW)).toBe("in_progress"); + expect( + gradingStateOf( + "me", + [], + [ + draft("them", { + updatedAt: ago(GRADING_HOLD_MS * 2), + deadlineAt: ahead(60_000), + }), + ], + NOW, + ), + ).toBe("in_progress"); + }); + + it("is open past another judge's stale draft", () => { + expect( + gradingStateOf( + "me", + [], + [draft("them", { updatedAt: ago(GRADING_HOLD_MS + 1) })], + NOW, + ), + ).toBe("open"); + }); + + it("is open past the judge's own stale draft", () => { + expect( + gradingStateOf( + "me", + [], + [draft("me", { updatedAt: ago(GRADING_HOLD_MS + 1) })], + NOW, + ), + ).toBe("open"); + }); + + it("hands the hold to whoever started last", () => { + const old = draft("them", { updatedAt: ago(GRADING_HOLD_MS + 1) }); + const fresh = draft("me"); + expect(gradingStateOf("me", [], [old, fresh], NOW)).toBe("mine"); + expect(gradingStateOf("them", [], [old, fresh], NOW)).toBe("in_progress"); + }); + + describe("reserved for the judge assigned to it", () => { + const until = ahead(3 * 60_000); + + it("is reserved while a free project is kept back", () => { + expect(gradingStateOf("me", [], [], NOW, until)).toBe("reserved"); + }); + + it("is open once nothing is kept back", () => { + expect(gradingStateOf("me", [], [], NOW, null)).toBe("open"); + }); + + it("is reserved past another judge's stale draft", () => { + expect( + gradingStateOf( + "me", + [], + [draft("them", { updatedAt: ago(GRADING_HOLD_MS + 1) })], + NOW, + until, + ), + ).toBe("reserved"); + }); + + it("gives way to the assigned judge's draft: in progress, not reserved", () => { + expect(gradingStateOf("me", [], [draft("them")], NOW, until)).toBe( + "in_progress", + ); + }); + + it("gives way to a grade, and to the judge's own work", () => { + expect(gradingStateOf("me", [{ judgeId: "them" }], [], NOW, until)).toBe( + "graded", + ); + expect(gradingStateOf("me", [{ judgeId: "me" }], [], NOW, until)).toBe( + "mine", + ); + expect(gradingStateOf("me", [], [draft("me")], NOW, until)).toBe("mine"); + }); + }); +}); + +describe("gradingStates", () => { + it("reads each project from its own rows", () => { + const states = gradingStates( + "me", + ["mine", "theirs", "held", "free"], + [ + { entryId: "mine", judgeId: "me" }, + { entryId: "theirs", judgeId: "them" }, + ], + [{ entryId: "held", ...draft("them") }], + NOW, + ); + expect(Object.fromEntries(states)).toEqual({ + mine: "mine", + theirs: "graded", + held: "in_progress", + free: "open", + }); + }); + + it("marks the projects kept back for another judge", () => { + const until = ahead(60_000); + const states = gradingStates( + "me", + ["kept", "kept-but-held", "free"], + [], + [{ entryId: "kept-but-held", ...draft("them") }], + NOW, + new Map([ + ["kept", until], + ["kept-but-held", until], + ]), + ); + expect(Object.fromEntries(states)).toEqual({ + kept: "reserved", + "kept-but-held": "in_progress", + free: "open", + }); + }); + + it("ignores rows for projects it was not asked about", () => { + const states = gradingStates( + "me", + ["a"], + [{ entryId: "b", judgeId: "them" }], + [], + NOW, + ); + expect([...states.keys()]).toEqual(["a"]); + expect(states.get("a")).toBe("open"); + }); +}); + +describe("gradingRefusal", () => { + it("names the challenge when another judge graded the project", () => { + expect(gradingRefusal("graded", "Best Use of AI")).toBe( + "Another judge has already graded this project for Best Use of AI. Each project is graded once.", + ); + }); + + it("asks the judge to pick another while one is grading", () => { + expect(gradingRefusal("in_progress", "General")).toBe( + "Another judge is grading this project right now. Pick another one.", + ); + }); + + it("lets a judge write to a project that is theirs or open", () => { + expect(gradingRefusal("mine", "General")).toBeNull(); + expect(gradingRefusal("open", "General")).toBeNull(); + }); + + it("leaves a reserved project to the timing rules, which say when it opens", () => { + expect(gradingRefusal("reserved", "General")).toBeNull(); + }); +}); + +describe("gradingLockKey", () => { + it("is the same for the same pair and differs for another", () => { + expect(gradingLockKey("e1", "c1")).toBe(gradingLockKey("e1", "c1")); + expect(gradingLockKey("e1", "c1")).not.toBe(gradingLockKey("e1", "c2")); + expect(gradingLockKey("e1", "c1")).not.toBe(gradingLockKey("e2", "c1")); + expect(gradingLockKey("e1", "c2")).not.toBe(gradingLockKey("e2", "c1")); + }); +}); diff --git a/packages/api/src/services/judging/grading-hold.ts b/packages/api/src/services/judging/grading-hold.ts new file mode 100644 index 00000000..431f9049 --- /dev/null +++ b/packages/api/src/services/judging/grading-hold.ts @@ -0,0 +1,127 @@ +// Each project is graded once per (root) challenge, by one judge. Pure: the +// desk loads the rows, this decides. +// +// A project is closed to other judges once someone has submitted an evaluation +// of it. Until then a judge who is writing one holds it: their draft keeps +// other judges off for GRADING_HOLD_MS after its last save, and a scheduled +// presentation's draft holds until its deadline. A draft left alone past that +// holds nothing, and a judge who starts afterwards takes the hold over. +// Evaluations that already exist twice, from before this rule, are left as they +// are. +// +// A presentation assigned to another judge is reserved for them until its +// takeover time (timing.ts). An open project in that window reads "reserved". +// Once the assigned judge has a draft it reads "in_progress" like any other +// held project, so a judge who takes over after the window cannot take one the +// assigned judge started. + +/** How long after its last save a draft keeps other judges off the project. */ +export const GRADING_HOLD_MS = 10 * 60_000; + +/** + * Where a project stands for one judge: "mine" (they graded it, or hold it), + * "graded" (another judge did), "in_progress" (another judge holds it), + * "reserved" (free, but kept for the judge assigned to it until a time), or + * "open". + */ +export type GradingState = + "mine" | "graded" | "in_progress" | "reserved" | "open"; + +type HeldDraft = { updatedAt: Date; deadlineAt: Date | null }; + +/** Whether a draft still keeps other judges off its project. */ +export function holdsGrade(draft: HeldDraft, now: Date): boolean { + return ( + now.getTime() - draft.updatedAt.getTime() < GRADING_HOLD_MS || + (draft.deadlineAt !== null && draft.deadlineAt.getTime() > now.getTime()) + ); +} + +/** + * One project's state for `judgeId`, from its evaluations and drafts for the + * challenge (every judge's, theirs included). A judge's own evaluation wins + * over another's, so a project both graded stays theirs to edit. + * `reservedUntil` is when the project opens to this judge, if it is held back + * for another judge until then (timing.ts reservedUntil); it only shows on a + * project nobody has graded or holds. + */ +export function gradingStateOf( + judgeId: string, + evaluations: readonly { judgeId: string }[], + drafts: readonly (HeldDraft & { judgeId: string })[], + now: Date, + reservedUntil: Date | null = null, +): GradingState { + if (evaluations.some((evaluation) => evaluation.judgeId === judgeId)) { + return "mine"; + } + if (evaluations.length > 0) return "graded"; + + const holding = drafts.filter((draft) => holdsGrade(draft, now)); + if (holding.some((draft) => draft.judgeId === judgeId)) return "mine"; + if (holding.length > 0) return "in_progress"; + return reservedUntil ? "reserved" : "open"; +} + +/** + * The state of each of `entryIds`, from the rows of one challenge. + * `reservedUntil` holds the entries kept back for another judge, with when + * each opens to this one. + */ +export function gradingStates( + judgeId: string, + entryIds: readonly string[], + evaluations: readonly { entryId: string; judgeId: string }[], + drafts: readonly (HeldDraft & { entryId: string; judgeId: string })[], + now: Date, + reservedUntil: ReadonlyMap = new Map(), +): Map { + const evaluationsOf = new Map(); + for (const evaluation of evaluations) { + const list = evaluationsOf.get(evaluation.entryId) ?? []; + list.push(evaluation); + evaluationsOf.set(evaluation.entryId, list); + } + const draftsOf = new Map(); + for (const draft of drafts) { + const list = draftsOf.get(draft.entryId) ?? []; + list.push(draft); + draftsOf.set(draft.entryId, list); + } + + return new Map( + entryIds.map((entryId) => [ + entryId, + gradingStateOf( + judgeId, + evaluationsOf.get(entryId) ?? [], + draftsOf.get(entryId) ?? [], + now, + reservedUntil.get(entryId) ?? null, + ), + ]), + ); +} + +/** + * Why a judge cannot write to a project in this state, or null if they can. + * A reserved project is refused by the timing rules instead, which say when it + * opens (timing.ts). + */ +export function gradingRefusal( + state: GradingState, + challengeLabel: string, +): string | null { + if (state === "graded") { + return `Another judge has already graded this project for ${challengeLabel}. Each project is graded once.`; + } + if (state === "in_progress") { + return "Another judge is grading this project right now. Pick another one."; + } + return null; +} + +/** What writers of one project's grade for one challenge lock on. */ +export function gradingLockKey(entryId: string, challengeId: string): string { + return `grading:${entryId}:${challengeId}`; +} diff --git a/packages/api/src/services/judging/import.test.ts b/packages/api/src/services/judging/import.test.ts new file mode 100644 index 00000000..f11915a5 --- /dev/null +++ b/packages/api/src/services/judging/import.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { fileTag, planImport, portalLabels } from "./import"; +import type { ImportFacts } from "./import"; + +const quiet: ImportFacts = { + inventoryLocked: false, + claimsStarted: false, + hasEvaluations: false, + scheduleSaved: false, +}; +const name = { hackathonName: "Hacklytics 2027" }; + +function refusal(fn: () => unknown) { + try { + fn(); + } catch (error) { + expect(error).toBeInstanceOf(TRPCError); + return error as TRPCError; + } + throw new Error("expected a refusal"); +} + +describe("planImport", () => { + describe("automatic", () => { + it("replaces while nothing depends on the project list", () => { + expect(planImport("automatic", quiet, name)).toEqual({ + replace: true, + revokeAccess: false, + }); + }); + + it.each([ + ["a room link locked the list", { inventoryLocked: true }], + ["hackers have claimed", { claimsStarted: true }], + ["judges have scored", { hasEvaluations: true }], + ["a schedule is saved", { scheduleSaved: true }], + ])("only adds once %s", (_why, facts) => { + expect(planImport("automatic", { ...quiet, ...facts }, name)).toEqual({ + replace: false, + revokeAccess: false, + }); + }); + }); + + describe("replace", () => { + it("replaces an unlocked list without asking for the name", () => { + expect(planImport("replace", quiet, name)).toEqual({ + replace: true, + revokeAccess: false, + }); + }); + + it("needs the typed name once room links are out, then signs guests out", () => { + const locked = { ...quiet, inventoryLocked: true }; + expect(refusal(() => planImport("replace", locked, name)).code).toBe( + "BAD_REQUEST", + ); + expect( + refusal(() => + planImport("replace", locked, { ...name, typed: "hacklytics 2027" }), + ).message, + ).toContain("Type the hackathon name, Hacklytics 2027"); + expect( + planImport("replace", locked, { ...name, typed: " Hacklytics 2027 " }), + ).toEqual({ replace: true, revokeAccess: true }); + }); + + it.each([ + ["claims", { claimsStarted: true }, /claiming/], + ["a saved schedule", { scheduleSaved: true }, /schedule is saved/], + ["evaluations", { hasEvaluations: true }, /scored/], + ])("is refused after %s, even with the name typed", (_why, facts, message) => { + const error = refusal(() => + planImport( + "replace", + { ...quiet, inventoryLocked: true, ...facts }, + { ...name, typed: "Hacklytics 2027" }, + ), + ); + expect(error.code).toBe("PRECONDITION_FAILED"); + expect(error.message).toMatch(message); + }); + }); +}); + +describe("portalLabels", () => { + it("joins tracks, challenges and CreateX, without NONE or repeats", () => { + expect( + portalLabels({ + tracks: ["Healthcare", "NONE", " Finance "], + challenges: ["MLH_MONGODB", "healthcare", ""], + isCreateX: true, + }), + ).toEqual(["CreateX", "Finance", "Healthcare", "MLH_MONGODB"]); + }); + + it("is empty for a project that opted into nothing", () => { + expect( + portalLabels({ tracks: null, challenges: ["none"], isCreateX: false }), + ).toEqual([]); + }); +}); + +describe("fileTag", () => { + it("is short, stable and says nothing about the contents", () => { + const csv = "Project Title,Submitter Email\nTidewater,priya@gatech.edu\n"; + expect(fileTag(csv)).toMatch(/^[0-9a-f]{12}$/); + expect(fileTag(csv)).toBe(fileTag(csv)); + expect(fileTag(`${csv} `)).not.toBe(fileTag(csv)); + }); +}); diff --git a/packages/api/src/services/judging/import.ts b/packages/api/src/services/judging/import.ts new file mode 100644 index 00000000..13f4757d --- /dev/null +++ b/packages/api/src/services/judging/import.ts @@ -0,0 +1,609 @@ +import { createHash } from "node:crypto"; +import { TRPCError } from "@trpc/server"; +import { and, eq, inArray, isNotNull, isNull, or } from "drizzle-orm"; +import { + hackathonParticipants, + hackathonProjects, + judgingChallenges, + judgingEntries, + judgingEntryChallenges, + judgingEntryMembers, + judgingRooms, + users, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { + chunks, + idForLabel, + initializeGroups, + lockEdition, + readSetupLock, + rebuildDerivedMemberships, + resolveLabels, +} from "./challenges"; +import type { JudgingDb } from "./config"; +import { DevpostFileError, httpUrl, readDevpostExport } from "./devpost"; +import type { DevpostImport, DevpostProject } from "./devpost"; +import { revokeRoomAccess } from "./rooms"; +import { normalizeLabel, sameLabel } from "./scope"; + +// Getting projects into judging: a Devpost submissions export, or the +// projects teams submitted through the portal. Both land in judging_entry; +// `source` remembers which, because a Devpost replace only ever swaps out the +// Devpost half. + +export const DEVPOST_MAX_BYTES = 10 * 1024 * 1024; + +export type ImportMode = "automatic" | "replace"; + +export type ImportFacts = { + inventoryLocked: boolean; + claimsStarted: boolean; + hasEvaluations: boolean; + scheduleSaved: boolean; +}; + +/** + * Whether an import replaces the Devpost projects or only adds new ones, and + * whether doing so has to sign guest judges out. + * + * `automatic` replaces until anything depends on the current list (a room + * link, a claim, a score, a saved schedule), then only adds. `replace` is the + * organiser insisting: refused once hackers, a schedule or scores depend on + * the list, and once room links are out it takes the hackathon's name typed + * out, because every guest judge is signed out. + */ +export function planImport( + mode: ImportMode, + facts: ImportFacts, + confirm: { typed?: string; hackathonName: string }, +): { replace: boolean; revokeAccess: boolean } { + if (mode === "automatic") { + const settled = + facts.inventoryLocked || + facts.claimsStarted || + facts.hasEvaluations || + facts.scheduleSaved; + return { replace: !settled, revokeAccess: false }; + } + + if (facts.claimsStarted) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "Hackers have started claiming projects, so the list can only grow. Import in automatic mode to add the new projects.", + }); + } + if (facts.scheduleSaved) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "A schedule is saved against the current projects. Drop it on the Schedule tab first, or import in automatic mode to add new projects.", + }); + } + if (facts.hasEvaluations) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "Judges have scored the current projects. Import in automatic mode to add new projects, or drop evaluations from Resets first.", + }); + } + if ( + facts.inventoryLocked && + confirm.typed?.trim() !== confirm.hackathonName.trim() + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Room QR links are out, and replacing the projects signs every guest judge out. Type the hackathon name, ${confirm.hackathonName}, to confirm.`, + }); + } + return { replace: true, revokeAccess: facts.inventoryLocked }; +} + +/** A short, non-reversible tag for the uploaded file, for the audit log. */ +export function fileTag(csv: string): string { + return createHash("sha256").update(csv).digest("hex").slice(0, 12); +} + +/** + * A replacement must not orphan a room: every active room judging a plain + * challenge needs that challenge to still have projects afterwards, from the + * new file or from portal submissions. + */ +async function assertRoomsKeepProjects( + tx: JudgingDb, + hackathonId: string, + labels: readonly string[], +) { + const rooms = await tx + .select({ + name: judgingRooms.name, + challengeId: judgingRooms.challengeId, + label: judgingChallenges.label, + }) + .from(judgingRooms) + .innerJoin( + judgingChallenges, + eq(judgingChallenges.id, judgingRooms.challengeId), + ) + .where( + and( + eq(judgingRooms.hackathonId, hackathonId), + isNull(judgingRooms.archivedAt), + eq(judgingChallenges.isGroup, false), + ), + ); + if (rooms.length === 0) return; + + const portalBacked = new Set( + ( + await tx + .select({ challengeId: judgingEntryChallenges.challengeId }) + .from(judgingEntryChallenges) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEntryChallenges.entryId), + ) + .where( + and( + eq(judgingEntries.hackathonId, hackathonId), + eq(judgingEntries.source, "portal"), + isNull(judgingEntries.deletedAt), + ), + ) + ).map((row) => row.challengeId), + ); + + const orphaned = rooms.find( + (room) => + !portalBacked.has(room.challengeId) && + !labels.some((label) => sameLabel(label, room.label)), + ); + if (orphaned) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `${orphaned.name} judges ${orphaned.label}, which this file does not have. Point the room at another challenge or archive it, then import again.`, + }); + } +} + +type NewEntry = { + source: "devpost" | "portal"; + sourceProjectId?: string | null; + submissionUrl?: string | null; + title: string; + description: string; + demoLinks: string[]; + videoUrl: string | null; + technologies: string[]; + universities: string[]; + prizeCategories: string[]; + participantCount: number; + projectCreatedAt: Date | null; + submittedAt: Date | null; + members: { + name: string; + email: string; + userId?: string | null; + }[]; +}; + +/** + * Inserts entries with their members and opt-ins, then derives group and + * general memberships. Returns the new ids in input order. + */ +async function insertEntries( + tx: JudgingDb, + hackathonId: string, + entries: readonly NewEntry[], + challengeIds: Map, +): Promise { + const ids: string[] = []; + for (const slice of chunks(entries, 200)) { + const rows = await tx + .insert(judgingEntries) + .values( + slice.map(({ members: _members, ...entry }) => ({ + ...entry, + hackathonId, + })), + ) + .returning({ id: judgingEntries.id }); + ids.push(...rows.map((row) => row.id)); + } + + const members = entries.flatMap((entry, i) => + entry.members.map((member, order) => ({ + entryId: ids[i]!, + hackathonId, + name: member.name, + email: member.email, + userId: member.userId ?? null, + displayOrder: order, + })), + ); + for (const slice of chunks(members)) { + await tx.insert(judgingEntryMembers).values(slice); + } + + const optIns = entries.flatMap((entry, i) => { + const seen = new Set(); + return entry.prizeCategories.flatMap((label) => { + const challengeId = idForLabel(challengeIds, label); + if (!challengeId || seen.has(challengeId)) return []; + seen.add(challengeId); + return [{ entryId: ids[i]!, challengeId, hackathonId, isOptIn: true }]; + }); + }); + for (const slice of chunks(optIns, 1000)) { + await tx.insert(judgingEntryChallenges).values(slice); + } + + await rebuildDerivedMemberships(tx, hackathonId, ids); + return ids; +} + +const fromDevpost = (project: DevpostProject): NewEntry => ({ + source: "devpost", + submissionUrl: project.submissionUrl, + title: project.title, + description: project.description, + demoLinks: project.demoLinks, + videoUrl: project.videoUrl, + technologies: project.technologies, + universities: project.universities, + prizeCategories: project.prizeCategories, + participantCount: project.participantCount, + projectCreatedAt: project.projectCreatedAt, + submittedAt: project.submittedAt, + members: project.members, +}); + +/** + * Applies a Devpost submissions export in one transaction. The file is read + * before the transaction starts, so a bad file never takes the lock. + */ +export async function importDevpost( + db: DrizzleDB, + input: { + hackathonId: string; + csv: string; + mode: ImportMode; + confirmation?: string; + userId: string; + }, +) { + const bytes = Buffer.byteLength(input.csv, "utf8"); + if (bytes > DEVPOST_MAX_BYTES) { + throw new TRPCError({ + code: "PAYLOAD_TOO_LARGE", + message: `That file is ${(bytes / 1024 / 1024).toFixed(1)} MB; the limit is 10 MB. Upload the submissions export on its own, not a combined workbook.`, + }); + } + + let parsed: DevpostImport; + try { + parsed = readDevpostExport(input.csv); + } catch (error) { + if (error instanceof DevpostFileError) { + throw new TRPCError({ code: "BAD_REQUEST", message: error.message }); + } + throw error; + } + + const result = await db.transaction(async (tx) => { + const { hackathon, config } = await lockEdition(tx, input.hackathonId); + const lock = await readSetupLock(tx, input.hackathonId); + const plan = planImport( + input.mode, + { + inventoryLocked: !!config.inventoryLockedAt, + claimsStarted: !!config.claimsStartedAt, + hasEvaluations: lock.hasEvaluations, + scheduleSaved: lock.scheduleSaved, + }, + { typed: input.confirmation, hackathonName: hackathon.name }, + ); + + await initializeGroups(tx, input.hackathonId, config); + + let removed = 0; + let revoked = { links: 0, sessions: 0, presence: 0 }; + if (plan.replace) { + await assertRoomsKeepProjects(tx, input.hackathonId, parsed.labels); + if (plan.revokeAccess) { + revoked = await revokeRoomAccess( + tx, + { hackathonId: input.hackathonId }, + input.userId, + ); + } + // Hard delete: nothing scored or claimed them (planImport made sure), + // and their submission URLs must be free for the new rows. + const gone = await tx + .delete(judgingEntries) + .where( + and( + eq(judgingEntries.hackathonId, input.hackathonId), + eq(judgingEntries.source, "devpost"), + ), + ) + .returning({ id: judgingEntries.id }); + removed = gone.length; + } + + const labels = await resolveLabels(tx, input.hackathonId, parsed.labels); + + // Removed entries keep their URL, so an organiser's removal survives a + // re-import of the same file. + const known = new Set( + ( + await tx + .select({ url: judgingEntries.submissionUrl }) + .from(judgingEntries) + .where( + and( + eq(judgingEntries.hackathonId, input.hackathonId), + isNotNull(judgingEntries.submissionUrl), + ), + ) + ).map((row) => row.url), + ); + const fresh = parsed.projects.filter((p) => !known.has(p.submissionUrl)); + await insertEntries( + tx, + input.hackathonId, + fresh.map(fromDevpost), + labels.ids, + ); + + return { + mode: plan.replace ? ("replace" as const) : ("add" as const), + imported: fresh.length, + skipped: parsed.projects.length - fresh.length, + removed, + newChallenges: labels.created, + linksRevoked: revoked.links, + guestsSignedOut: revoked.sessions, + }; + }); + + return { + ...result, + collapsedRows: parsed.collapsedRows, + drafts: parsed.drafts, + rejected: parsed.rejected, + fileTag: fileTag(input.csv), + bytes, + }; +} + +// Portal tracks are free text from a fixed list, and "NONE" means none. +const NO_TRACK = "none"; +const CREATE_X_LABEL = "CreateX"; + +/** The challenge labels a portal submission opted into. */ +export function portalLabels(project: { + tracks: string[] | null; + challenges: string[] | null; + isCreateX: boolean | null; +}): string[] { + const out: string[] = []; + const raw = [ + ...(project.tracks ?? []), + ...(project.challenges ?? []), + ...(project.isCreateX ? [CREATE_X_LABEL] : []), + ]; + for (const value of raw) { + const label = normalizeLabel(value); + if (!label || label.toLowerCase() === NO_TRACK) continue; + if (!out.some((known) => sameLabel(known, label))) out.push(label); + } + return out.sort((a, b) => a.localeCompare(b)); +} + +const PROMOTABLE = ["submitted", "judging", "winner"] as const; + +/** + * Copies portal submissions into judging. Safe to run again as late ones + * land: a project already promoted is skipped, including one an organiser + * removed, which stays removed. Members are the team's participants (or the + * solo submitter) and come linked to their accounts, so nobody has to claim. + */ +export async function promoteSubmissions( + db: DrizzleDB, + input: { hackathonId: string }, +) { + return db.transaction(async (tx) => { + const { config } = await lockEdition(tx, input.hackathonId); + await initializeGroups(tx, input.hackathonId, config); + + const submitted = await tx + .select({ + id: hackathonProjects.id, + teamId: hackathonProjects.teamId, + submittedById: hackathonProjects.submittedById, + name: hackathonProjects.name, + description: hackathonProjects.description, + technologies: hackathonProjects.technologies, + tracks: hackathonProjects.tracks, + challenges: hackathonProjects.challenges, + isCreateX: hackathonProjects.isCreateX, + githubUrl: hackathonProjects.githubUrl, + demoUrl: hackathonProjects.demoUrl, + videoUrl: hackathonProjects.videoUrl, + slides: hackathonProjects.slides, + submittedAt: hackathonProjects.submittedAt, + createdAt: hackathonProjects.createdAt, + }) + .from(hackathonProjects) + .where( + and( + eq(hackathonProjects.hackathonId, input.hackathonId), + inArray(hackathonProjects.status, [...PROMOTABLE]), + isNull(hackathonProjects.withdrawnByAdminAt), + ), + ); + + const promoted = new Set( + ( + await tx + .select({ id: judgingEntries.sourceProjectId }) + .from(judgingEntries) + .where( + and( + eq(judgingEntries.hackathonId, input.hackathonId), + isNotNull(judgingEntries.sourceProjectId), + ), + ) + ).map((row) => row.id), + ); + const pending = submitted.filter((project) => !promoted.has(project.id)); + if (pending.length === 0) { + return { + promoted: 0, + alreadyPromoted: submitted.length, + unlinkedMembers: 0, + newChallenges: 0, + }; + } + + const teamIds = [ + ...new Set( + pending.map((p) => p.teamId).filter((id): id is string => !!id), + ), + ]; + const submitterIds = [ + ...new Set( + pending + .map((p) => p.submittedById) + .filter((id): id is string => !!id), + ), + ]; + const people = + teamIds.length + submitterIds.length > 0 + ? await tx + .select({ + id: hackathonParticipants.id, + userId: hackathonParticipants.userId, + teamId: hackathonParticipants.teamId, + firstName: hackathonParticipants.firstName, + lastName: hackathonParticipants.lastName, + school: hackathonParticipants.school, + accountName: users.name, + email: users.email, + }) + .from(hackathonParticipants) + .innerJoin(users, eq(users.id, hackathonParticipants.userId)) + .where( + and( + eq(hackathonParticipants.hackathonId, input.hackathonId), + or( + teamIds.length > 0 + ? inArray(hackathonParticipants.teamId, teamIds) + : undefined, + submitterIds.length > 0 + ? inArray(hackathonParticipants.id, submitterIds) + : undefined, + ), + ), + ) + : []; + + // One project per person per edition. Someone already on an entry (a + // Devpost project they claimed, say) is still listed here, unlinked. + const linked = new Set( + ( + await tx + .select({ userId: judgingEntryMembers.userId }) + .from(judgingEntryMembers) + .where( + and( + eq(judgingEntryMembers.hackathonId, input.hackathonId), + isNotNull(judgingEntryMembers.userId), + ), + ) + ).map((row) => row.userId), + ); + let unlinkedMembers = 0; + + const entries = pending.map((project): NewEntry => { + const team = project.teamId + ? people.filter((p) => p.teamId === project.teamId) + : []; + const roster = team.length + ? team + : people.filter((p) => p.id === project.submittedById); + // The submitter first, then the rest of the team by name. + roster.sort( + (a, b) => + Number(b.id === project.submittedById) - + Number(a.id === project.submittedById) || + (a.lastName ?? "").localeCompare(b.lastName ?? "") || + a.id.localeCompare(b.id), + ); + + const members = roster.map((person) => { + const name = + [person.firstName, person.lastName] + .map((part) => part?.trim()) + .filter(Boolean) + .join(" ") || + person.accountName?.trim() || + person.email; + const free = !linked.has(person.userId); + if (free) linked.add(person.userId); + else unlinkedMembers += 1; + return { + name, + email: person.email.toLowerCase(), + userId: free ? person.userId : null, + }; + }); + + const demoLinks = [ + ...new Set( + [project.demoUrl, project.githubUrl, project.slides] + .map((url) => (url ? httpUrl(url) : null)) + .filter((url): url is string => url !== null), + ), + ]; + + return { + source: "portal", + sourceProjectId: project.id, + title: project.name, + description: project.description, + demoLinks, + videoUrl: project.videoUrl ? httpUrl(project.videoUrl) : null, + technologies: project.technologies ?? [], + universities: [ + ...new Set( + roster + .map((person) => person.school?.trim()) + .filter((school): school is string => !!school), + ), + ], + prizeCategories: portalLabels(project), + participantCount: Math.max(1, members.length), + projectCreatedAt: project.createdAt, + submittedAt: project.submittedAt, + members, + }; + }); + + const labels = await resolveLabels( + tx, + input.hackathonId, + entries.flatMap((entry) => entry.prizeCategories), + ); + await insertEntries(tx, input.hackathonId, entries, labels.ids); + + return { + promoted: entries.length, + alreadyPromoted: submitted.length - pending.length, + unlinkedMembers, + newChallenges: labels.created, + }; + }); +} diff --git a/packages/api/src/services/judging/itinerary.test.ts b/packages/api/src/services/judging/itinerary.test.ts new file mode 100644 index 00000000..48b05f22 --- /dev/null +++ b/packages/api/src/services/judging/itinerary.test.ts @@ -0,0 +1,444 @@ +import { describe, expect, it } from "vitest"; +import { + buildItinerary, + evaluationsForAppointment, + groupFeedback, + judgesPresentDuring, + visibleChallenges, +} from "./itinerary"; +import type { + ItineraryAppointment, + ItineraryChallenge, + ItineraryRoom, +} from "./itinerary"; + +const challenge = ( + id: string, + over: Partial = {}, +): ItineraryChallenge => ({ + id, + label: id, + parentId: null, + mode: "scheduled", + displayOrder: 0, + ...over, +}); + +const challenges: ItineraryChallenge[] = [ + challenge("general", { label: "General", displayOrder: 0 }), + challenge("mlh", { + label: "MLH Challenges", + mode: "unscheduled", + displayOrder: 2, + }), + challenge("mongo", { + label: "Best use of MongoDB", + parentId: "mlh", + mode: "scheduled", + }), + challenge("cloud", { label: "Best use of Cloudflare", parentId: "mlh" }), + challenge("sponsor", { label: "Sponsor Prize", displayOrder: 1 }), + challenge("online", { label: "Online Judging", mode: "remote" }), +]; + +const room = ( + id: string, + over: Partial = {}, +): ItineraryRoom => ({ + id, + challengeId: "general", + name: id, + kind: "room", + building: "Klaus", + displayOrder: 0, + archived: false, + ...over, +}); + +const at = (minute: number) => new Date(Date.UTC(2027, 1, 8, 15, minute)); + +const slot = ( + id: string, + challengeId: string, + roomId: string, + startMinute: number, +): ItineraryAppointment => ({ + id, + challengeId, + roomId, + startsAt: at(startMinute), + endsAt: at(startMinute + 10), +}); + +describe("visibleChallenges", () => { + it("groups under roots, drops remote, keeps display order", () => { + expect( + visibleChallenges(challenges, [ + "online", + "mongo", + "mlh", + "general", + "sponsor", + "general", + ]), + ).toEqual([ + { id: "general", label: "General", mode: "scheduled", children: [] }, + { + id: "sponsor", + label: "Sponsor Prize", + mode: "scheduled", + children: [], + }, + { + id: "mlh", + label: "MLH Challenges", + // A child is judged the way its root is. + mode: "unscheduled", + children: ["Best use of MongoDB"], + }, + ]); + }); + + it("shows a root reached only through a child", () => { + expect(visibleChallenges(challenges, ["cloud"])).toEqual([ + { + id: "mlh", + label: "MLH Challenges", + mode: "unscheduled", + children: ["Best use of Cloudflare"], + }, + ]); + }); +}); + +describe("evaluationsForAppointment", () => { + it("takes evaluations of the slot and untimed ones in its challenge tree", () => { + const evaluations = [ + { + judgeId: "a", + challengeId: "general", + appointmentId: "s1", + ratingsComplete: true, + }, + { + judgeId: "b", + challengeId: "general", + appointmentId: null, + ratingsComplete: true, + }, + { + judgeId: "c", + challengeId: "general", + appointmentId: "s9", + ratingsComplete: true, + }, + { + judgeId: "d", + challengeId: "sponsor", + appointmentId: null, + ratingsComplete: true, + }, + ]; + expect( + evaluationsForAppointment( + { id: "s1", challengeId: "general" }, + evaluations, + challenges, + ).map((e) => e.judgeId), + ).toEqual(["a", "b"]); + }); +}); + +describe("judgesPresentDuring", () => { + const window = { roomId: "r1", startsAt: at(0), endsAt: at(10) }; + + it("counts overlapping time in the same room only", () => { + expect( + judgesPresentDuring(window, [ + { + judgeId: "a", + roomId: "r1", + joinedAt: at(-30), + lastSeenAt: at(20), + leftAt: null, + }, + { + judgeId: "b", + roomId: "r2", + joinedAt: at(-30), + lastSeenAt: at(20), + leftAt: null, + }, + { + judgeId: "c", + roomId: "r1", + joinedAt: at(-30), + lastSeenAt: at(-1), + leftAt: at(-1), + }, + { + judgeId: "d", + roomId: "r1", + joinedAt: at(10), + lastSeenAt: at(20), + leftAt: null, + }, + ]), + ).toEqual(["a"]); + }); + + it("lets a stopped heartbeat lapse after two minutes", () => { + const stale = { + judgeId: "a", + roomId: "r1", + joinedAt: at(-60), + leftAt: null, + }; + expect( + judgesPresentDuring(window, [{ ...stale, lastSeenAt: at(-1) }]), + ).toEqual(["a"]); + expect( + judgesPresentDuring(window, [{ ...stale, lastSeenAt: at(-3) }]), + ).toEqual([]); + }); +}); + +describe("buildItinerary", () => { + const rooms = [ + room("r1", { name: "Room 1" }), + room("r2", { name: "Room 2", challengeId: "sponsor", building: "CULC" }), + room("m1", { + name: "MLH Table", + kind: "table", + challengeId: "mlh", + displayOrder: 1, + }), + room("m0", { name: "MLH Booth", challengeId: "mlh", building: null }), + room("m9", { name: "Old Table", challengeId: "mlh", archived: true }), + ]; + const base = { + challenges, + entryChallengeIds: ["general", "sponsor", "mlh", "mongo", "online"], + rooms, + appointments: [ + slot("s2", "sponsor", "r2", 30), + slot("s1", "general", "r1", 0), + slot("s3", "online", "r1", 60), + ], + evaluations: [], + presence: [], + }; + + it("lists appointments in time order with room, building and status", () => { + const itinerary = buildItinerary({ ...base, now: at(5) }); + expect(itinerary.appointments).toEqual([ + expect.objectContaining({ + id: "s1", + challenge: "General", + room: "Room 1", + building: "Klaus", + status: "pending", + }), + expect.objectContaining({ + id: "s2", + challenge: "Sponsor Prize", + room: "Room 2", + building: "CULC", + status: "future", + }), + ]); + // Remote judging never reaches hackers. + expect(itinerary.appointments.map((a) => a.id)).not.toContain("s3"); + expect(itinerary.missed).toBe(false); + expect(itinerary.nextEndsAt).toEqual(at(10)); + }); + + it("flags a missed slot and points the refetch at the next end", () => { + const itinerary = buildItinerary({ ...base, now: at(12) }); + expect(itinerary.appointments[0]!.status).toBe("missed"); + expect(itinerary.missed).toBe(true); + expect(itinerary.nextEndsAt).toEqual(at(40)); + expect(buildItinerary({ ...base, now: at(45) }).nextEndsAt).toBeNull(); + }); + + it("marks a slot complete once its judges submit", () => { + const itinerary = buildItinerary({ + ...base, + evaluations: [ + { + judgeId: "j1", + challengeId: "general", + appointmentId: "s1", + ratingsComplete: true, + }, + ], + presence: [ + { + judgeId: "j1", + roomId: "r1", + joinedAt: at(-5), + lastSeenAt: at(11), + leftAt: null, + }, + ], + now: at(12), + }); + expect(itinerary.appointments[0]!.status).toBe("complete"); + expect(itinerary.missed).toBe(false); + }); + + it("keeps a slot incomplete while a judge in the room has not submitted", () => { + const itinerary = buildItinerary({ + ...base, + evaluations: [ + { + judgeId: "j1", + challengeId: "general", + appointmentId: "s1", + ratingsComplete: true, + }, + ], + presence: [ + { + judgeId: "j1", + roomId: "r1", + joinedAt: at(-5), + lastSeenAt: at(11), + leftAt: null, + }, + { + judgeId: "j2", + roomId: "r1", + joinedAt: at(-5), + lastSeenAt: at(11), + leftAt: null, + }, + ], + now: at(12), + }); + expect(itinerary.appointments[0]!.status).toBe("incomplete"); + }); + + it("keeps a slot incomplete while an evaluation lacks a rating", () => { + const itinerary = buildItinerary({ + ...base, + evaluations: [ + { + judgeId: "j1", + challengeId: "general", + appointmentId: "s1", + ratingsComplete: false, + }, + ], + now: at(12), + }); + expect(itinerary.appointments[0]!.status).toBe("incomplete"); + }); + + it("lists unscheduled challenges with their live rooms and whether judged", () => { + const pending = buildItinerary({ ...base, now: at(5) }); + expect(pending.unscheduled).toEqual([ + { + challengeId: "mlh", + challenge: "MLH Challenges", + children: ["Best use of MongoDB"], + rooms: [ + { name: "MLH Booth", kind: "room", building: null }, + { name: "MLH Table", kind: "table", building: "Klaus" }, + ], + judged: false, + }, + ]); + + const judged = buildItinerary({ + ...base, + evaluations: [ + { + judgeId: "j1", + challengeId: "mlh", + appointmentId: null, + ratingsComplete: true, + }, + ], + now: at(5), + }); + expect(judged.unscheduled[0]!.judged).toBe(true); + + const unrated = buildItinerary({ + ...base, + evaluations: [ + { + judgeId: "j1", + challengeId: "mlh", + appointmentId: null, + ratingsComplete: false, + }, + ], + now: at(5), + }); + expect(unrated.unscheduled[0]!.judged).toBe(false); + }); +}); + +describe("groupFeedback", () => { + it("orders by challenge then arrival, and drops blank answers", () => { + const items = groupFeedback( + [ + { id: "e2", challengeId: "sponsor", submittedAt: at(40) }, + { id: "e1", challengeId: "general", submittedAt: at(30) }, + { id: "e3", challengeId: "general", submittedAt: at(5) }, + { id: "e4", challengeId: "general", submittedAt: at(50) }, + ], + [ + { + evaluationId: "e1", + label: "Next steps", + displayOrder: 2, + value: " Ship it ", + }, + { + evaluationId: "e1", + label: "Strengths", + displayOrder: 1, + value: "Clear demo", + }, + { + evaluationId: "e2", + label: "Strengths", + displayOrder: 1, + value: "Great API use", + }, + { + evaluationId: "e3", + label: "Strengths", + displayOrder: 1, + value: "Bold idea", + }, + { + evaluationId: "e4", + label: "Strengths", + displayOrder: 1, + value: " ", + }, + ], + challenges, + ); + expect(items).toEqual([ + { + challenge: "General", + responses: [{ label: "Strengths", value: "Bold idea" }], + }, + { + challenge: "General", + responses: [ + { label: "Strengths", value: "Clear demo" }, + { label: "Next steps", value: "Ship it" }, + ], + }, + { + challenge: "Sponsor Prize", + responses: [{ label: "Strengths", value: "Great API use" }], + }, + ]); + }); +}); diff --git a/packages/api/src/services/judging/itinerary.ts b/packages/api/src/services/judging/itinerary.ts new file mode 100644 index 00000000..02f8c87b --- /dev/null +++ b/packages/api/src/services/judging/itinerary.ts @@ -0,0 +1,328 @@ +// What a hacker sees of their project's judging: the challenges it is in, +// when and where it presents, how each presentation went, and the written +// feedback judges chose to share. Pure, so the rules can be tested without a +// database; the router loads the rows. +// +// Remote challenges are judged without the team in the room, so they never +// appear here. + +import type { JudgingRoomKind } from "@query/db"; +import { appointmentStatus } from "./appointment-status"; +import type { AppointmentStatus } from "./appointment-status"; +import { rootOf } from "./scope"; +import type { ChallengeMode, ChallengeNode } from "./scope"; + +export type ItineraryChallenge = ChallengeNode & { + label: string; + displayOrder: number; +}; + +export type ItineraryRoom = { + id: string; + challengeId: string; + name: string; + kind: JudgingRoomKind; + building: string | null; + displayOrder: number; + archived: boolean; +}; + +export type ItineraryAppointment = { + id: string; + challengeId: string; + roomId: string; + startsAt: Date; + endsAt: Date; +}; + +export type ItineraryEvaluation = { + judgeId: string; + challengeId: string; + appointmentId: string | null; + /** Holds every rating (scoring.hasEveryRating), so placings count it. */ + ratingsComplete: boolean; +}; + +export type ItineraryPresence = { + judgeId: string; + roomId: string; + joinedAt: Date; + lastSeenAt: Date; + leftAt: Date | null; +}; + +/** A top-level challenge the project is judged in, with its sub-prizes. */ +export type VisibleChallenge = { + id: string; + label: string; + mode: ChallengeMode; + /** Labels of the challenges under this one that the project is in. */ + children: string[]; +}; + +/** + * The project's challenges as hackers see them: grouped under their root + * (rooms and appointments are per root), remote ones dropped, in the + * organisers' display order. + */ +export function visibleChallenges( + challenges: readonly ItineraryChallenge[], + entryChallengeIds: readonly string[], +): VisibleChallenge[] { + const byRoot = new Map< + string, + { root: ItineraryChallenge; children: ItineraryChallenge[] } + >(); + + for (const id of new Set(entryChallengeIds)) { + const root = rootOf(challenges, id); + if (!root || root.mode === "remote") continue; + const group = byRoot.get(root.id) ?? { root, children: [] }; + if (id !== root.id) { + const child = challenges.find((challenge) => challenge.id === id); + if (child) group.children.push(child); + } + byRoot.set(root.id, group); + } + + return [...byRoot.values()] + .sort((a, b) => byDisplay(a.root, b.root)) + .map(({ root, children }) => ({ + id: root.id, + label: root.label, + mode: root.mode, + children: children.sort(byDisplay).map((child) => child.label), + })); +} + +function byDisplay( + a: { displayOrder: number; label: string }, + b: { displayOrder: number; label: string }, +) { + return a.displayOrder - b.displayOrder || a.label.localeCompare(b.label); +} + +/** + * The evaluations that belong to one appointment: those written against it, + * plus untimed ones in the same challenge tree (written before a schedule + * existed, or after a move cleared the link). + */ +export function evaluationsForAppointment( + appointment: { id: string; challengeId: string }, + evaluations: readonly T[], + challenges: readonly ChallengeNode[], +): T[] { + const root = rootOf(challenges, appointment.challengeId)?.id; + return evaluations.filter( + (evaluation) => + evaluation.appointmentId === appointment.id || + (!evaluation.appointmentId && + rootOf(challenges, evaluation.challengeId)?.id === root), + ); +} + +// Judges heartbeat every minute. An open presence row whose heartbeat stopped +// (a closed laptop) counts as gone two minutes after the last one, or it would +// hold every later slot in that room open as "incomplete". +const HEARTBEAT_GRACE_MS = 2 * 60 * 1000; + +/** Judges whose time in the slot's room overlapped the slot. */ +export function judgesPresentDuring( + slot: { roomId: string; startsAt: Date; endsAt: Date }, + presence: readonly ItineraryPresence[], +): string[] { + const present = new Set(); + for (const row of presence) { + if (row.roomId !== slot.roomId) continue; + const left = + row.leftAt ?? new Date(row.lastSeenAt.getTime() + HEARTBEAT_GRACE_MS); + if (row.joinedAt < slot.endsAt && left > slot.startsAt) { + present.add(row.judgeId); + } + } + return [...present]; +} + +export type ItineraryAppointmentView = { + id: string; + challengeId: string; + challenge: string; + children: string[]; + room: string | null; + building: string | null; + startsAt: Date; + endsAt: Date; + status: AppointmentStatus; +}; + +export type UnscheduledChallengeView = { + challengeId: string; + challenge: string; + children: string[]; + rooms: { name: string; kind: JudgingRoomKind; building: string | null }[]; + /** A judge has rated the project in this challenge, every rating in. */ + judged: boolean; +}; + +export type Itinerary = { + appointments: ItineraryAppointmentView[]; + unscheduled: UnscheduledChallengeView[]; + /** Some appointment ended with nobody judging the project. */ + missed: boolean; + /** + * When the next appointment that has not ended yet ends. Statuses change + * then, so the client refetches at that moment rather than waiting for its + * next poll. + */ + nextEndsAt: Date | null; +}; + +export function buildItinerary(input: { + challenges: readonly ItineraryChallenge[]; + entryChallengeIds: readonly string[]; + rooms: readonly ItineraryRoom[]; + appointments: readonly ItineraryAppointment[]; + evaluations: readonly ItineraryEvaluation[]; + presence: readonly ItineraryPresence[]; + now: Date; +}): Itinerary { + const { challenges, rooms, evaluations, now } = input; + const visible = visibleChallenges(challenges, input.entryChallengeIds); + const visibleById = new Map( + visible.map((challenge) => [challenge.id, challenge]), + ); + const roomById = new Map(rooms.map((room) => [room.id, room])); + const rootId = (id: string) => rootOf(challenges, id)?.id; + + const appointments: ItineraryAppointmentView[] = []; + for (const appointment of [...input.appointments].sort( + (a, b) => a.startsAt.getTime() - b.startsAt.getTime(), + )) { + const challenge = visibleById.get(rootId(appointment.challengeId) ?? ""); + if (!challenge) continue; + const room = roomById.get(appointment.roomId); + appointments.push({ + id: appointment.id, + challengeId: challenge.id, + challenge: challenge.label, + children: challenge.children, + room: room?.name ?? null, + building: room?.building ?? null, + startsAt: appointment.startsAt, + endsAt: appointment.endsAt, + status: appointmentStatus( + { + startsAt: appointment.startsAt, + endsAt: appointment.endsAt, + evaluations: evaluationsForAppointment( + appointment, + evaluations, + challenges, + ), + presentJudgeIds: judgesPresentDuring(appointment, input.presence), + }, + now, + ), + }); + } + + const unscheduled = visible + .filter((challenge) => challenge.mode === "unscheduled") + .map((challenge) => ({ + challengeId: challenge.id, + challenge: challenge.label, + children: challenge.children, + rooms: rooms + .filter( + (room) => !room.archived && rootId(room.challengeId) === challenge.id, + ) + .sort(byDisplayName) + .map((room) => ({ + name: room.name, + kind: room.kind, + building: room.building, + })), + judged: evaluations.some( + (evaluation) => + evaluation.ratingsComplete && + rootId(evaluation.challengeId) === challenge.id, + ), + })); + + const upcomingEnds = appointments + .map((appointment) => appointment.endsAt.getTime()) + .filter((end) => end > now.getTime()); + + return { + appointments, + unscheduled, + missed: appointments.some((appointment) => appointment.status === "missed"), + nextEndsAt: upcomingEnds.length + ? new Date(Math.min(...upcomingEnds)) + : null, + }; +} + +function byDisplayName( + a: { displayOrder: number; name: string }, + b: { displayOrder: number; name: string }, +) { + return a.displayOrder - b.displayOrder || a.name.localeCompare(b.name); +} + +export type FeedbackEvaluation = { + id: string; + challengeId: string; + submittedAt: Date; +}; + +export type FeedbackResponse = { + evaluationId: string; + label: string; + displayOrder: number; + value: string; +}; + +export type FeedbackItem = { + challenge: string; + responses: { label: string; value: string }[]; +}; + +/** + * Written feedback, one item per evaluation that has any to share, in + * challenge order then the order it arrived. Carries no judge and no score: + * the router passes only member evaluations that hold every rating, and their + * public responses. + */ +export function groupFeedback( + evaluations: readonly FeedbackEvaluation[], + responses: readonly FeedbackResponse[], + challenges: readonly ItineraryChallenge[], +): FeedbackItem[] { + const challengeById = new Map( + challenges.map((challenge) => [challenge.id, challenge]), + ); + const order = (id: string) => challengeById.get(id)?.displayOrder ?? 0; + + return [...evaluations] + .sort( + (a, b) => + order(a.challengeId) - order(b.challengeId) || + (challengeById.get(a.challengeId)?.label ?? "").localeCompare( + challengeById.get(b.challengeId)?.label ?? "", + ) || + a.submittedAt.getTime() - b.submittedAt.getTime(), + ) + .map((evaluation) => ({ + challenge: challengeById.get(evaluation.challengeId)?.label ?? "Judging", + responses: responses + .filter( + (response) => + response.evaluationId === evaluation.id && + response.value.trim() !== "", + ) + .sort((a, b) => a.displayOrder - b.displayOrder) + .map(({ label, value }) => ({ label, value: value.trim() })), + })) + .filter((item) => item.responses.length > 0); +} diff --git a/packages/api/src/services/judging/notify.test.ts b/packages/api/src/services/judging/notify.test.ts new file mode 100644 index 00000000..cd308e23 --- /dev/null +++ b/packages/api/src/services/judging/notify.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { uniqueAddresses } from "./notify"; + +describe("uniqueAddresses", () => { + it("mails a linked member at their account, an unlinked one at the listed address", () => { + expect( + uniqueAddresses([ + { listedEmail: "ada@devpost.example", accountEmail: "ada@gatech.edu" }, + { listedEmail: "grace@devpost.example", accountEmail: null }, + ]), + ).toEqual(["ada@gatech.edu", "grace@devpost.example"]); + }); + + it("sends one message per address regardless of case or project", () => { + expect( + uniqueAddresses([ + { listedEmail: "Ada@Example.com", accountEmail: null }, + { listedEmail: "ada@example.com ", accountEmail: null }, + { listedEmail: "other@example.com", accountEmail: "ada@example.com" }, + ]), + ).toEqual(["Ada@Example.com"]); + }); + + it("skips members with no address", () => { + expect( + uniqueAddresses([{ listedEmail: " ", accountEmail: null }]), + ).toEqual([]); + }); +}); diff --git a/packages/api/src/services/judging/notify.ts b/packages/api/src/services/judging/notify.ts new file mode 100644 index 00000000..42a3b6db --- /dev/null +++ b/packages/api/src/services/judging/notify.ts @@ -0,0 +1,223 @@ +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { + auditLogs, + hackathonParticipants, + hackathons, + judgingAppointments, + judgingEntries, + judgingEntryMembers, + users, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { emailConcurrency, forEachWithConcurrency } from "../fanout"; +import { portalOrigin } from "./claims"; + +export type MemberAddress = { + /** The address Devpost listed, or the one the portal member signed up with. */ + listedEmail: string; + /** The portal account's own address once the member is linked to one. */ + accountEmail: string | null; +}; + +/** + * One address per person. A linked member is mailed at the account they sign + * in with, since the Devpost address is often not one they read here; an + * unlinked one at the address Devpost listed. Teammates sharing an address, or + * a person listed on two projects, get one message. + */ +export function uniqueAddresses(members: readonly MemberAddress[]): string[] { + const seen = new Map(); + for (const member of members) { + const address = (member.accountEmail ?? member.listedEmail).trim(); + if (!address) continue; + const key = address.toLowerCase(); + if (!seen.has(key)) seen.set(key, address); + } + return [...seen.values()]; +} + +/** + * Tells the teams that have a presentation on the schedule that their times + * and rooms are now on the Judging tab. Meant to be called without await + * after the publish commits: it resolves rather than rejects, and logs what it + * could not send. + */ +export async function notifyItineraryPosted( + db: DrizzleDB, + hackathonId: string, +): Promise { + try { + const [hackathon] = await db + .select({ name: hackathons.name }) + .from(hackathons) + .where(eq(hackathons.id, hackathonId)) + .limit(1); + if (!hackathon) return; + + // Members of every project with an appointment, Devpost members who never + // made an account included. + const members = await db + .select({ + listedEmail: judgingEntryMembers.email, + accountEmail: users.email, + }) + .from(judgingEntryMembers) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEntryMembers.entryId), + ) + .leftJoin(users, eq(users.id, judgingEntryMembers.userId)) + .where( + and( + eq(judgingEntryMembers.hackathonId, hackathonId), + isNull(judgingEntries.deletedAt), + inArray( + judgingEntries.id, + db + .select({ id: judgingAppointments.entryId }) + .from(judgingAppointments) + .where(eq(judgingAppointments.hackathonId, hackathonId)), + ), + ), + ); + + const addresses = uniqueAddresses(members); + if (addresses.length === 0) return; + + const { sendJudgingTimesPostedEmail } = await import("@query/auth/email"); + const host = portalOrigin(); + + await forEachWithConcurrency(addresses, emailConcurrency(), async (email) => { + try { + await sendJudgingTimesPostedEmail({ + email, + hackathonName: hackathon.name, + hackathonId, + host, + }); + } catch (error) { + // Deliberate operational logging: the only record of which address the + // provider rejected. + // eslint-disable-next-line no-console + console.error( + `[Judging] Itinerary notice to ${email} failed:`, + error, + ); + } + }); + } catch (error) { + // Deliberate operational logging: the publish stands, and this is the only + // record that the notices did not go out. + // eslint-disable-next-line no-console + console.error( + `[Judging] Itinerary notices for ${hackathonId} failed:`, + error, + ); + } +} + +/** + * Whether this edition's results were published before. The placings carry + * only the current publish, so the audit trail is what remembers an earlier + * one; a republish after a correction must not announce the results again. + * Read before the new publish is recorded. + */ +export async function resultsPublishedBefore( + db: DrizzleDB, + hackathonId: string, +): Promise { + const [row] = await db + .select({ id: auditLogs.id }) + .from(auditLogs) + .where( + and( + eq(auditLogs.action, "judging.results.publish"), + eq(auditLogs.resourceId, hackathonId), + ), + ) + .limit(1); + return !!row; +} + +/** + * Tells the edition's accepted hackers, and every member of a judged project, + * that the results are on the Results tab. Called without await after the + * first publish, like notifyItineraryPosted: it resolves rather than rejects. + */ +export async function notifyResultsPublished( + db: DrizzleDB, + hackathonId: string, +): Promise { + try { + const [hackathon] = await db + .select({ name: hackathons.name }) + .from(hackathons) + .where(eq(hackathons.id, hackathonId)) + .limit(1); + if (!hackathon) return; + + const [hackers, members] = await Promise.all([ + db + .select({ email: users.email }) + .from(hackathonParticipants) + .innerJoin(users, eq(users.id, hackathonParticipants.userId)) + .where( + and( + eq(hackathonParticipants.hackathonId, hackathonId), + inArray(hackathonParticipants.registrationStatus, [ + "approved", + "checked_in", + ]), + ), + ), + db + .select({ + listedEmail: judgingEntryMembers.email, + accountEmail: users.email, + }) + .from(judgingEntryMembers) + .innerJoin( + judgingEntries, + eq(judgingEntries.id, judgingEntryMembers.entryId), + ) + .leftJoin(users, eq(users.id, judgingEntryMembers.userId)) + .where( + and( + eq(judgingEntryMembers.hackathonId, hackathonId), + isNull(judgingEntries.deletedAt), + ), + ), + ]); + + const addresses = uniqueAddresses([ + ...hackers.map((row) => ({ listedEmail: row.email, accountEmail: null })), + ...members, + ]); + if (addresses.length === 0) return; + + const { sendResultsPublishedEmail } = await import("@query/auth/email"); + const host = portalOrigin(); + const resultsUrl = `${host}/hackathons/${hackathonId}?tab=RESULTS`; + + await forEachWithConcurrency(addresses, emailConcurrency(), async (email) => { + try { + await sendResultsPublishedEmail({ + email, + hackathonName: hackathon.name, + resultsUrl, + host, + }); + } catch (error) { + // Deliberate operational logging: the only record of which address the + // provider rejected. + // eslint-disable-next-line no-console + console.error(`[Judging] Results notice to ${email} failed:`, error); + } + }); + } catch (error) { + // Deliberate operational logging: the publish stands, and this is the only + // record that the notices did not go out. + // eslint-disable-next-line no-console + console.error(`[Judging] Results notices for ${hackathonId} failed:`, error); + } +} diff --git a/packages/api/src/services/judging/outer-column.test.ts b/packages/api/src/services/judging/outer-column.test.ts new file mode 100644 index 00000000..77c516e3 --- /dev/null +++ b/packages/api/src/services/judging/outer-column.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { + judgingAppointments, + judgingRoomLinks, + judgingRooms, + judgingSchedules, +} from "@query/db"; +import { outerColumn } from "./outer-column"; + +const db = drizzle.mock(); + +describe("outerColumn", () => { + it("keeps the outer table's name in a one-table select", () => { + const query = db + .select({ + appointments: sql`(select count(*)::int from ${judgingAppointments} where ${judgingAppointments.scheduleId} = ${outerColumn(judgingSchedules, judgingSchedules.id)})`, + }) + .from(judgingSchedules) + .toSQL(); + + expect(query.sql).toContain( + `where "schedule_id" = "judging_schedule"."id"`, + ); + }); + + it("is needed: a plain column loses its table name there", () => { + const query = db + .select({ + hasLink: sql`exists (select 1 from ${judgingRoomLinks} where ${judgingRoomLinks.roomId} = ${judgingRooms.id})`, + }) + .from(judgingRooms) + .toSQL(); + + expect(query.sql).toContain(`where "room_id" = "id"`); + }); +}); diff --git a/packages/api/src/services/judging/outer-column.ts b/packages/api/src/services/judging/outer-column.ts new file mode 100644 index 00000000..f82a5dd0 --- /dev/null +++ b/packages/api/src/services/judging/outer-column.ts @@ -0,0 +1,10 @@ +import { getTableName, sql } from "drizzle-orm"; +import type { Column, Table } from "drizzle-orm"; + +// A select from one table with no joins drops the table name from every +// column in a sql expression. In a correlated subquery the outer row's column +// then resolves to the subquery's own column of the same name, so the +// subquery compares its rows with themselves. Name the outer column with this +// instead; the query must read the table under its own name. +export const outerColumn = (table: Table, column: Column) => + sql`${sql.identifier(getTableName(table))}.${sql.identifier(column.name)}`; diff --git a/packages/api/src/services/judging/placements.test.ts b/packages/api/src/services/judging/placements.test.ts new file mode 100644 index 00000000..bc8f401c --- /dev/null +++ b/packages/api/src/services/judging/placements.test.ts @@ -0,0 +1,329 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { calibrate } from "./calibration"; +import type * as CalibrationModule from "./calibration"; +import { computePlacements, formatScore } from "./placements"; +import type { PlacementEvaluation } from "./placements"; + +// The real calibrate, but spied on so a test can swap it out. The first group +// below is about ranking, scopes and ties, so it runs on scores that are left +// as the raw means; the second runs the real thing. +vi.mock("./calibration", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, calibrate: vi.fn(actual.calibrate) }; +}); +const actualCalibrate = async () => + (await vi.importActual("./calibration")).calibrate; + +const leaveAsRaw: typeof calibrate = (evaluations) => ({ + adjusted: new Map( + evaluations + .filter((e) => e.ratingsComplete && e.ratings.length > 0) + .map((e) => [ + e.id, + e.ratings.reduce((sum, value) => sum + value, 0) / e.ratings.length, + ]), + ), + judges: [], +}); + +let count = 0; +const evaluation = ( + entryId: string, + challengeId: string, + ratings: number[], + ratingsComplete = true, + over: Partial = {}, +): PlacementEvaluation => { + count += 1; + return { + id: `evaluation-${count}`, + judgeId: "judge", + submittedAt: new Date(Date.UTC(2027, 1, 6, 10, 0, count)), + entryId, + challengeId, + ratings, + ratingsComplete, + ...over, + }; +}; + +const challenges = [ + { id: "general", label: "General" }, + { id: "sponsor", label: "Sponsor Prize" }, +]; + +describe("computePlacements", () => { + beforeEach(() => { + count = 0; + vi.mocked(calibrate).mockReset(); + vi.mocked(calibrate).mockImplementation(leaveAsRaw); + }); + + it("ranks overall and per challenge, ties sharing a placing", () => { + const placements = computePlacements({ + entryIds: ["a", "b", "c"], + memberships: [ + { entryId: "a", challengeId: "general" }, + { entryId: "b", challengeId: "general" }, + { entryId: "c", challengeId: "general" }, + { entryId: "b", challengeId: "sponsor" }, + { entryId: "c", challengeId: "sponsor" }, + ], + challenges, + evaluations: [ + evaluation("a", "general", [4, 4]), + evaluation("b", "general", [5, 3]), + evaluation("c", "general", [3, 3]), + evaluation("b", "sponsor", [2, 2]), + evaluation("c", "sponsor", [5, 5]), + ], + }); + + const scope = (name: string) => + placements + .filter((p) => p.scope === name) + .map(({ entryId, placement, score, evaluationCount }) => ({ + entryId, + placement, + score, + evaluationCount, + })); + + // Overall: a 4, b mean(4, 2) = 3, c mean(3, 5) = 4. + expect(scope("overall")).toEqual([ + { entryId: "a", placement: 1, score: 4, evaluationCount: 1 }, + { entryId: "c", placement: 1, score: 4, evaluationCount: 2 }, + { entryId: "b", placement: 3, score: 3, evaluationCount: 2 }, + ]); + expect(scope("general")).toEqual([ + { entryId: "a", placement: 1, score: 4, evaluationCount: 1 }, + { entryId: "b", placement: 1, score: 4, evaluationCount: 1 }, + { entryId: "c", placement: 3, score: 3, evaluationCount: 1 }, + ]); + expect(scope("sponsor")).toEqual([ + { entryId: "c", placement: 1, score: 5, evaluationCount: 1 }, + { entryId: "b", placement: 2, score: 2, evaluationCount: 1 }, + ]); + expect(placements.find((p) => p.scope === "sponsor")?.scopeLabel).toBe( + "Sponsor Prize", + ); + expect(placements.find((p) => p.scope === "overall")?.scopeLabel).toBe( + "Overall", + ); + }); + + it("counts evaluations with every rating only and leaves unscored projects out", () => { + const placements = computePlacements({ + entryIds: ["a", "b"], + memberships: [ + { entryId: "a", challengeId: "general" }, + { entryId: "b", challengeId: "general" }, + ], + challenges, + evaluations: [ + evaluation("a", "general", [5]), + evaluation("a", "general", [1], false), + evaluation("b", "general", [2], false), + ], + }); + expect(placements.map((p) => [p.scope, p.entryId, p.score])).toEqual([ + ["overall", "a", 5], + ["general", "a", 5], + ]); + }); + + it("ignores pulled projects and challenges a project is not in", () => { + const placements = computePlacements({ + entryIds: ["a"], + memberships: [{ entryId: "a", challengeId: "general" }], + challenges, + evaluations: [ + evaluation("a", "general", [3]), + // Scored for a challenge it was since removed from. + evaluation("a", "sponsor", [5]), + // A project no longer in judging. + evaluation("gone", "general", [5]), + ], + }); + expect(placements.map((p) => [p.scope, p.entryId, p.score])).toEqual([ + ["overall", "a", 4], + ["general", "a", 3], + ]); + }); + + it("returns nothing before anyone is scored", () => { + expect( + computePlacements({ + entryIds: ["a"], + memberships: [{ entryId: "a", challengeId: "general" }], + challenges, + evaluations: [], + }), + ).toEqual([]); + }); +}); + +describe("computePlacements with judge calibration", () => { + beforeEach(async () => { + count = 0; + vi.mocked(calibrate).mockReset(); + vi.mocked(calibrate).mockImplementation(await actualCalibrate()); + }); + + // A strict judge and a lenient one each graded six projects, dealt out at + // random, so the two sets are alike in quality and the lenient judge's higher + // marks are the judge's, not the projects'. + const strictScores = [3.0, 3.2, 3.4, 3.6, 3.8, 4.0]; + const lenientScores = [3.8, 4.0, 4.2, 4.4, 4.6, 4.8]; + // Marked in a scrambled order, so neither judge looks to be drifting. + const gradingOrder = [2, 5, 0, 4, 1, 3]; + const twoJudges = () => { + const graded = (prefix: string, judgeId: string, scores: number[]) => + gradingOrder.map((i) => + evaluation(`${prefix}${i + 1}`, "general", [scores[i]!], true, { + judgeId, + }), + ); + const evaluations = [ + ...graded("s", "strict", strictScores), + ...graded("l", "lenient", lenientScores), + ]; + const entryIds = evaluations.map((e) => e.entryId); + return computePlacements({ + entryIds, + memberships: entryIds.map((entryId) => ({ + entryId, + challengeId: "general", + })), + challenges, + evaluations, + }); + }; + + it("ranks on the calibrated score, so a strict judge's best is not buried", () => { + const overall = new Map( + twoJudges() + .filter((p) => p.scope === "overall") + .map((p) => [p.entryId, p]), + ); + const place = (entryId: string) => overall.get(entryId)!.placement; + const raw = (entryId: string) => overall.get(entryId)!.rawScore; + + // On raw marks the strict judge's best (4.0) is below the lenient judge's + // third best (4.4). + expect(raw("s6")).toBeLessThan(raw("l4")); + // Calibrated, each of the strict judge's projects beats the lenient + // judge's project whose raw mark is 0.4 above it. + expect(place("s6")).toBeLessThan(place("l4")); + expect(place("s5")).toBeLessThan(place("l3")); + expect(place("s4")).toBeLessThan(place("l2")); + expect(place("s3")).toBeLessThan(place("l1")); + // Within a judge, the order is the judge's own. + expect(place("l6")).toBeLessThan(place("l5")); + expect(place("s2")).toBeLessThan(place("s1")); + }); + + it("keeps the plain mean beside the calibrated score", () => { + const placements = twoJudges(); + const byEntry = new Map( + placements + .filter((p) => p.scope === "overall") + .map((p) => [p.entryId, p]), + ); + expect(byEntry.get("s6")?.rawScore).toBeCloseTo(4.0, 10); + expect(byEntry.get("l1")?.rawScore).toBeCloseTo(3.8, 10); + // The strict judge's score moves up, the lenient judge's down. + expect(byEntry.get("s6")!.score).toBeGreaterThan(4.0); + expect(byEntry.get("l6")!.score).toBeLessThan(4.8); + }); + + it("scores a project the same in a challenge as overall when it has one evaluation", () => { + const placements = twoJudges(); + const pick = (scope: string, entryId: string) => + placements.find((p) => p.scope === scope && p.entryId === entryId); + expect(pick("general", "s3")?.score).toBe(pick("overall", "s3")?.score); + expect(pick("general", "s3")?.rawScore).toBe( + pick("overall", "s3")?.rawScore, + ); + }); + + it("calibrates once, over the live entries' evaluations only", () => { + const evaluations = [ + evaluation("a", "general", [4], true, { judgeId: "one" }), + evaluation("b", "sponsor", [3], true, { judgeId: "two" }), + evaluation("a", "general", [2], false, { judgeId: "one" }), + // Pulled from prizes: judges scored it, it must not shift anyone else. + evaluation("pulled", "general", [5], true, { judgeId: "two" }), + ]; + computePlacements({ + entryIds: ["a", "b"], + memberships: [ + { entryId: "a", challengeId: "general" }, + { entryId: "b", challengeId: "sponsor" }, + ], + challenges, + evaluations, + }); + + expect(calibrate).toHaveBeenCalledTimes(1); + const given = vi.mocked(calibrate).mock.calls[0]![0]; + expect(given.map((e) => e.entryId).sort()).toEqual(["a", "a", "b"]); + }); + + it("does not let a pulled project's grades move anyone else's score", () => { + const base = [ + evaluation("a", "general", [4], true, { judgeId: "one" }), + evaluation("b", "general", [3], true, { judgeId: "one" }), + evaluation("c", "general", [3.5], true, { judgeId: "two" }), + ]; + const run = (extra: PlacementEvaluation[]) => + computePlacements({ + entryIds: ["a", "b", "c"], + memberships: ["a", "b", "c"].map((entryId) => ({ + entryId, + challengeId: "general", + })), + challenges, + evaluations: [...base, ...extra], + }).map((p) => [p.scope, p.entryId, p.score]); + + expect( + run([evaluation("pulled", "general", [5], true, { judgeId: "two" })]), + ).toEqual(run([])); + }); + + it("does not compare a sponsor's judges with the table judges", () => { + // The sponsor's two guests both mark high; that must not drag the tables. + const tables = [ + evaluation("a", "general", [3], true, { judgeId: "t1" }), + evaluation("b", "general", [4], true, { judgeId: "t2" }), + ]; + const sponsor = [ + evaluation("c", "sponsor", [5], true, { judgeId: "g1" }), + evaluation("d", "sponsor", [5], true, { judgeId: "g2" }), + ]; + const input = { + entryIds: ["a", "b", "c", "d"], + memberships: [ + { entryId: "a", challengeId: "general" }, + { entryId: "b", challengeId: "general" }, + { entryId: "c", challengeId: "sponsor" }, + { entryId: "d", challengeId: "sponsor" }, + ], + challenges, + }; + const general = (evaluations: PlacementEvaluation[]) => + computePlacements({ ...input, evaluations }) + .filter((p) => p.scope === "general") + .map((p) => [p.entryId, p.score]); + + expect(general([...tables, ...sponsor])).toEqual(general(tables)); + }); +}); + +describe("formatScore", () => { + it("writes two decimal places for numeric(4,2)", () => { + expect(formatScore(4)).toBe("4.00"); + expect(formatScore(10 / 3)).toBe("3.33"); + }); +}); diff --git a/packages/api/src/services/judging/placements.ts b/packages/api/src/services/judging/placements.ts new file mode 100644 index 00000000..ec26ffac --- /dev/null +++ b/packages/api/src/services/judging/placements.ts @@ -0,0 +1,135 @@ +// Turns evaluations into placings: one ranking per challenge and one overall. +// A project's score is the mean of its counted evaluations (scoring.ts), each +// calibrated for its judge's habits first (calibration.ts); equal scores share +// a placing. The raw mean is kept beside it. Projects nobody scored are left +// out rather than listed last, since a placing is published and "8th of 8" for +// a project no judge reached would be a claim nobody made. + +import { calibrate } from "./calibration"; +import { overallScore, rankByScore, scopedScore } from "./scoring"; +import type { ScoredEvaluation } from "./scoring"; + +/** The placement scope for the ranking across every challenge. */ +export const OVERALL_SCOPE = "overall"; +export const OVERALL_LABEL = "Overall"; + +export type PlacementEvaluation = ScoredEvaluation & { + /** Calibration counts evaluations by this alone. */ + ratingsComplete: boolean; + id: string; + entryId: string; + judgeId: string; + submittedAt: Date; +}; + +export type ComputedPlacement = { + /** "overall" or a challenge id. */ + scope: string; + scopeLabel: string; + entryId: string; + placement: number; + /** Calibrated for judge bias. This is what ranks. */ + score: number; + /** The plain mean of the same evaluations. */ + rawScore: number; + evaluationCount: number; +}; + +export function computePlacements(input: { + /** Projects still in judging. Evaluations of anything else are ignored. */ + entryIds: readonly string[]; + /** Which projects are in which challenge. */ + memberships: readonly { entryId: string; challengeId: string }[]; + /** Every challenge, in the order rankings should come out. */ + challenges: readonly { id: string; label: string }[]; + evaluations: readonly PlacementEvaluation[]; +}): ComputedPlacement[] { + // Sorted so a tie keeps the same order from one compute to the next. + const entryIds = [...new Set(input.entryIds)].sort(); + const live = new Set(entryIds); + + // Calibrated once, over every counted evaluation of a live entry, so a + // judge's habits are learned from all of their grades and not from the + // slice that fell in one scope. + const liveEvaluations = input.evaluations.filter((evaluation) => + live.has(evaluation.entryId), + ); + const { adjusted } = calibrate(liveEvaluations); + + const evaluationsOf = new Map(); + for (const evaluation of liveEvaluations) { + const list = evaluationsOf.get(evaluation.entryId) ?? []; + list.push({ ...evaluation, adjustedScore: adjusted.get(evaluation.id) }); + evaluationsOf.set(evaluation.entryId, list); + } + + const rank = ( + scope: string, + scopeLabel: string, + candidates: readonly string[], + summarize: (evals: PlacementEvaluation[]) => { + score: number | null; + count: number; + }, + ): ComputedPlacement[] => { + const scored = candidates + .map((entryId) => { + const evals = evaluationsOf.get(entryId) ?? []; + return { + entryId, + ...summarize(evals), + // The same evaluations on their plain ratings. + rawScore: summarize( + evals.map((evaluation) => ({ + ...evaluation, + adjustedScore: undefined, + })), + ).score, + }; + }) + .filter( + ( + row, + ): row is { + entryId: string; + score: number; + count: number; + rawScore: number; + } => row.score !== null && row.rawScore !== null, + ); + return rankByScore(scored).map((row) => ({ + scope, + scopeLabel, + entryId: row.entryId, + placement: row.rank, + score: row.score, + rawScore: row.rawScore, + evaluationCount: row.count, + })); + }; + + const placements = rank(OVERALL_SCOPE, OVERALL_LABEL, entryIds, overallScore); + + for (const challenge of input.challenges) { + const members = new Set( + input.memberships + .filter((membership) => membership.challengeId === challenge.id) + .map((membership) => membership.entryId), + ); + placements.push( + ...rank( + challenge.id, + challenge.label, + entryIds.filter((id) => members.has(id)), + (evals) => scopedScore(evals, challenge.id), + ), + ); + } + + return placements; +} + +/** numeric(4,2) as the driver wants it. */ +export function formatScore(score: number): string { + return score.toFixed(2); +} diff --git a/packages/api/src/services/judging/principal.test.ts b/packages/api/src/services/judging/principal.test.ts new file mode 100644 index 00000000..2db50c40 --- /dev/null +++ b/packages/api/src/services/judging/principal.test.ts @@ -0,0 +1,383 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + activateRoomLink, + asJudge, + guestCookieOptions, + readCookie, + resolveDeskPrincipal, + resolveMemberJudge, +} from "./principal"; +import type { GuestJudge } from "./principal"; +import type { JudgingDb } from "./config"; +import { signRoomLink } from "./room-link"; +import type { DrizzleDB } from "@query/db"; + +// Stands in for the query-builder calls the principal makes. Every select and +// insert resolves to the next result in its queue. +function fakeDb(results: { select?: unknown[][]; insert?: unknown[][] }) { + const calls = { select: 0, insert: 0, update: 0 }; + // Every row handed to insert().values(), and every update().set(), in order. + const inserted: Record[] = []; + const updated: Record[] = []; + const chain = (kind: keyof typeof calls) => { + const next = () => { + calls[kind] += 1; + return Promise.resolve(results[kind]?.shift() ?? []); + }; + const builder: Record = {}; + for (const method of [ + "from", + "where", + "innerJoin", + "leftJoin", + "onConflictDoNothing", + "for", + ]) { + builder[method] = () => builder; + } + builder.values = (row: Record) => { + inserted.push(row); + return builder; + }; + builder.set = (row: Record) => { + calls.update += 1; + updated.push(row); + return builder; + }; + builder.limit = next; + builder.returning = next; + return builder; + }; + const db = { + select: () => chain("select"), + insert: () => chain("insert"), + update: () => chain("update"), + transaction: (run: (tx: unknown) => unknown) => run(db), + }; + return { db: db as unknown as JudgingDb, calls, inserted, updated }; +} + +const HACK = "6f1d2c3b-4a5e-4f60-8a7b-9c0d1e2f3a4b"; +const OTHER_HACK = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + +describe("readCookie", () => { + it("finds one cookie among several", () => { + expect( + readCookie( + "a=1; query_judging_guest=tok-en_1; b=2", + "query_judging_guest", + ), + ).toBe("tok-en_1"); + }); + + it("is null when absent, empty or malformed", () => { + expect(readCookie(null, "x")).toBeNull(); + expect(readCookie("", "x")).toBeNull(); + expect(readCookie("y=1", "x")).toBeNull(); + expect(readCookie("x=", "x")).toBeNull(); + expect(readCookie("x=%E0%A4%A", "x")).toBeNull(); + }); + + it("does not match a cookie whose name merely contains it", () => { + expect(readCookie("prefix_x=1; x=2", "x")).toBe("2"); + }); +}); + +describe("guestCookieOptions", () => { + const original = process.env.NODE_ENV; + afterEach(() => { + (process.env as Record).NODE_ENV = original; + }); + + it("is an httpOnly, lax, site-wide cookie that outlives the session", () => { + // Eight hours of session, then four in which a scan keeps the same judge. + expect(guestCookieOptions()).toMatchObject({ + httpOnly: true, + sameSite: "lax", + path: "/", + maxAge: 12 * 3600, + }); + }); + + it("is Secure in production only", () => { + (process.env as Record).NODE_ENV = "production"; + expect(guestCookieOptions().secure).toBe(true); + (process.env as Record).NODE_ENV = + "development"; + expect(guestCookieOptions().secure).toBe(false); + }); +}); + +describe("asJudge", () => { + const guest: GuestJudge = { + kind: "guest", + hackathonId: HACK, + sessionId: "s", + linkId: "l", + roomId: "r", + roomChallengeId: "c", + judgeId: null, + displayName: null, + }; + + it("holds back a guest who has not given a name", () => { + expect(asJudge(guest)).toBeNull(); + }); + + it("lets a named guest and any member judge", () => { + expect( + asJudge({ ...guest, judgeId: "j", displayName: "Ada" }), + ).toMatchObject({ + judgeId: "j", + }); + expect( + asJudge({ + kind: "member", + hackathonId: HACK, + userId: "u", + judgeId: "j", + displayName: "Ada", + }), + ).toMatchObject({ kind: "member" }); + }); +}); + +describe("resolveMemberJudge", () => { + it("is an approved judge with their existing desk row", async () => { + const { db, calls } = fakeDb({ + select: [[{ name: "Ada" }], [{ id: "j1", displayName: "Ada" }]], + }); + await expect( + resolveMemberJudge(db, { userId: "u1", hackathonId: HACK }), + ).resolves.toEqual({ + kind: "member", + hackathonId: HACK, + userId: "u1", + judgeId: "j1", + displayName: "Ada", + }); + expect(calls.insert).toBe(0); + }); + + it("is null for someone who is neither a judge nor staff", async () => { + const { db } = fakeDb({ select: [[], []] }); + await expect( + resolveMemberJudge(db, { userId: "u1", hackathonId: HACK }), + ).resolves.toBeNull(); + }); + + it("is null for volunteers and lapsed staff", async () => { + const volunteer = fakeDb({ + select: [[], [{ role: "volunteer", expiresAt: null }]], + }); + await expect( + resolveMemberJudge(volunteer.db, { userId: "u1", hackathonId: HACK }), + ).resolves.toBeNull(); + + const lapsed = fakeDb({ + select: [[], [{ role: "admin", expiresAt: new Date(Date.now() - 1000) }]], + }); + await expect( + resolveMemberJudge(lapsed.db, { userId: "u1", hackathonId: HACK }), + ).resolves.toBeNull(); + }); + + it("creates staff's desk row on first use, named from the session", async () => { + const { db, calls } = fakeDb({ + select: [ + [], + [{ role: "admin", expiresAt: null }], + [], + [ + { + id: HACK, + name: "Hacklytics", + startDate: new Date(), + endDate: new Date(), + }, + ], + ], + insert: [[{ id: "j2", displayName: "Grace" }]], + }); + await expect( + resolveMemberJudge(db, { + userId: "u2", + hackathonId: HACK, + userName: " Grace ", + }), + ).resolves.toMatchObject({ judgeId: "j2", displayName: "Grace" }); + expect(calls.insert).toBe(1); + }); + + it("refuses staff an edition that does not exist", async () => { + const { db, calls } = fakeDb({ + select: [[], [{ role: "super_admin", expiresAt: null }], [], []], + }); + await expect( + resolveMemberJudge(db, { userId: "u2", hackathonId: HACK }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + expect(calls.insert).toBe(0); + }); + + it("reads the row a parallel first request created", async () => { + const { db } = fakeDb({ + select: [[{ name: "Ada" }], [], [{ id: "j3", displayName: "Ada" }]], + insert: [[]], + }); + await expect( + resolveMemberJudge(db, { userId: "u1", hackathonId: HACK }), + ).resolves.toMatchObject({ judgeId: "j3" }); + }); +}); + +describe("resolveDeskPrincipal", () => { + const session = { + sessionId: "s1", + hackathonId: HACK, + linkId: "l1", + roomId: "r1", + roomChallengeId: "c1", + judgeId: null, + displayName: null, + }; + + it("is nobody without a judge account or a guest cookie", async () => { + const { db, calls } = fakeDb({}); + await expect( + resolveDeskPrincipal(db, { hackathonId: HACK, cookieHeader: "a=1" }), + ).resolves.toBeNull(); + expect(calls.select).toBe(0); + }); + + it("is the guest the cookie belongs to", async () => { + const { db } = fakeDb({ select: [[session]] }); + await expect( + resolveDeskPrincipal(db, { + hackathonId: HACK, + cookieHeader: "query_judging_guest=abc", + }), + ).resolves.toEqual({ kind: "guest", ...session }); + }); + + it("does not let a guest cookie into another edition", async () => { + const { db } = fakeDb({ select: [[session]] }); + await expect( + resolveDeskPrincipal(db, { + hackathonId: OTHER_HACK, + cookieHeader: "query_judging_guest=abc", + }), + ).resolves.toBeNull(); + }); + + it("falls back to the guest cookie for a signed-in non-judge", async () => { + const { db } = fakeDb({ select: [[], [], [session]] }); + await expect( + resolveDeskPrincipal(db, { + hackathonId: HACK, + userId: "u9", + cookieHeader: "query_judging_guest=abc", + }), + ).resolves.toMatchObject({ kind: "guest", sessionId: "s1" }); + }); +}); + +describe("activateRoomLink", () => { + const original = process.env.AUTH_SECRET; + afterEach(() => { + if (original === undefined) delete process.env.AUTH_SECRET; + else process.env.AUTH_SECRET = original; + }); + + it("refuses a bad signature without touching the database", async () => { + process.env.AUTH_SECRET = "test-secret"; + const { db, calls } = fakeDb({}); + await expect( + activateRoomLink(db as unknown as DrizzleDB, { + linkId: "3f1c2b8e-9d4a-4c1e-8f2a-5b6c7d8e9f01", + sig: "forged", + }), + ).resolves.toEqual({ kind: "invalid" }); + expect(calls.select).toBe(0); + }); + + describe("a guest who already has a session", () => { + const LINK = "3f1c2b8e-9d4a-4c1e-8f2a-5b6c7d8e9f01"; + const HOUR = 3600 * 1000; + + // The scan finds the link, then the live session for the cookie (one + // from another room's link, or none once it lapsed or was revoked), then + // the session row itself. + async function scan(row: Record | null) { + process.env.AUTH_SECRET = "test-secret"; + const live = + row && !row.revokedAt && (row.expiresAt as Date) > new Date() + ? [{ sessionId: "s1", hackathonId: row.hackathonId, linkId: "l1" }] + : []; + const { db, inserted, updated } = fakeDb({ + select: [[{ hackathonId: HACK, roomId: "r2" }], live, row ? [row] : []], + }); + const result = await activateRoomLink(db as unknown as DrizzleDB, { + linkId: LINK, + sig: signRoomLink(LINK), + guestToken: "old-token", + }); + return { result, inserted, updated }; + } + + const named = (overrides: Record = {}) => ({ + sessionId: "s1", + hackathonId: HACK, + judgeId: "j1", + expiresAt: new Date(Date.now() + HOUR), + revokedAt: null, + ...overrides, + }); + + it("keeps their judge row in the next room and seats them there", async () => { + const { result, inserted, updated } = await scan(named()); + expect(result).toMatchObject({ kind: "guest", roomId: "r2" }); + expect(inserted).toHaveLength(2); + expect(inserted[0]).toMatchObject({ + linkId: LINK, + roomId: "r2", + hackathonId: HACK, + judgeId: "j1", + completedAt: expect.any(Date), + }); + expect(inserted[1]).toMatchObject({ + judgeId: "j1", + roomId: "r2", + hackathonId: HACK, + }); + // The session for the room they left ends. + expect(updated).toEqual([{ expiresAt: expect.any(Date) }]); + }); + + it("recognises a session that lapsed a little while ago", async () => { + const { inserted } = await scan( + named({ expiresAt: new Date(Date.now() - HOUR) }), + ); + expect(inserted[0]).toMatchObject({ judgeId: "j1" }); + expect(inserted).toHaveLength(2); + }); + + it.each([ + ["has not given a name", { judgeId: null }], + ["is from another edition", { hackathonId: OTHER_HACK }], + ["was revoked by an organiser", { revokedAt: new Date() }], + ["lapsed long ago", { expiresAt: new Date(Date.now() - 5 * HOUR) }], + ])("starts a nameless session when the old one %s", async (_, row) => { + const { result, inserted, updated } = await scan(named(row)); + expect(result).toMatchObject({ kind: "guest", roomId: "r2" }); + expect(inserted).toHaveLength(1); + expect(inserted[0]).toMatchObject({ judgeId: null, completedAt: null }); + expect(updated).toHaveLength(0); + }); + + it("starts a nameless session when the cookie matches nothing", async () => { + const { inserted } = await scan(null); + expect(inserted).toHaveLength(1); + expect(inserted[0]).toMatchObject({ judgeId: null, completedAt: null }); + }); + }); +}); diff --git a/packages/api/src/services/judging/principal.ts b/packages/api/src/services/judging/principal.ts new file mode 100644 index 00000000..a1fa36df --- /dev/null +++ b/packages/api/src/services/judging/principal.ts @@ -0,0 +1,582 @@ +import { TRPCError } from "@trpc/server"; +import { and, eq, gt, isNull, sql } from "drizzle-orm"; +import { + admins, + judges, + judgingGuestSessions, + judgingJudges, + judgingPresence, + judgingRoomLinks, + judgingRooms, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { isExpiredAdmin, isStaffRole } from "../../types/portal-context"; +import { assertHackathonExists } from "./config"; +import type { JudgingDb, JudgingTx } from "./config"; +import { + GUEST_SESSION_SECONDS, + JUDGING_GUEST_COOKIE, + hashGuestToken, + newGuestToken, + verifyRoomLink, +} from "./room-link"; + +// Who is sitting at the judge desk. Two kinds of judge: +// +// member a signed-in portal user with an approved judge row for the +// edition, or any active staff admin. Their judging_judge row is +// created the first time they reach the desk. +// guest someone who scanned a room's QR code without such an account. +// The scan starts a guest session (cookie = raw token, database = +// its sha256); typing a name creates their judging_judge row and +// seats them in that room. A guest judges only the room's +// challenge and what sits below it, and the session ends when the +// link is revoked, the room archived, or eight hours pass. Scanning +// another room's code from the same browser moves them to a new +// session for the same judge row. + +export { JUDGING_GUEST_COOKIE }; + +export const GUEST_SESSION_ENDED = + "Your guest judging session has ended. Scan the QR code at your room or table again to keep judging."; + +export type MemberJudge = { + kind: "member"; + hackathonId: string; + userId: string; + judgeId: string; + displayName: string; +}; + +export type GuestJudge = { + kind: "guest"; + hackathonId: string; + sessionId: string; + linkId: string; + roomId: string; + /** The challenge the guest's room serves. */ + roomChallengeId: string; + /** Null until the guest has given a name. */ + judgeId: string | null; + displayName: string | null; +}; + +export type DeskPrincipal = MemberJudge | GuestJudge; + +/** A principal who can judge: any member, or a guest who has given a name. */ +export type Judge = + MemberJudge | (GuestJudge & { judgeId: string; displayName: string }); + +export function asJudge(principal: DeskPrincipal): Judge | null { + if (principal.kind === "member") return principal; + if (!principal.judgeId || !principal.displayName) return null; + return { + ...principal, + judgeId: principal.judgeId, + displayName: principal.displayName, + }; +} + +/** One cookie's value from a Cookie header, or null. */ +export function readCookie( + header: string | null | undefined, + name: string, +): string | null { + if (!header) return null; + for (const part of header.split(";")) { + const split = part.indexOf("="); + if (split === -1 || part.slice(0, split).trim() !== name) continue; + try { + return decodeURIComponent(part.slice(split + 1).trim()) || null; + } catch { + return null; + } + } + return null; +} + +/** + * How long after a guest session ends a scan of another room's code still + * recognises the judge who held it. + */ +export const GUEST_CARRY_OVER_SECONDS = 4 * 3600; + +/** + * Options for the guest session cookie, set by the room-link route handler. + * Lax so the cookie rides along when the QR code opens the desk from a camera + * app; httpOnly because the desk never reads it, only the API does. It + * outlives the session by GUEST_CARRY_OVER_SECONDS: a lapsed session opens + * nothing, but scanning a code again in that time keeps the same judge. + */ +export function guestCookieOptions() { + return { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + path: "/", + maxAge: GUEST_SESSION_SECONDS + GUEST_CARRY_OVER_SECONDS, + }; +} + +/** + * The member judge `userId` is for this edition, or null when they are + * neither an approved judge nor staff. Creates their judging_judge row on + * first use. + */ +export async function resolveMemberJudge( + db: JudgingDb, + input: { userId: string; hackathonId: string; userName?: string | null }, +): Promise { + const [rostered] = await db + .select({ name: judges.name }) + .from(judges) + .where( + and( + eq(judges.userId, input.userId), + eq(judges.hackathonId, input.hackathonId), + eq(judges.isActive, true), + ), + ) + .limit(1); + + if (!rostered) { + const [admin] = await db + .select({ role: admins.role, expiresAt: admins.expiresAt }) + .from(admins) + .where(and(eq(admins.userId, input.userId), eq(admins.isActive, true))) + .limit(1); + if (!admin || !isStaffRole(admin.role) || isExpiredAdmin(admin)) { + return null; + } + } + + const memberRow = and( + eq(judgingJudges.hackathonId, input.hackathonId), + eq(judgingJudges.userId, input.userId), + eq(judgingJudges.kind, "member"), + ); + const [existing] = await db + .select({ id: judgingJudges.id, displayName: judgingJudges.displayName }) + .from(judgingJudges) + .where(memberRow) + .limit(1); + + let judge = existing; + if (!judge) { + // Staff reach this with an edition id from the URL; a roster row already + // proves the edition exists. A missing one would otherwise fail the + // foreign key as a 500. + if (!rostered) await assertHackathonExists(db, input.hackathonId); + const displayName = + rostered?.name?.trim() || input.userName?.trim() || "Judge"; + [judge] = await db + .insert(judgingJudges) + .values({ + hackathonId: input.hackathonId, + kind: "member", + userId: input.userId, + displayName, + }) + .onConflictDoNothing({ + target: [judgingJudges.hackathonId, judgingJudges.userId], + where: sql`${judgingJudges.kind} = 'member'`, + }) + .returning({ + id: judgingJudges.id, + displayName: judgingJudges.displayName, + }); + // A parallel first request created it between the read and the insert. + if (!judge) { + [judge] = await db + .select({ + id: judgingJudges.id, + displayName: judgingJudges.displayName, + }) + .from(judgingJudges) + .where(memberRow) + .limit(1); + } + } + if (!judge) { + throw new Error("A member judge row was neither found nor created."); + } + + return { + kind: "member", + hackathonId: input.hackathonId, + userId: input.userId, + judgeId: judge.id, + displayName: judge.displayName, + }; +} + +/** The live guest session a raw cookie token belongs to, or null. */ +export async function resolveGuestSession( + db: JudgingDb, + token: string, + now = new Date(), +): Promise { + const [row] = await db + .select({ + sessionId: judgingGuestSessions.id, + hackathonId: judgingGuestSessions.hackathonId, + linkId: judgingGuestSessions.linkId, + roomId: judgingGuestSessions.roomId, + roomChallengeId: judgingRooms.challengeId, + judgeId: judgingJudges.id, + displayName: judgingJudges.displayName, + }) + .from(judgingGuestSessions) + .innerJoin( + judgingRoomLinks, + eq(judgingRoomLinks.id, judgingGuestSessions.linkId), + ) + .innerJoin(judgingRooms, eq(judgingRooms.id, judgingGuestSessions.roomId)) + .leftJoin(judgingJudges, eq(judgingJudges.id, judgingGuestSessions.judgeId)) + .where( + and( + eq(judgingGuestSessions.tokenHash, hashGuestToken(token)), + isNull(judgingGuestSessions.revokedAt), + gt(judgingGuestSessions.expiresAt, now), + isNull(judgingRoomLinks.revokedAt), + isNull(judgingRooms.archivedAt), + ), + ) + .limit(1); + + if (!row) return null; + return { kind: "guest", ...row }; +} + +/** + * The desk principal for a request: the signed-in member judge if there is + * one, otherwise the guest the cookie belongs to, as long as the guest's room + * is in this edition. Null when neither applies. + */ +export async function resolveDeskPrincipal( + db: JudgingDb, + input: { + hackathonId: string; + userId?: string | null; + userName?: string | null; + cookieHeader?: string | null; + }, +): Promise { + if (input.userId) { + const member = await resolveMemberJudge(db, { + userId: input.userId, + hackathonId: input.hackathonId, + userName: input.userName, + }); + if (member) return member; + } + + const token = readCookie(input.cookieHeader, JUDGING_GUEST_COOKIE); + if (!token) return null; + const guest = await resolveGuestSession(db, token); + return guest?.hackathonId === input.hackathonId ? guest : null; +} + +/** + * Seats a judge in a room: closes whatever room they were in and opens a + * presence in this one. Sitting down where they already are only refreshes + * the heartbeat. Run inside a transaction. + */ +export async function seatJudge( + tx: JudgingTx, + input: { judgeId: string; roomId: string; hackathonId: string }, + now = new Date(), +) { + // One open presence per judge. Locking the judge row first makes two + // seatings at once take turns instead of tripping the unique index. + await tx + .select({ id: judgingJudges.id }) + .from(judgingJudges) + .where(eq(judgingJudges.id, input.judgeId)) + .for("update"); + + const [open] = await tx + .select({ id: judgingPresence.id, roomId: judgingPresence.roomId }) + .from(judgingPresence) + .where( + and( + eq(judgingPresence.judgeId, input.judgeId), + isNull(judgingPresence.leftAt), + ), + ) + .limit(1); + + if (open?.roomId === input.roomId) { + await tx + .update(judgingPresence) + .set({ lastSeenAt: now }) + .where(eq(judgingPresence.id, open.id)); + return; + } + + if (open) { + await tx + .update(judgingPresence) + .set({ leftAt: now }) + .where(eq(judgingPresence.id, open.id)); + } + + await tx.insert(judgingPresence).values({ + judgeId: input.judgeId, + roomId: input.roomId, + hackathonId: input.hackathonId, + joinedAt: now, + lastSeenAt: now, + }); +} + +/** + * Gives a guest a name: creates their judge row and seats them in the room + * their link opened. A second call (a double tap, two tabs) returns the judge + * the first one created. + */ +export async function nameGuest( + db: DrizzleDB, + guest: GuestJudge, + displayName: string, +): Promise<{ judgeId: string; displayName: string }> { + return db.transaction(async (tx) => { + const now = new Date(); + const [session] = await tx + .select({ + judgeId: judgingGuestSessions.judgeId, + roomId: judgingGuestSessions.roomId, + }) + .from(judgingGuestSessions) + .where( + and( + eq(judgingGuestSessions.id, guest.sessionId), + isNull(judgingGuestSessions.revokedAt), + gt(judgingGuestSessions.expiresAt, now), + ), + ) + .for("update") + .limit(1); + if (!session) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: GUEST_SESSION_ENDED, + }); + } + + if (session.judgeId) { + const [named] = await tx + .select({ + id: judgingJudges.id, + displayName: judgingJudges.displayName, + }) + .from(judgingJudges) + .where(eq(judgingJudges.id, session.judgeId)) + .limit(1); + if (named) return { judgeId: named.id, displayName: named.displayName }; + } + + const [judge] = await tx + .insert(judgingJudges) + .values({ hackathonId: guest.hackathonId, kind: "guest", displayName }) + .returning({ + id: judgingJudges.id, + displayName: judgingJudges.displayName, + }); + + await tx + .update(judgingGuestSessions) + .set({ judgeId: judge.id, completedAt: now }) + .where(eq(judgingGuestSessions.id, guest.sessionId)); + + await seatJudge( + tx, + { + judgeId: judge.id, + roomId: session.roomId, + hackathonId: guest.hackathonId, + }, + now, + ); + + return { judgeId: judge.id, displayName: judge.displayName }; + }); +} + +export type RoomLinkActivation = + /** Bad signature, revoked link or archived room. */ + | { kind: "invalid" } + /** A member judge, now seated in the room. */ + | { kind: "member"; hackathonId: string; roomId: string } + /** + * A guest. `token` is the cookie to set, or null when the browser already + * holds a live session for this link and keeps it. + */ + | { + kind: "guest"; + hackathonId: string; + roomId: string; + token: string | null; + expiresAt: Date | null; + }; + +/** + * The judge a guest browser already is in this edition: the named session its + * cookie holds, live or lapsed within GUEST_CARRY_OVER_SECONDS. Null for an + * unnamed session, another edition's session, one an organiser revoked, or one + * that lapsed longer ago. + */ +async function carriedGuest( + db: JudgingDb, + token: string, + hackathonId: string, + now: Date, +): Promise<{ sessionId: string; judgeId: string } | null> { + const [row] = await db + .select({ + sessionId: judgingGuestSessions.id, + hackathonId: judgingGuestSessions.hackathonId, + judgeId: judgingGuestSessions.judgeId, + expiresAt: judgingGuestSessions.expiresAt, + revokedAt: judgingGuestSessions.revokedAt, + }) + .from(judgingGuestSessions) + .where(eq(judgingGuestSessions.tokenHash, hashGuestToken(token))) + .limit(1); + if (!row?.judgeId || row.revokedAt || row.hackathonId !== hackathonId) { + return null; + } + const lapsedMs = now.getTime() - row.expiresAt.getTime(); + return lapsedMs <= GUEST_CARRY_OVER_SECONDS * 1000 + ? { sessionId: row.sessionId, judgeId: row.judgeId } + : null; +} + +/** + * What scanning a room's QR code does, for the /judge/activate route + * handler. A signed-in member judge is seated in the room. Anyone else gets a + * guest session, unless their browser already has a live one from this same + * link: scanning twice must not turn a named guest into a new, nameless one. + * A named guest scanning a different room's code keeps their judge row. + */ +export async function activateRoomLink( + db: DrizzleDB, + input: { + linkId: string; + sig: string; + userId?: string | null; + userName?: string | null; + /** The guest cookie the browser sent, if any. */ + guestToken?: string | null; + }, +): Promise { + if (!verifyRoomLink(input.linkId, input.sig)) return { kind: "invalid" }; + + const now = new Date(); + const [link] = await db + .select({ + hackathonId: judgingRoomLinks.hackathonId, + roomId: judgingRoomLinks.roomId, + }) + .from(judgingRoomLinks) + .innerJoin(judgingRooms, eq(judgingRooms.id, judgingRoomLinks.roomId)) + .where( + and( + eq(judgingRoomLinks.id, input.linkId), + isNull(judgingRoomLinks.revokedAt), + isNull(judgingRooms.archivedAt), + ), + ) + .limit(1); + if (!link) return { kind: "invalid" }; + + if (input.userId) { + const member = await resolveMemberJudge(db, { + userId: input.userId, + hackathonId: link.hackathonId, + userName: input.userName, + }); + if (member) { + await db.transaction((tx) => + seatJudge( + tx, + { + judgeId: member.judgeId, + roomId: link.roomId, + hackathonId: link.hackathonId, + }, + now, + ), + ); + return { + kind: "member", + hackathonId: link.hackathonId, + roomId: link.roomId, + }; + } + } + + if (input.guestToken) { + const current = await resolveGuestSession(db, input.guestToken, now); + if (current?.linkId === input.linkId) { + return { + kind: "guest", + hackathonId: link.hackathonId, + roomId: link.roomId, + token: null, + expiresAt: null, + }; + } + } + + // A named guest scanning another room's code is the same judge: the new + // session keeps their judge row, so they skip the name gate and their scores + // stay under one name. + const carried = input.guestToken + ? await carriedGuest(db, input.guestToken, link.hackathonId, now) + : null; + + const token = newGuestToken(); + const expiresAt = new Date(now.getTime() + GUEST_SESSION_SECONDS * 1000); + await db.transaction(async (tx) => { + await tx.insert(judgingGuestSessions).values({ + tokenHash: hashGuestToken(token), + linkId: input.linkId, + roomId: link.roomId, + hackathonId: link.hackathonId, + judgeId: carried?.judgeId ?? null, + completedAt: carried ? now : null, + expiresAt, + }); + if (carried) { + // The browser moves to the new session. The old one ends now rather + // than counting as a guest in the room they left until it lapses. + await tx + .update(judgingGuestSessions) + .set({ expiresAt: now }) + .where( + and( + eq(judgingGuestSessions.id, carried.sessionId), + gt(judgingGuestSessions.expiresAt, now), + ), + ); + await seatJudge( + tx, + { + judgeId: carried.judgeId, + roomId: link.roomId, + hackathonId: link.hackathonId, + }, + now, + ); + } + }); + + return { + kind: "guest", + hackathonId: link.hackathonId, + roomId: link.roomId, + token, + expiresAt, + }; +} diff --git a/packages/api/src/services/judging/queue-store.test.ts b/packages/api/src/services/judging/queue-store.test.ts new file mode 100644 index 00000000..8d03f681 --- /dev/null +++ b/packages/api/src/services/judging/queue-store.test.ts @@ -0,0 +1,543 @@ +import { describe, expect, it } from "vitest"; +import { getTableName } from "drizzle-orm"; +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { + judgingAppointments, + judgingEntries, + judgingEntryChallenges, + judgingEvaluationDrafts, + judgingEvaluations, + judgingSchedules, +} from "@query/db"; +import { QUEUE_REFUSAL, assertQueueOffers, loadQueue } from "./queue-store"; +import { QUEUE_WINDOW, queueOrder, queueWindow } from "./queue"; +import type { ChallengeRow, Seat } from "./evaluation-store"; +import { GRADING_HOLD_MS } from "./grading-hold"; + +const HACK = "hackathon"; +const JUDGE = "judge-1"; + +const row = (id: string, over: Partial = {}): ChallengeRow => ({ + id, + parentId: null, + mode: "unscheduled", + label: id, + isGeneral: false, + isGroup: false, + ...over, +}); +const general = row("general", { isGeneral: true }); +const sponsor = row("sponsor", { mode: "scheduled" }); +const mlh = row("mlh", { isGroup: true }); +const mongo = row("mongo", { parentId: "mlh" }); +const challenges = [general, sponsor, mlh, mongo]; + +const seat = (challengeId: string): Seat => ({ + roomId: "room-1", + roomName: "Table 4", + challengeId, +}); + +const entryIds = Array.from({ length: 8 }, (_, i) => `entry-${i}`); + +const name = (table: unknown) => getTableName(table as never); +const via = (...tables: unknown[]) => tables.map(name).join(">"); +const entries = via(judgingEntries, judgingEntryChallenges); +const evaluations = via(judgingEvaluations); +const drafts = via(judgingEvaluationDrafts); +const schedules = via(judgingSchedules); +const appointments = via(judgingAppointments); +const memberships = via(judgingEntryChallenges); + +type Rows = unknown[] | ((call: number) => unknown[]); +type Read = { table: string; where?: SQL; on?: SQL }; + +/** A database whose selects answer by the tables they read, in order. */ +function fakeDb(answers: Record) { + const reads: Read[] = []; + const counts = new Map(); + const select = () => { + const joined: unknown[] = []; + let from: unknown; + let where: SQL | undefined; + let on: SQL | undefined; + const chain: Record = { + from: (table: unknown) => { + from = table; + return chain; + }, + where: (clause: SQL) => { + where = clause; + return chain; + }, + then: ( + ok: (rows: unknown[]) => unknown, + fail: (e: unknown) => unknown, + ) => { + const key = via(from, ...joined); + reads.push({ table: key, where, on }); + const call = counts.get(key) ?? 0; + counts.set(key, call + 1); + const answer = answers[key] ?? []; + return Promise.resolve( + typeof answer === "function" ? answer(call) : answer, + ).then(ok, fail); + }, + }; + for (const join of ["innerJoin", "leftJoin"]) { + chain[join] = (table: unknown, clause: SQL) => { + joined.push(table); + on = clause; + return chain; + }; + } + for (const method of ["orderBy", "limit", "for"]) { + chain[method] = () => chain; + } + return chain; + }; + return { db: { select } as never, reads }; +} + +const ids = (list: readonly string[]) => list.map((id) => ({ id })); +const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60_000); +const minutesAhead = (minutes: number) => + new Date(Date.now() + minutes * 60_000); + +const input = ( + over: Partial[1]> = {}, +): Parameters[1] => ({ + hackathonId: HACK, + judgeId: JUDGE, + challenge: general, + seat: null, + ...over, +}); + +describe("loadQueue", () => { + it("says where each project stands for the judge", async () => { + const { db } = fakeDb({ + [entries]: ids(entryIds.slice(0, 5)), + [evaluations]: [ + { entryId: "entry-0", judgeId: JUDGE }, + { entryId: "entry-1", judgeId: "other" }, + ], + [drafts]: [ + { + entryId: "entry-2", + judgeId: "other", + updatedAt: minutesAgo(1), + deadlineAt: null, + }, + { + entryId: "entry-3", + judgeId: "other", + updatedAt: minutesAgo(60), + deadlineAt: null, + }, + ], + }); + + const queue = await loadQueue(db, input()); + + expect(queue.timed).toBe(false); + expect(queue.needsRoom).toBe(false); + expect( + Object.fromEntries(queue.candidates.map((c) => [c.entryId, c.grading])), + ).toEqual({ + "entry-0": "mine", + "entry-1": "graded", + "entry-2": "in_progress", + "entry-3": "open", + "entry-4": "open", + }); + }); + + it("keeps a project the judge only opened on offer to them", async () => { + const mine = (entryId: string, revision: number) => ({ + entryId, + judgeId: JUDGE, + updatedAt: minutesAgo(1), + deadlineAt: null, + revision, + }); + const { db } = fakeDb({ + [entries]: ids(entryIds.slice(0, 4)), + [evaluations]: [ + { entryId: "entry-2", judgeId: "other" }, + { entryId: "entry-3", judgeId: JUDGE }, + ], + [drafts]: [ + mine("entry-0", 0), + mine("entry-1", 2), + mine("entry-2", 0), + mine("entry-3", 0), + ], + }); + + const queue = await loadQueue(db, input()); + + expect( + Object.fromEntries(queue.candidates.map((c) => [c.entryId, c.grading])), + ).toEqual({ + // Opened, nothing saved: still on offer. + "entry-0": "open", + // Saved to: the judge is grading it. + "entry-1": "mine", + // Another judge's grade, or the judge's own, wins over an empty draft. + "entry-2": "graded", + "entry-3": "mine", + }); + }); + + it("has no candidates, and reads no grades, for a challenge without projects", async () => { + const { db, reads } = fakeDb({}); + + const queue = await loadQueue(db, input()); + + expect(queue.candidates).toEqual([]); + expect(reads.map((r) => r.table)).toEqual([schedules, entries]); + }); + + it("narrows the projects to a prize when asked", async () => { + const { db, reads } = fakeDb({ [entries]: ids(["entry-0"]) }); + + await loadQueue(db, input({ challenge: mlh, filterId: "mongo" })); + + const params = new PgDialect().sqlToQuery( + reads.find((r) => r.table === entries)?.on as SQL, + ).params; + expect(params).toContain("mongo"); + }); + + describe("at a scheduled challenge under a saved schedule", () => { + const world = (over: Record = {}) => ({ + [schedules]: [{ id: "schedule" }], + [entries]: ids(["entry-0", "entry-1", "entry-2"]), + ...over, + }); + + it("is timed, and asks a judge seated elsewhere to join the room", async () => { + const { db, reads } = fakeDb(world()); + + const queue = await loadQueue(db, input({ challenge: sponsor })); + expect(queue).toEqual({ timed: true, needsRoom: true, candidates: [] }); + + const elsewhere = await loadQueue( + db, + input({ challenge: sponsor, seat: seat("general") }), + ); + expect(elsewhere.needsRoom).toBe(true); + expect(reads.some((r) => r.table === entries)).toBe(false); + }); + + it("offers only the presentations running now in the judge's room", async () => { + const slot = (entryId: string, startsAt: Date, deadlineAt: Date) => ({ + id: `slot-${entryId}`, + entryId, + startsAt, + deadlineAt, + }); + const { db } = fakeDb( + world({ + [appointments]: [ + slot("entry-0", minutesAgo(30), minutesAgo(20)), + slot("entry-1", minutesAgo(5), minutesAhead(5)), + slot("entry-2", minutesAhead(20), minutesAhead(30)), + ], + }), + ); + + const queue = await loadQueue( + db, + input({ challenge: sponsor, seat: seat("sponsor") }), + ); + + expect(queue.timed).toBe(true); + expect(queue.needsRoom).toBe(false); + expect(queue.candidates.map((c) => c.entryId)).toEqual(["entry-1"]); + }); + + describe("with a judge assigned to each presentation", () => { + const running = (entryId: string, assignedJudgeId: string | null) => ({ + id: `slot-${entryId}`, + entryId, + startsAt: minutesAgo(2), + deadlineAt: minutesAhead(8), + assignedJudgeId, + }); + const here = input({ challenge: sponsor, seat: seat("sponsor") }); + + it("leaves out a presentation still reserved for another judge", async () => { + const { db } = fakeDb( + world({ + [appointments]: [ + running("entry-0", "other"), + running("entry-1", JUDGE), + running("entry-2", null), + ], + }), + ); + + const queue = await loadQueue(db, here); + + expect(queue.candidates.map((c) => c.entryId)).toEqual([ + "entry-1", + "entry-2", + ]); + }); + + it("says which presentations are the judge's own", async () => { + const { db } = fakeDb( + world({ + [appointments]: [ + running("entry-0", "other"), + running("entry-1", JUDGE), + running("entry-2", null), + ], + }), + ); + + const queue = await loadQueue(db, here); + + expect( + Object.fromEntries( + queue.candidates.map((c) => [c.entryId, c.assigned ?? false]), + ), + ).toEqual({ "entry-1": true, "entry-2": false }); + }); + + it("opens a presentation to the judge once its takeover time has passed", async () => { + const late = (entryId: string) => ({ + ...running(entryId, "other"), + startsAt: minutesAgo(6), + deadlineAt: minutesAhead(4), + }); + const { db } = fakeDb( + world({ + [appointments]: [late("entry-0"), running("entry-1", "other")], + }), + ); + + const queue = await loadQueue(db, here); + + expect(queue.candidates.map((c) => c.entryId)).toEqual(["entry-0"]); + // Open for takeover, not the judge's own. + expect(queue.candidates[0].assigned).toBeUndefined(); + }); + + it("offers the judge's own presentation before the ones open to everyone", async () => { + const { db } = fakeDb( + world({ + [appointments]: [ + running("entry-0", null), + running("entry-1", null), + running("entry-2", JUDGE), + ], + }), + ); + + const queue = await loadQueue(db, here); + + expect( + queueWindow(JUDGE, "sponsor", queue.candidates, Infinity)[0], + ).toBe("entry-2"); + }); + + it("still keeps a presentation another judge holds off the judge", async () => { + const { db } = fakeDb( + world({ + [appointments]: [ + { ...running("entry-0", "other"), startsAt: minutesAgo(6) }, + ], + [drafts]: [ + { + entryId: "entry-0", + judgeId: "other", + updatedAt: minutesAgo(1), + deadlineAt: minutesAhead(4), + revision: 1, + }, + ], + }), + ); + + const queue = await loadQueue(db, here); + + expect(queue.candidates).toEqual([ + { entryId: "entry-0", grading: "in_progress" }, + ]); + expect( + queueWindow(JUDGE, "sponsor", queue.candidates, Infinity), + ).toEqual([]); + }); + }); + }); + + it("is untimed for a scheduled challenge nobody has scheduled yet", async () => { + const { db } = fakeDb({ [entries]: ids(["entry-0"]) }); + + const queue = await loadQueue(db, input({ challenge: sponsor })); + + expect(queue.timed).toBe(false); + expect(queue.candidates).toHaveLength(1); + }); +}); + +describe("assertQueueOffers", () => { + const order = queueOrder(JUDGE, "general", entryIds); + const claim = ( + entryId: string, + answers: Record, + over: Partial[1]> = {}, + ) => { + const fake = fakeDb({ [entries]: ids(entryIds), ...answers }); + return { + ...fake, + claim: () => + assertQueueOffers(fake.db, { + hackathonId: HACK, + judgeId: JUDGE, + entryId, + challenge: general, + challenges, + ...over, + }), + }; + }; + + it("lets a judge start any of the first projects their queue offers", async () => { + for (const entryId of order.slice(0, QUEUE_WINDOW)) { + await expect(claim(entryId, {}).claim()).resolves.toBeUndefined(); + } + }); + + it("refuses a project further down their queue", async () => { + for (const entryId of order.slice(QUEUE_WINDOW)) { + await expect(claim(entryId, {}).claim()).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + message: QUEUE_REFUSAL, + }); + } + }); + + it("says what to do instead", () => { + expect(QUEUE_REFUSAL).toBe( + "Grade the projects your queue offers so every judge sees a random mix. Use Grade next project.", + ); + }); + + it("slides the window past projects that are graded or held", async () => { + const fourth = order[QUEUE_WINDOW]; + const { claim: go } = claim(fourth, { + [evaluations]: [{ entryId: order[0], judgeId: "other" }], + [drafts]: [ + { + entryId: order[1], + judgeId: "other", + updatedAt: minutesAgo(1), + deadlineAt: null, + }, + { + entryId: order[2], + judgeId: "other", + updatedAt: minutesAgo(2), + deadlineAt: null, + }, + ], + }); + + await expect(go()).resolves.toBeUndefined(); + }); + + it("does not slide the window past projects the judge only opened", async () => { + const held = (entryId: string, revision: number) => ({ + entryId, + judgeId: JUDGE, + updatedAt: minutesAgo(1), + deadlineAt: null, + revision, + }); + const opened = order.slice(0, QUEUE_WINDOW).map((id) => held(id, 0)); + await expect( + claim(order[QUEUE_WINDOW], { [drafts]: opened }).claim(), + ).rejects.toMatchObject({ message: QUEUE_REFUSAL }); + + // Once they save to one, they are grading it, and the window moves on. + const started = [held(order[0], 1), ...opened.slice(1)]; + await expect( + claim(order[QUEUE_WINDOW], { [drafts]: started }).claim(), + ).resolves.toBeUndefined(); + }); + + it("does not count a draft left alone past the hold", async () => { + const { claim: go } = claim(order[0], { + [drafts]: [ + { + entryId: order[0], + judgeId: "other", + updatedAt: new Date(Date.now() - GRADING_HOLD_MS - 60_000), + deadlineAt: null, + }, + ], + }); + + await expect(go()).resolves.toBeUndefined(); + }); + + it("asks nothing of a scheduled challenge: the schedule decides", async () => { + const { claim: go, reads } = claim( + order[7], + { [schedules]: [{ id: "schedule" }] }, + { challenge: sponsor }, + ); + + await expect(go()).resolves.toBeUndefined(); + expect(reads.some((r) => r.table === entries)).toBe(false); + }); + + it("still asks it of a scheduled challenge nobody has scheduled yet", async () => { + const worst = queueOrder(JUDGE, "sponsor", entryIds)[7]; + const { claim: go } = claim(worst, {}, { challenge: sponsor }); + + await expect(go()).rejects.toMatchObject({ message: QUEUE_REFUSAL }); + }); + + describe("under a group, with the list narrowed to one prize", () => { + const wholeOrder = queueOrder(JUDGE, "mlh", entryIds); + const prize = (entryId: string, prizeEntries: string[]) => + claim( + entryId, + { + [entries]: (call) => ids(call === 0 ? entryIds : prizeEntries), + [memberships]: [{ challengeId: "mongo" }], + }, + { challenge: mlh }, + ); + + it("offers a project that tops its prize's queue though it is far down the group's", async () => { + const far = wholeOrder[7]; + + await expect(prize(far, [far]).claim()).resolves.toBeUndefined(); + }); + + it("refuses a project that is outside the window of the group and of its prize", async () => { + const prizeEntries = wholeOrder.slice(3, 8); + const prizeOrder = queueOrder(JUDGE, "mlh", prizeEntries); + + await expect( + prize(prizeOrder[QUEUE_WINDOW], prizeEntries).claim(), + ).rejects.toMatchObject({ message: QUEUE_REFUSAL }); + }); + + it("checks the group first and stops there when it offers the project", async () => { + const { claim: go, reads } = prize(wholeOrder[0], []); + + await go(); + + expect(reads.filter((r) => r.table === entries)).toHaveLength(1); + expect(reads.some((r) => r.table === memberships)).toBe(false); + }); + }); +}); diff --git a/packages/api/src/services/judging/queue-store.ts b/packages/api/src/services/judging/queue-store.ts new file mode 100644 index 00000000..e6d85780 --- /dev/null +++ b/packages/api/src/services/judging/queue-store.ts @@ -0,0 +1,255 @@ +import { TRPCError } from "@trpc/server"; +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { + judgingAppointments, + judgingEntries, + judgingEntryChallenges, + judgingEvaluationDrafts, + judgingEvaluations, + judgingSchedules, +} from "@query/db"; +import type { JudgingDb } from "./config"; +import type { ChallengeRow, Seat } from "./evaluation-store"; +import { gradingStates } from "./grading-hold"; +import type { GradingState } from "./grading-hold"; +import { queueWindow } from "./queue"; +import { descendantsOf } from "./scope"; +import { evaluationTiming } from "./timing"; + +// The projects a judge is offered at a challenge, read from the database. The +// desk's next-project pick and the check on a judge's first claim of a project +// both go through loadQueue, so they never disagree about what is on offer. + +export type Queue = { + /** A scheduled challenge under a saved schedule: the schedule decides who sees what. */ + timed: boolean; + /** Timed, and the judge is not seated in a room that serves the challenge. */ + needsRoom: boolean; + /** + * The challenge's projects and where each stands for the judge. A project + * the judge has only opened counts as open (see loadQueue). For a timed + * challenge, only the presentations running now in the judge's room that + * the judge may start: not one still reserved for another judge (timing.ts). + * Those assigned to the judge say so. + */ + candidates: { entryId: string; grading: GradingState; assigned?: boolean }[]; +}; + +/** + * `challenge` is the root being graded. `filterId` narrows the projects to one + * prize inside it, as the desk's challenge filter does. + */ +export async function loadQueue( + db: JudgingDb, + input: { + hackathonId: string; + judgeId: string; + challenge: ChallengeRow; + seat: Seat | null; + filterId?: string; + }, + now = new Date(), +): Promise { + const { challenge, seat } = input; + const [schedule] = await db + .select({ id: judgingSchedules.id }) + .from(judgingSchedules) + .where(eq(judgingSchedules.hackathonId, input.hackathonId)) + .limit(1); + // A scheduled challenge is judged where it presents. + const timed = schedule !== undefined && challenge.mode === "scheduled"; + if (timed && seat?.challengeId !== challenge.id) { + return { timed, needsRoom: true, candidates: [] }; + } + const queue = (candidates: Queue["candidates"]): Queue => ({ + timed, + needsRoom: false, + candidates, + }); + + const entries = await db + .select({ id: judgingEntries.id }) + .from(judgingEntries) + .innerJoin( + judgingEntryChallenges, + and( + eq(judgingEntryChallenges.entryId, judgingEntries.id), + eq(judgingEntryChallenges.challengeId, input.filterId ?? challenge.id), + ), + ) + .where( + and( + eq(judgingEntries.hackathonId, input.hackathonId), + isNull(judgingEntries.deletedAt), + ), + ); + if (entries.length === 0) return queue([]); + const entryIds = entries.map((entry) => entry.id); + + const evaluations = await db + .select({ + entryId: judgingEvaluations.entryId, + judgeId: judgingEvaluations.judgeId, + }) + .from(judgingEvaluations) + .where( + and( + eq(judgingEvaluations.challengeId, challenge.id), + inArray(judgingEvaluations.entryId, entryIds), + ), + ); + const drafts = await db + .select({ + entryId: judgingEvaluationDrafts.entryId, + judgeId: judgingEvaluationDrafts.judgeId, + updatedAt: judgingEvaluationDrafts.updatedAt, + deadlineAt: judgingEvaluationDrafts.deadlineAt, + revision: judgingEvaluationDrafts.revision, + }) + .from(judgingEvaluationDrafts) + .where( + and( + eq(judgingEvaluationDrafts.challengeId, challenge.id), + inArray(judgingEvaluationDrafts.entryId, entryIds), + ), + ); + const grading = gradingStates( + input.judgeId, + entryIds, + evaluations, + drafts, + now, + ); + // A project the judge has only opened (desk.startGrading's draft, never + // saved to) stays on offer to them. Otherwise opening projects and backing + // out of them, or skipping them, would walk the judge down their queue. + const graded = new Set( + evaluations + .filter((evaluation) => evaluation.judgeId === input.judgeId) + .map((evaluation) => evaluation.entryId), + ); + const opened = new Set( + drafts + .filter( + (draft) => + draft.judgeId === input.judgeId && + draft.revision === 0 && + !graded.has(draft.entryId), + ) + .map((draft) => draft.entryId), + ); + let candidates = entryIds.map((entryId) => { + const state = grading.get(entryId) ?? ("open" as const); + return { + entryId, + grading: + state === "mine" && opened.has(entryId) ? ("open" as const) : state, + }; + }); + + if (timed && seat) { + const slots = await db + .select({ + id: judgingAppointments.id, + entryId: judgingAppointments.entryId, + startsAt: judgingAppointments.startsAt, + deadlineAt: judgingAppointments.deadlineAt, + assignedJudgeId: judgingAppointments.assignedJudgeId, + }) + .from(judgingAppointments) + .where( + and( + eq(judgingAppointments.scheduleId, schedule.id), + eq(judgingAppointments.challengeId, challenge.id), + eq(judgingAppointments.roomId, seat.roomId), + inArray(judgingAppointments.entryId, entryIds), + ), + ); + const running = new Set( + slots + .filter( + (slot) => + evaluationTiming( + { + judgeId: input.judgeId, + hasSchedule: true, + mode: challenge.mode, + challengeId: challenge.id, + evaluation: null, + appointment: slot, + presenceChallengeId: seat.challengeId, + }, + now, + ).canEdit, + ) + .map((slot) => slot.entryId), + ); + const assigned = new Set( + slots + .filter((slot) => slot.assignedJudgeId === input.judgeId) + .map((slot) => slot.entryId), + ); + candidates = candidates + .filter((c) => running.has(c.entryId)) + .map((c) => (assigned.has(c.entryId) ? { ...c, assigned: true } : c)); + } + + return queue(candidates); +} + +export const QUEUE_REFUSAL = + "Grade the projects your queue offers so every judge sees a random mix. Use Grade next project."; + +/** + * For a judge's first claim of a project (they hold no draft of it and have + * not graded it): at an untimed challenge it has to be one of the projects + * their queue offers (see QUEUE_WINDOW). A judge who narrowed the list to one + * prize is offered that prize's own window, so the project counts if it sits + * in the window of any prize it is entered in. Run it after claimGrading, in + * the same transaction. + */ +export async function assertQueueOffers( + tx: JudgingDb, + input: { + hackathonId: string; + judgeId: string; + entryId: string; + challenge: ChallengeRow; + challenges: readonly ChallengeRow[]; + }, + now = new Date(), +) { + const { challenge } = input; + const offers = (queue: Queue) => + queueWindow(input.judgeId, challenge.id, queue.candidates).includes( + input.entryId, + ); + // A timed challenge stops at the schedule check, so the seat is never read. + const base = { hackathonId: input.hackathonId, judgeId: input.judgeId }; + + const whole = await loadQueue(tx, { ...base, challenge, seat: null }, now); + if (whole.timed || offers(whole)) return; + + const below = descendantsOf(input.challenges, challenge.id).map((c) => c.id); + if (below.length > 0) { + const entered = await tx + .select({ challengeId: judgingEntryChallenges.challengeId }) + .from(judgingEntryChallenges) + .where( + and( + eq(judgingEntryChallenges.entryId, input.entryId), + inArray(judgingEntryChallenges.challengeId, below), + ), + ); + for (const { challengeId } of entered) { + const prize = await loadQueue( + tx, + { ...base, challenge, seat: null, filterId: challengeId }, + now, + ); + if (offers(prize)) return; + } + } + + throw new TRPCError({ code: "PRECONDITION_FAILED", message: QUEUE_REFUSAL }); +} diff --git a/packages/api/src/services/judging/queue.test.ts b/packages/api/src/services/judging/queue.test.ts new file mode 100644 index 00000000..474f8613 --- /dev/null +++ b/packages/api/src/services/judging/queue.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from "vitest"; +import { QUEUE_WINDOW, nextToGrade, queueOrder, queueWindow } from "./queue"; +import type { GradingState } from "./grading-hold"; + +const CHALLENGE = "c0000000-0000-4000-8000-000000000001"; +const entries = Array.from( + { length: 30 }, + (_, i) => `e${String(i).padStart(7, "0")}-0000-4000-8000-000000000000`, +); +const judges = Array.from( + { length: 40 }, + (_, i) => `j${String(i).padStart(7, "0")}-0000-4000-8000-000000000000`, +); + +const open = (entryId: string) => ({ entryId, grading: "open" as const }); +const state = (entryId: string, grading: GradingState) => ({ + entryId, + grading, +}); + +describe("queueOrder", () => { + it("is the same every time for a judge", () => { + expect(queueOrder(judges[0], CHALLENGE, entries)).toEqual( + queueOrder(judges[0], CHALLENGE, entries), + ); + }); + + it("does not depend on the order the projects arrive in", () => { + expect(queueOrder(judges[0], CHALLENGE, [...entries].reverse())).toEqual( + queueOrder(judges[0], CHALLENGE, entries), + ); + }); + + it("holds every project exactly once", () => { + const order = queueOrder(judges[0], CHALLENGE, entries); + expect(order).toHaveLength(entries.length); + expect(new Set(order)).toEqual(new Set(entries)); + }); + + it("gives a different judge a different order", () => { + expect(queueOrder(judges[0], CHALLENGE, entries)).not.toEqual( + queueOrder(judges[1], CHALLENGE, entries), + ); + }); + + it("gives the same judge a different order in another challenge", () => { + expect(queueOrder(judges[0], CHALLENGE, entries)).not.toEqual( + queueOrder(judges[0], "c0000000-0000-4000-8000-000000000002", entries), + ); + }); + + it("spreads the first pick across judges", () => { + const firsts = new Map(); + for (const judge of judges) { + const [first] = queueOrder(judge, CHALLENGE, entries); + firsts.set(first, (firsts.get(first) ?? 0) + 1); + } + // 40 judges over 30 projects: no one project is everyone's first. + expect(firsts.size).toBeGreaterThanOrEqual(15); + expect(Math.max(...firsts.values())).toBeLessThanOrEqual(6); + }); + + it("rarely sends two judges to the same project first", () => { + const firsts = judges.map( + (judge) => queueOrder(judge, CHALLENGE, entries)[0], + ); + let pairs = 0; + let collisions = 0; + for (let a = 0; a < firsts.length; a++) { + for (let b = a + 1; b < firsts.length; b++) { + pairs += 1; + if (firsts[a] === firsts[b]) collisions += 1; + } + } + // One in 30 if the order is random. + expect(collisions / pairs).toBeLessThan(0.1); + }); + + it("puts a project in no fixed place from one judge to the next", () => { + const places = judges.map((judge) => + queueOrder(judge, CHALLENGE, entries).indexOf(entries[0]), + ); + expect(new Set(places).size).toBeGreaterThanOrEqual(10); + }); + + it("is empty for no projects", () => { + expect(queueOrder(judges[0], CHALLENGE, [])).toEqual([]); + }); +}); + +describe("nextToGrade", () => { + it("is the judge's first project in their own order", () => { + expect(nextToGrade(judges[0], CHALLENGE, entries.map(open))).toBe( + queueOrder(judges[0], CHALLENGE, entries)[0], + ); + }); + + it("is the same on every call", () => { + const candidates = entries.map(open); + expect(nextToGrade(judges[3], CHALLENGE, candidates)).toBe( + nextToGrade(judges[3], CHALLENGE, candidates), + ); + }); + + it("passes over projects that are not open", () => { + const order = queueOrder(judges[0], CHALLENGE, entries); + const candidates = entries.map((entryId) => { + if (entryId === order[0]) return state(entryId, "graded"); + if (entryId === order[1]) return state(entryId, "in_progress"); + if (entryId === order[2]) return state(entryId, "mine"); + return open(entryId); + }); + expect(nextToGrade(judges[0], CHALLENGE, candidates)).toBe(order[3]); + }); + + it("never returns a project that is not open", () => { + const candidates = entries.map((entryId, i) => + state(entryId, (["graded", "in_progress", "mine"] as const)[i % 3]), + ); + expect(nextToGrade(judges[0], CHALLENGE, candidates)).toBeNull(); + }); + + it("is null when there is nothing to grade", () => { + expect(nextToGrade(judges[0], CHALLENGE, [])).toBeNull(); + }); + + it("only picks from the projects it is given", () => { + const scope = entries.slice(0, 3); + const picked = judges.map((judge) => + nextToGrade(judge, CHALLENGE, scope.map(open)), + ); + for (const entryId of picked) expect(scope).toContain(entryId); + expect(new Set(picked).size).toBeGreaterThan(1); + }); +}); + +describe("queueWindow", () => { + it("is the first few open projects in the judge's order", () => { + expect(QUEUE_WINDOW).toBe(3); + expect(queueWindow(judges[0], CHALLENGE, entries.map(open))).toEqual( + queueOrder(judges[0], CHALLENGE, entries).slice(0, QUEUE_WINDOW), + ); + }); + + it("counts only open projects, so held and graded ones do not use it up", () => { + const order = queueOrder(judges[0], CHALLENGE, entries); + const candidates = entries.map((entryId) => { + if (entryId === order[0]) return state(entryId, "graded"); + if (entryId === order[1]) return state(entryId, "in_progress"); + if (entryId === order[2]) return state(entryId, "mine"); + return open(entryId); + }); + expect(queueWindow(judges[0], CHALLENGE, candidates)).toEqual( + order.slice(3, 3 + QUEUE_WINDOW), + ); + }); + + it("is shorter when fewer projects are open", () => { + expect( + queueWindow(judges[0], CHALLENGE, entries.slice(0, 2).map(open)), + ).toHaveLength(2); + expect(queueWindow(judges[0], CHALLENGE, [])).toEqual([]); + }); + + it("is every open project when the window is unlimited", () => { + expect( + queueWindow(judges[0], CHALLENGE, entries.map(open), Infinity), + ).toEqual(queueOrder(judges[0], CHALLENGE, entries)); + }); + + it("differs from judge to judge, so each draws their own sample", () => { + const windows = new Set( + judges.map((judge) => + queueWindow(judge, CHALLENGE, entries.map(open)).join(), + ), + ); + expect(windows.size).toBeGreaterThan(30); + }); +}); + +describe("presentations assigned to the judge", () => { + const order = queueOrder(judges[0], CHALLENGE, entries); + const assigned = (entryId: string) => ({ + entryId, + grading: "open" as const, + assigned: true, + }); + // The judge's own come first, whatever place their queue gives them. + const mine = [order[7], order[3]]; + const candidates = entries.map((entryId) => + mine.includes(entryId) ? assigned(entryId) : open(entryId), + ); + + it("leads the window, each group in the judge's own order", () => { + expect( + queueWindow(judges[0], CHALLENGE, candidates, Infinity).slice(0, 4), + ).toEqual([order[3], order[7], order[0], order[1]]); + }); + + it("is what the judge is sent to next, ahead of the rest of the queue", () => { + expect( + nextToGrade(judges[0], CHALLENGE, candidates, { window: Infinity }), + ).toBe(order[3]); + }); + + it("passes over a skipped one, then the other, then the rest", () => { + const next = (skip: string[]) => + nextToGrade(judges[0], CHALLENGE, candidates, { skip, window: Infinity }); + expect(next([order[3]])).toBe(order[7]); + expect(next([order[3], order[7]])).toBe(order[0]); + }); + + it("does not offer one that is not open", () => { + const taken = entries.map((entryId) => + entryId === order[3] + ? { entryId, grading: "graded" as const, assigned: true } + : mine.includes(entryId) + ? assigned(entryId) + : open(entryId), + ); + expect(nextToGrade(judges[0], CHALLENGE, taken, { window: Infinity })).toBe( + order[7], + ); + }); + + it("changes nothing for projects with no assignment", () => { + expect(queueWindow(judges[0], CHALLENGE, entries.map(open))).toEqual( + order.slice(0, QUEUE_WINDOW), + ); + expect( + queueWindow( + judges[0], + CHALLENGE, + entries.map((entryId) => ({ + entryId, + grading: "open" as const, + assigned: false, + })), + ), + ).toEqual(order.slice(0, QUEUE_WINDOW)); + }); +}); + +describe("nextToGrade with skips", () => { + const order = queueOrder(judges[0], CHALLENGE, entries); + const next = (skip: string[], window?: number) => + nextToGrade(judges[0], CHALLENGE, entries.map(open), { skip, window }); + + it("passes over a skipped project to the next one in the window", () => { + expect(next([order[0]])).toBe(order[1]); + expect(next([order[0], order[1]])).toBe(order[2]); + }); + + it("is nothing once every project in the window is skipped", () => { + expect(next(order.slice(0, QUEUE_WINDOW))).toBeNull(); + }); + + it("never reaches past the window for a project that was not skipped", () => { + expect(next(order.slice(0, QUEUE_WINDOW))).not.toBe(order[QUEUE_WINDOW]); + }); + + it("ignores a skipped project outside the window", () => { + expect(next([order[10]])).toBe(order[0]); + }); + + it("reaches as far as an unlimited window goes", () => { + expect(next(order.slice(0, 5), Infinity)).toBe(order[5]); + }); +}); diff --git a/packages/api/src/services/judging/queue.ts b/packages/api/src/services/judging/queue.ts new file mode 100644 index 00000000..d0c30a34 --- /dev/null +++ b/packages/api/src/services/judging/queue.ts @@ -0,0 +1,94 @@ +import { createHash } from "node:crypto"; +import type { GradingState } from "./grading-hold"; + +// The order a judge is sent through a challenge's projects. Every judge gets +// their own shuffle of the same projects, so judges at one table draw a random +// sample each and rarely reach for the same project at once. The shuffle is a +// hash, not a random draw: it does not move between polls, and a judge who +// reloads is sent to the same project. + +/** A project's place in one judge's queue for a challenge. */ +function queueKey(judgeId: string, challengeId: string, entryId: string) { + return createHash("sha256") + .update(`${judgeId}:${entryId}:${challengeId}`) + .digest("hex"); +} + +/** `entryIds` in the order `judgeId` is sent through them for the challenge. */ +export function queueOrder( + judgeId: string, + challengeId: string, + entryIds: readonly string[], +): string[] { + return entryIds + .map((entryId) => ({ + entryId, + key: queueKey(judgeId, challengeId, entryId), + })) + .sort((a, b) => + a.key === b.key + ? a.entryId.localeCompare(b.entryId) + : a.key < b.key + ? -1 + : 1, + ) + .map((item) => item.entryId); +} + +type Candidate = { + entryId: string; + grading: GradingState; + /** A presentation assigned to this judge (scheduled challenges only). */ + assigned?: boolean; +}; + +// How many open projects at the front of their queue a judge may start at an +// untimed challenge. Calibration depends on each judge's sample being random. +// A small window lets a judge skip a team that is away from its table without +// letting them pick projects. +export const QUEUE_WINDOW = 3; + +/** + * The open projects a judge is offered: the first `window` of them in their + * queue order, with the presentations assigned to the judge ahead of the rest + * (each group in that order). A scheduled challenge passes Infinity, since the + * schedule already decides who sees what. + */ +export function queueWindow( + judgeId: string, + challengeId: string, + candidates: readonly Candidate[], + window = QUEUE_WINDOW, +): string[] { + const open = candidates.filter((candidate) => candidate.grading === "open"); + const assigned = new Set( + open.filter((c) => c.assigned).map((c) => c.entryId), + ); + const ordered = queueOrder( + judgeId, + challengeId, + open.map((candidate) => candidate.entryId), + ); + return [ + ...ordered.filter((entryId) => assigned.has(entryId)), + ...ordered.filter((entryId) => !assigned.has(entryId)), + ].slice(0, window); +} + +/** + * The next project for the judge to grade: the first of the projects their + * queue offers that they have not skipped. + */ +export function nextToGrade( + judgeId: string, + challengeId: string, + candidates: readonly Candidate[], + options: { skip?: readonly string[]; window?: number } = {}, +): string | null { + const skip = new Set(options.skip); + return ( + queueWindow(judgeId, challengeId, candidates, options.window).find( + (entryId) => !skip.has(entryId), + ) ?? null + ); +} diff --git a/packages/api/src/services/judging/resets.ts b/packages/api/src/services/judging/resets.ts new file mode 100644 index 00000000..f1378cce --- /dev/null +++ b/packages/api/src/services/judging/resets.ts @@ -0,0 +1,357 @@ +import { TRPCError } from "@trpc/server"; +import { and, eq, inArray, isNotNull, or } from "drizzle-orm"; +import { + judgingAnnouncements, + judgingBuildings, + judgingChallenges, + judgingClaimLinks, + judgingConfigs, + judgingEntries, + judgingEntryChallenges, + judgingEntryMembers, + judgingEvaluationDrafts, + judgingEvaluations, + judgingJudges, + judgingPlacements, + judgingRooms, + judgingRubricItems, + judgingSchedules, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { + initializeGroups, + lockEdition, + readSetupLock, + rebuildDerivedMemberships, +} from "./challenges"; +import { ensureJudgingConfig } from "./config"; +import type { JudgingDb } from "./config"; + +// The organiser's way back. Each reset takes the hackathon's name typed out, +// runs in one transaction, and says what it removed. Entries are deleted +// outright here, portal ones included: team.submitProject reads a removed +// (soft-deleted) portal entry as an organiser pulling the project, and a +// reset is not that. + +export type ResetInput = { hackathonId: string; confirmation: string }; + +async function lockConfirmedEdition(tx: JudgingDb, input: ResetInput) { + const locked = await lockEdition(tx, input.hackathonId); + if (input.confirmation.trim() !== locked.hackathon.name.trim()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `That is not the hackathon's name. Type ${locked.hackathon.name} exactly to confirm.`, + }); + } + return locked; +} + +async function hasRooms(tx: JudgingDb, hackathonId: string) { + const [room] = await tx + .select({ id: judgingRooms.id }) + .from(judgingRooms) + .where(eq(judgingRooms.hackathonId, hackathonId)) + .limit(1); + return !!room; +} + +const refuse = (message: string) => + new TRPCError({ code: "PRECONDITION_FAILED", message }); + +/** + * Every evaluation, draft and computed placing. The schedule stays, and can + * be dropped again once nothing has been scored. + */ +export function dropEvaluations(db: DrizzleDB, input: ResetInput) { + return db.transaction(async (tx) => { + await lockConfirmedEdition(tx, input); + const id = input.hackathonId; + + const drafts = await tx + .delete(judgingEvaluationDrafts) + .where(eq(judgingEvaluationDrafts.hackathonId, id)) + .returning({ id: judgingEvaluationDrafts.id }); + const evaluations = await tx + .delete(judgingEvaluations) + .where(eq(judgingEvaluations.hackathonId, id)) + .returning({ id: judgingEvaluations.id }); + const placements = await tx + .delete(judgingPlacements) + .where(eq(judgingPlacements.hackathonId, id)) + .returning({ id: judgingPlacements.id }); + await tx + .update(judgingSchedules) + .set({ firstResultAt: null }) + .where(eq(judgingSchedules.hackathonId, id)); + + return { + evaluations: evaluations.length, + drafts: drafts.length, + placements: placements.length, + }; + }); +} + +/** + * Every project, from both sources, and the challenges they brought. The + * starter groups and any group an organiser made stay. Needs rooms, the + * schedule and evaluations gone first, so nothing is removed by accident + * along the way. + */ +export function resetProjects(db: DrizzleDB, input: ResetInput) { + return db.transaction(async (tx) => { + await lockConfirmedEdition(tx, input); + const id = input.hackathonId; + + const lock = await readSetupLock(tx, id); + if (lock.scheduleSaved) { + throw refuse("Drop the saved schedule before resetting projects."); + } + if (lock.hasEvaluations) { + throw refuse("Drop evaluations before resetting projects."); + } + if (await hasRooms(tx, id)) { + throw refuse("Drop rooms before resetting projects."); + } + + const entries = await tx + .delete(judgingEntries) + .where(eq(judgingEntries.hackathonId, id)) + .returning({ id: judgingEntries.id }); + const challenges = await tx + .delete(judgingChallenges) + .where( + and( + eq(judgingChallenges.hackathonId, id), + eq(judgingChallenges.isGroup, false), + ), + ) + .returning({ id: judgingChallenges.id }); + await tx + .update(judgingConfigs) + .set({ + inventoryLockedAt: null, + inventoryLockedById: null, + claimsStartedAt: null, + updatedAt: new Date(), + }) + .where(eq(judgingConfigs.hackathonId, id)); + + return { entries: entries.length, challenges: challenges.length }; + }); +} + +/** Every room and announcement. Buildings stay for the next set of rooms. */ +export function dropRooms(db: DrizzleDB, input: ResetInput) { + return db.transaction(async (tx) => { + await lockConfirmedEdition(tx, input); + const id = input.hackathonId; + + const lock = await readSetupLock(tx, id); + if (lock.scheduleSaved) { + throw refuse( + "The saved schedule seats projects in these rooms. Drop it on the Schedule tab first.", + ); + } + + const announcements = await tx + .delete(judgingAnnouncements) + .where(eq(judgingAnnouncements.hackathonId, id)) + .returning({ id: judgingAnnouncements.id }); + const rooms = await tx + .delete(judgingRooms) + .where(eq(judgingRooms.hackathonId, id)) + .returning({ id: judgingRooms.id }); + + return { rooms: rooms.length, announcements: announcements.length }; + }); +} + +/** + * The challenge tree and rubric back to how a new edition starts: groups + * deleted and re-seeded, every challenge at the top level and scheduled, no + * rubric. Projects and their opt-ins stay. + */ +export function resetSetup(db: DrizzleDB, input: ResetInput) { + return db.transaction(async (tx) => { + const { config } = await lockConfirmedEdition(tx, input); + const id = input.hackathonId; + + const lock = await readSetupLock(tx, id); + if (lock.scheduleSaved) { + throw refuse("Drop the saved schedule before resetting setup."); + } + if (lock.hasEvaluations) { + throw refuse("Drop evaluations before resetting setup."); + } + if (await hasRooms(tx, id)) { + throw refuse( + "Rooms are tied to these challenges. Drop rooms before resetting setup.", + ); + } + + await tx + .delete(judgingEntryChallenges) + .where( + and( + eq(judgingEntryChallenges.hackathonId, id), + eq(judgingEntryChallenges.isOptIn, false), + ), + ); + await tx + .update(judgingChallenges) + .set({ + parentId: null, + mode: "scheduled", + isGeneral: false, + importLabelMatch: null, + }) + .where( + and( + eq(judgingChallenges.hackathonId, id), + eq(judgingChallenges.isGroup, false), + ), + ); + const groups = await tx + .delete(judgingChallenges) + .where( + and( + eq(judgingChallenges.hackathonId, id), + eq(judgingChallenges.isGroup, true), + ), + ) + .returning({ id: judgingChallenges.id }); + const rubric = await tx + .delete(judgingRubricItems) + .where(eq(judgingRubricItems.hackathonId, id)) + .returning({ id: judgingRubricItems.id }); + + await initializeGroups(tx, id, { ...config, groupsInitializedAt: null }); + await rebuildDerivedMemberships(tx, id); + + return { groups: groups.length, rubricItems: rubric.length }; + }); +} + +/** + * What hackers were shown and what they claimed: the itinerary goes back to + * hidden, emergency mode off, Devpost claims and claim links cleared so + * claiming starts over. Portal projects were linked when promoted and stay + * linked. + */ +export function resetLaunch(db: DrizzleDB, input: ResetInput) { + return db.transaction(async (tx) => { + await lockConfirmedEdition(tx, input); + const id = input.hackathonId; + + const links = await tx + .delete(judgingClaimLinks) + .where(eq(judgingClaimLinks.hackathonId, id)) + .returning({ id: judgingClaimLinks.id }); + + const devpostEntries = tx + .select({ id: judgingEntries.id }) + .from(judgingEntries) + .where( + and( + eq(judgingEntries.hackathonId, id), + eq(judgingEntries.source, "devpost"), + ), + ); + const unclaimed = await tx + .update(judgingEntryMembers) + .set({ userId: null, claimedAt: null }) + .where( + and( + eq(judgingEntryMembers.hackathonId, id), + inArray(judgingEntryMembers.entryId, devpostEntries), + or( + isNotNull(judgingEntryMembers.userId), + isNotNull(judgingEntryMembers.claimedAt), + ), + ), + ) + .returning({ id: judgingEntryMembers.id }); + + await tx + .update(judgingConfigs) + .set({ + itineraryPublished: false, + emergencyMode: false, + claimsStartedAt: null, + updatedAt: new Date(), + }) + .where(eq(judgingConfigs.hackathonId, id)); + + return { claimLinks: links.length, unclaimedMembers: unclaimed.length }; + }); +} + +/** + * Everything judging holds for the edition, in one transaction, then the + * starter groups again. Judges' accounts and the edition itself are + * untouched; only their judging rows go. + */ +export function resetAll(db: DrizzleDB, input: ResetInput) { + return db.transaction(async (tx) => { + await lockConfirmedEdition(tx, input); + const id = input.hackathonId; + + // Table by table so the result can say what went. Most of these would + // also go by cascade from the rooms, judges and entries. + const drafts = await tx + .delete(judgingEvaluationDrafts) + .where(eq(judgingEvaluationDrafts.hackathonId, id)) + .returning({ id: judgingEvaluationDrafts.id }); + const evaluations = await tx + .delete(judgingEvaluations) + .where(eq(judgingEvaluations.hackathonId, id)) + .returning({ id: judgingEvaluations.id }); + await tx + .delete(judgingPlacements) + .where(eq(judgingPlacements.hackathonId, id)); + const schedules = await tx + .delete(judgingSchedules) + .where(eq(judgingSchedules.hackathonId, id)) + .returning({ id: judgingSchedules.id }); + await tx + .delete(judgingAnnouncements) + .where(eq(judgingAnnouncements.hackathonId, id)); + const judges = await tx + .delete(judgingJudges) + .where(eq(judgingJudges.hackathonId, id)) + .returning({ id: judgingJudges.id }); + const rooms = await tx + .delete(judgingRooms) + .where(eq(judgingRooms.hackathonId, id)) + .returning({ id: judgingRooms.id }); + await tx + .delete(judgingBuildings) + .where(eq(judgingBuildings.hackathonId, id)); + const entries = await tx + .delete(judgingEntries) + .where(eq(judgingEntries.hackathonId, id)) + .returning({ id: judgingEntries.id }); + const challenges = await tx + .delete(judgingChallenges) + .where(eq(judgingChallenges.hackathonId, id)) + .returning({ id: judgingChallenges.id }); + await tx + .delete(judgingRubricItems) + .where(eq(judgingRubricItems.hackathonId, id)); + await tx.delete(judgingConfigs).where(eq(judgingConfigs.hackathonId, id)); + + const config = await ensureJudgingConfig(tx, id); + await initializeGroups(tx, id, config); + + return { + entries: entries.length, + challenges: challenges.length, + rooms: rooms.length, + judges: judges.length, + evaluations: evaluations.length, + drafts: drafts.length, + scheduleDropped: schedules.length > 0, + }; + }); +} diff --git a/packages/api/src/services/judging/room-link.test.ts b/packages/api/src/services/judging/room-link.test.ts new file mode 100644 index 00000000..7f25c78c --- /dev/null +++ b/packages/api/src/services/judging/room-link.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + GUEST_SESSION_SECONDS, + JUDGING_GUEST_COOKIE, + hashGuestToken, + newGuestToken, + roomLinkPath, + signRoomLink, + verifyRoomLink, +} from "./room-link"; + +const LINK = "3f1c2b8e-9d4a-4c1e-8f2a-5b6c7d8e9f01"; +const OTHER_LINK = "7a2d4e6f-1b3c-4d5e-9f8a-0b1c2d3e4f56"; + +describe("room links", () => { + const original = process.env.AUTH_SECRET; + + beforeEach(() => { + process.env.AUTH_SECRET = "test-secret-for-room-links"; + }); + + afterEach(() => { + if (original === undefined) delete process.env.AUTH_SECRET; + else process.env.AUTH_SECRET = original; + }); + + it("verifies a signature it made", () => { + expect(verifyRoomLink(LINK, signRoomLink(LINK))).toBe(true); + }); + + it("signs the same link the same way every time", () => { + expect(signRoomLink(LINK)).toBe(signRoomLink(LINK)); + }); + + it("rejects a signature made for another link", () => { + expect(verifyRoomLink(LINK, signRoomLink(OTHER_LINK))).toBe(false); + }); + + it("rejects a tampered signature", () => { + const sig = signRoomLink(LINK); + const flipped = (sig[0] === "A" ? "B" : "A") + sig.slice(1); + expect(verifyRoomLink(LINK, flipped)).toBe(false); + expect(verifyRoomLink(LINK, sig.slice(1))).toBe(false); + expect(verifyRoomLink(LINK, "")).toBe(false); + }); + + it("rejects a signature made with a different secret", () => { + const sig = signRoomLink(LINK); + process.env.AUTH_SECRET = "a-rotated-secret"; + expect(verifyRoomLink(LINK, sig)).toBe(false); + }); + + it("throws a clear error when AUTH_SECRET is unset", () => { + delete process.env.AUTH_SECRET; + expect(() => signRoomLink(LINK)).toThrow(/AUTH_SECRET is not set/); + expect(() => verifyRoomLink(LINK, "anything")).toThrow( + /AUTH_SECRET is not set/, + ); + }); + + it("builds the activation path with the signature", () => { + expect(roomLinkPath(LINK)).toBe( + `/judge/activate/${LINK}?sig=${signRoomLink(LINK)}`, + ); + }); +}); + +describe("guest tokens", () => { + it("makes 32 random bytes, URL-safe", () => { + const token = newGuestToken(); + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(Buffer.from(token, "base64url")).toHaveLength(32); + expect(newGuestToken()).not.toBe(token); + }); + + it("hashes to sha256 hex, stable for the same token", () => { + const token = newGuestToken(); + expect(hashGuestToken(token)).toMatch(/^[0-9a-f]{64}$/); + expect(hashGuestToken(token)).toBe(hashGuestToken(token)); + expect(hashGuestToken(token)).not.toBe(hashGuestToken(newGuestToken())); + }); + + it("names the cookie and an eight-hour session", () => { + expect(JUDGING_GUEST_COOKIE).toBe("query_judging_guest"); + expect(GUEST_SESSION_SECONDS).toBe(28800); + }); +}); diff --git a/packages/api/src/services/judging/room-link.ts b/packages/api/src/services/judging/room-link.ts new file mode 100644 index 00000000..92d57395 --- /dev/null +++ b/packages/api/src/services/judging/room-link.ts @@ -0,0 +1,55 @@ +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +// The QR code on a judging room's door opens /judge/activate/?sig=… +// The signature proves the link came from an organiser's "generate link" and +// was not guessed from a room id. Revoking the link row is what ends it; the +// signature itself never expires. + +/** Cookie holding a guest judge's raw session token. Only its sha256 is stored. */ +export const JUDGING_GUEST_COOKIE = "query_judging_guest"; + +/** A guest judge's session lasts one judging day. */ +export const GUEST_SESSION_SECONDS = 8 * 3600; + +// Mixed into every signature so one minted here can never be replayed as some +// other value keyed from AUTH_SECRET. +const SIGNATURE_DOMAIN = "query/judging/room-link/v1:"; + +function signingKey(): string { + const secret = process.env.AUTH_SECRET; + if (!secret) { + throw new Error( + "AUTH_SECRET is not set. Judging room links are signed with it: set AUTH_SECRET in the environment, then generate the link again.", + ); + } + return secret; +} + +export function signRoomLink(linkId: string): string { + return createHmac("sha256", signingKey()) + .update(SIGNATURE_DOMAIN + linkId) + .digest("base64url"); +} + +/** Constant-time check of a signature from a scanned link. */ +export function verifyRoomLink(linkId: string, sig: string): boolean { + const expected = Buffer.from(signRoomLink(linkId)); + const given = Buffer.from(sig); + if (given.length !== expected.length) return false; + return timingSafeEqual(given, expected); +} + +/** The path a room's QR code points at, relative to the portal origin. */ +export function roomLinkPath(linkId: string): string { + return `/judge/activate/${linkId}?sig=${signRoomLink(linkId)}`; +} + +/** A fresh guest session token: 32 random bytes, URL-safe. */ +export function newGuestToken(): string { + return randomBytes(32).toString("base64url"); +} + +/** What the database stores for a guest token. */ +export function hashGuestToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} diff --git a/packages/api/src/services/judging/rooms.test.ts b/packages/api/src/services/judging/rooms.test.ts new file mode 100644 index 00000000..6511ca60 --- /dev/null +++ b/packages/api/src/services/judging/rooms.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + newTableNumbers, + nextTableNumber, + portalOrigin, + revokeRoomAccess, + roomLinkUrl, + slotTables, + tableNumber, +} from "./rooms"; +import { verifyRoomLink } from "./room-link"; +import type { JudgingDb } from "./config"; + +const saved = { + NEXTAUTH_URL: process.env.NEXTAUTH_URL, + AUTH_URL: process.env.AUTH_URL, + AUTH_SECRET: process.env.AUTH_SECRET, +}; + +beforeEach(() => { + delete process.env.NEXTAUTH_URL; + delete process.env.AUTH_URL; + process.env.AUTH_SECRET = "test-secret-for-room-links"; +}); + +afterEach(() => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +describe("portalOrigin", () => { + it("prefers the configured site URL over the request", () => { + process.env.NEXTAUTH_URL = "https://datasciencegt.org/some/path"; + expect(portalOrigin(new Request("https://preview.example.app/api/trpc"))).toBe( + "https://datasciencegt.org", + ); + }); + + it("falls back to AUTH_URL, then the request, then the public site", () => { + process.env.AUTH_URL = "http://localhost:3000"; + expect(portalOrigin()).toBe("http://localhost:3000"); + delete process.env.AUTH_URL; + expect(portalOrigin(new Request("https://preview.example.app/x"))).toBe( + "https://preview.example.app", + ); + expect(portalOrigin()).toBe("https://datasciencegt.org"); + }); +}); + +describe("roomLinkUrl", () => { + it("is an absolute activation link whose signature verifies", () => { + const linkId = "0b6f1c1e-7f44-4b0e-9d1a-2f3c4d5e6f70"; + const url = new URL(roomLinkUrl(linkId, "https://datasciencegt.org")); + expect(url.origin).toBe("https://datasciencegt.org"); + expect(url.pathname).toBe(`/judge/activate/${linkId}`); + expect(verifyRoomLink(linkId, url.searchParams.get("sig")!)).toBe(true); + }); +}); + +// Each awaited update resolves to the next queued result and records its +// `set` and `where` arguments. +function fakeTx(updates: unknown[][]) { + const sets: unknown[] = []; + const db = { + update: () => { + const builder = { + set: (value: unknown) => { + sets.push(value); + return builder; + }, + where: () => builder, + returning: () => Promise.resolve(updates.shift() ?? []), + }; + return builder; + }, + }; + return { tx: db as unknown as JudgingDb, sets }; +} + +describe("revokeRoomAccess", () => { + it("retires links, ends guest sessions and stands their judges up", async () => { + const { tx, sets } = fakeTx([ + [{ id: "link-1" }], + [{ judgeId: "judge-1" }, { judgeId: null }, { judgeId: "judge-1" }], + [{ id: "presence-1" }], + ]); + await expect( + revokeRoomAccess(tx, { hackathonId: "h1", roomId: "r1" }, "officer"), + ).resolves.toEqual({ links: 1, sessions: 3, presence: 1 }); + expect(sets).toHaveLength(3); + expect(sets[0]).toMatchObject({ revokedById: "officer" }); + }); + + it("touches no seats when no guest had given a name", async () => { + const { tx, sets } = fakeTx([[{ id: "link-1" }], [{ judgeId: null }]]); + await expect( + revokeRoomAccess(tx, { hackathonId: "h1" }, null), + ).resolves.toEqual({ links: 1, sessions: 1, presence: 0 }); + expect(sets).toHaveLength(2); + }); +}); + +describe("table numbering", () => { + const hall = [ + { name: "Table 1", archived: false }, + { name: "table 2", archived: false }, + { name: "Table 4", archived: false }, + { name: "Table 9", archived: true }, + { name: "Sponsor booth", archived: false }, + ]; + + it("reads the number out of a table's name only", () => { + expect(tableNumber("Table 12")).toBe(12); + expect(tableNumber(" table 3 ")).toBe(3); + expect(tableNumber("Table 3B")).toBeNull(); + expect(tableNumber("Room 210")).toBeNull(); + expect(tableNumber("Table 9999")).toBe(9999); + expect(tableNumber("Table 20000")).toBeNull(); + }); + + it("goes on from the highest number, archived tables included", () => { + expect(nextTableNumber(hall)).toBe(10); + expect(nextTableNumber([])).toBe(1); + expect(newTableNumbers(hall, 3)).toEqual([10, 11, 12]); + }); + + it("skips numbers an active table has, without running past the range", () => { + expect(newTableNumbers(hall, 5, 1)).toEqual([3, 5]); + // An archived table's number is free again. + expect(newTableNumbers(hall, 2, 9)).toEqual([9, 10]); + expect(newTableNumbers(hall, 2, 1)).toEqual([]); + }); + + it("slots new tables in by number among the hall's tables", () => { + const at = (id: string, number: number | null) => ({ id, number }); + const table = (id: string, number: number) => ({ id, number }); + const ids = (list: { id: string }[]) => list.map((station) => station.id); + const order = [ + at("Room 210", null), + at("Table 1", 1), + at("Table 3", 3), + // Another hall's table, or a room between the hall's tables. + at("Table 4 elsewhere", null), + at("Table 5", 5), + ]; + + expect( + ids(slotTables(order, [table("Table 2", 2), table("Table 6", 6)])), + ).toEqual([ + "Room 210", + "Table 1", + "Table 2", + "Table 3", + "Table 4 elsewhere", + "Table 5", + "Table 6", + ]); + // Below every table in the hall: ahead of the lowest. + expect( + ids( + slotTables( + [at("Room 210", null), at("Table 3", 3)], + [table("Table 1", 1), table("Table 2", 2)], + ), + ), + ).toEqual(["Room 210", "Table 1", "Table 2", "Table 3"]); + // A hall with no tables yet: at the end. + expect( + ids(slotTables([at("Room 210", null)], [table("Table 1", 1)])), + ).toEqual(["Room 210", "Table 1"]); + }); +}); diff --git a/packages/api/src/services/judging/rooms.ts b/packages/api/src/services/judging/rooms.ts new file mode 100644 index 00000000..42831c01 --- /dev/null +++ b/packages/api/src/services/judging/rooms.ts @@ -0,0 +1,240 @@ +import { and, count, eq, inArray, isNull } from "drizzle-orm"; +import { + judgingAppointments, + judgingGuestSessions, + judgingPresence, + judgingRoomLinks, +} from "@query/db"; +import { lockInventory } from "./config"; +import type { JudgingDb } from "./config"; +import { roomLinkPath } from "./room-link"; + +// Room QR links. A room has at most one live link; issuing a new one retires +// the old, and retiring a link signs out every guest who came in through it. + +const FALLBACK_ORIGIN = "https://datasciencegt.org"; + +/** + * Where the portal is served, for links that leave the app: printed QR codes + * outlive the request that made them, so the configured site URL wins over + * whatever host the organiser happened to use. + */ +export function portalOrigin(req?: Request): string { + const configured = process.env.NEXTAUTH_URL || process.env.AUTH_URL; + if (configured) return new URL(configured).origin; + if (req) return new URL(req.url).origin; + return FALLBACK_ORIGIN; +} + +/** The absolute URL a room's QR code encodes. */ +export function roomLinkUrl(linkId: string, origin: string): string { + return new URL(roomLinkPath(linkId), origin).toString(); +} + +export type RevokedAccess = { + links: number; + sessions: number; + presence: number; +}; + +/** + * Retires the live link of one room, or of every room in the edition, and + * ends the guest sessions in those rooms along with their seats. Member + * judges keep their seats: they signed in, and the link was only their way + * to the room. + */ +export async function revokeRoomAccess( + tx: JudgingDb, + scope: { hackathonId: string; roomId?: string }, + userId: string | null, +): Promise { + const now = new Date(); + + const links = await tx + .update(judgingRoomLinks) + .set({ revokedAt: now, revokedById: userId }) + .where( + and( + eq(judgingRoomLinks.hackathonId, scope.hackathonId), + scope.roomId ? eq(judgingRoomLinks.roomId, scope.roomId) : undefined, + isNull(judgingRoomLinks.revokedAt), + ), + ) + .returning({ id: judgingRoomLinks.id }); + + // By room rather than by the links just retired, so a session that + // outlived an earlier revocation is caught too. + const sessions = await tx + .update(judgingGuestSessions) + .set({ revokedAt: now }) + .where( + and( + eq(judgingGuestSessions.hackathonId, scope.hackathonId), + scope.roomId + ? eq(judgingGuestSessions.roomId, scope.roomId) + : undefined, + isNull(judgingGuestSessions.revokedAt), + ), + ) + .returning({ judgeId: judgingGuestSessions.judgeId }); + + const judgeIds = [ + ...new Set( + sessions + .map((session) => session.judgeId) + .filter((id): id is string => id !== null), + ), + ]; + const presence = + judgeIds.length > 0 + ? await tx + .update(judgingPresence) + .set({ leftAt: now }) + .where( + and( + inArray(judgingPresence.judgeId, judgeIds), + isNull(judgingPresence.leftAt), + ), + ) + .returning({ id: judgingPresence.id }) + : []; + + return { + links: links.length, + sessions: sessions.length, + presence: presence.length, + }; +} + +/** + * Replaces a room's link with a fresh one. The first link anywhere locks the + * project inventory: from then on a Devpost import only adds projects. + */ +export async function issueRoomLink( + tx: JudgingDb, + room: { id: string; hackathonId: string }, + userId: string, +) { + const revoked = await revokeRoomAccess( + tx, + { hackathonId: room.hackathonId, roomId: room.id }, + userId, + ); + const [link] = await tx + .insert(judgingRoomLinks) + .values({ + roomId: room.id, + hackathonId: room.hackathonId, + createdById: userId, + }) + .returning({ id: judgingRoomLinks.id, createdAt: judgingRoomLinks.createdAt }); + await lockInventory(tx, room.hackathonId, userId); + return { link: link!, revoked }; +} + +/** Everyone still seated in a room stands up, members included. */ +export async function emptyRoom(tx: JudgingDb, roomId: string) { + const rows = await tx + .update(judgingPresence) + .set({ leftAt: new Date() }) + .where( + and(eq(judgingPresence.roomId, roomId), isNull(judgingPresence.leftAt)), + ) + .returning({ id: judgingPresence.id }); + return rows.length; +} + +/** A judging table's name: "Table 7". */ +export const tableName = (number: number) => `Table ${number}`; + +/** The highest table number createTables hands out. */ +export const MAX_TABLE_NUMBER = 9999; + +/** + * The number in a "Table 7" name, or null for any other name. A number past + * MAX_TABLE_NUMBER reads as a plain name, so a hand-typed "Table 20000" + * cannot push the next blank-start batch out of range. + */ +export function tableNumber(name: string): number | null { + const match = /^table\s+(\d+)$/i.exec(name.trim()); + const number = match ? Number(match[1]) : null; + return number !== null && number <= MAX_TABLE_NUMBER ? number : null; +} + +type HallStation = { name: string; archived: boolean }; + +/** + * Where a hall's numbering goes on: one past its highest "Table N", archived + * tables included so a new table never shares a number with one that could + * come back. + */ +export function nextTableNumber(stations: readonly HallStation[]): number { + let highest = 0; + for (const station of stations) { + highest = Math.max(highest, tableNumber(station.name) ?? 0); + } + return highest + 1; +} + +/** + * The numbers to create for `count` tables starting at `first`, less those an + * active station in the hall already goes by. A skipped number is not made + * up at the end: the run matches the numbers on the tables in the hall. + */ +export function newTableNumbers( + stations: readonly HallStation[], + count: number, + first = nextTableNumber(stations), +): number[] { + const taken = new Set( + stations + .filter((station) => !station.archived) + .map((station) => tableNumber(station.name)), + ); + const numbers: number[] = []; + for (let number = first; number < first + count; number++) { + if (!taken.has(number)) numbers.push(number); + } + return numbers; +} + +/** + * The edition's station list with new tables slotted in by number. `number` + * is set only on the hall's tables. Each new table goes after the hall's + * highest-numbered table below it, else before its lowest above it, else at + * the end, so a Table 12 added later lists between 11 and 13, not after 30. + */ +export function slotTables( + order: readonly T[], + added: readonly (T & { number: number })[], +): T[] { + const list = [...order]; + for (const table of added) { + let after = -1; + let afterNumber = -Infinity; + let before = list.length; + let beforeNumber = Infinity; + list.forEach(({ number }, index) => { + if (number === null) return; + if (number < table.number && number > afterNumber) { + after = index; + afterNumber = number; + } + if (number > table.number && number < beforeNumber) { + before = index; + beforeNumber = number; + } + }); + list.splice(after >= 0 ? after + 1 : before, 0, table); + } + return list; +} + +/** How many scheduled presentations a room hosts. */ +export async function roomAppointmentCount(tx: JudgingDb, roomId: string) { + const [row] = await tx + .select({ value: count() }) + .from(judgingAppointments) + .where(eq(judgingAppointments.roomId, roomId)); + return row?.value ?? 0; +} diff --git a/packages/api/src/services/judging/schedule/aamogh-cp-solver.test.ts b/packages/api/src/services/judging/schedule/aamogh-cp-solver.test.ts new file mode 100644 index 00000000..524052f3 --- /dev/null +++ b/packages/api/src/services/judging/schedule/aamogh-cp-solver.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from "vitest"; +import { aamoghCPSolver } from "./aamogh-cp-solver"; +import { compareScores, scoreSchedule, validateSchedule } from "./rules"; +import type { PlanScore } from "./rules"; +import { solvePlan } from "./solve"; +import type { Placement, PlanInputs, PlanRoom, PlanTask } from "./inputs"; + +const task = (entryId: string, challengeId: string): PlanTask => ({ + entryId, + challengeId, + sponsor: challengeId !== "general", +}); +const room = (id: string, challengeId: string, buildingId: string): PlanRoom => ({ + id, + challengeId, + buildingId, +}); + +/** Every assignment of every task to every (room, slot): the true optimum. */ +function bruteForce(inputs: PlanInputs, relaxed: boolean): PlanScore | null { + let best: PlanScore | null = null; + const chosen: Placement[] = []; + const cells = new Set(); + const visit = (i: number) => { + if (i === inputs.tasks.length) { + if (!validateSchedule(inputs, chosen, { relaxed }).valid) return; + const score = scoreSchedule(inputs, chosen); + if (!best || compareScores(score, best) < 0) best = score; + return; + } + const t = inputs.tasks[i]!; + for (const r of inputs.rooms) { + if (r.challengeId !== t.challengeId) continue; + for (let slot = 0; slot < inputs.slots; slot += 1) { + const cell = `${r.id}@${slot}`; + if (cells.has(cell)) continue; + cells.add(cell); + chosen.push({ ...t, roomId: r.id, slot }); + visit(i + 1); + chosen.pop(); + cells.delete(cell); + } + } + }; + visit(0); + return best; +} + +/** A small seeded generator, so the random instances are the same every run. */ +function lcg(seed: number) { + let state = seed; + return (n: number) => { + state = (state * 1103515245 + 12345) % 2147483648; + return state % n; + }; +} + +function smallInstance(seed: number): PlanInputs { + const pick = lcg(seed); + const buildings = ["ENG", "HEC"]; + const rooms: PlanRoom[] = [room("g1", "general", buildings[pick(2)]!)]; + if (pick(2)) rooms.push(room("g2", "general", buildings[pick(2)]!)); + rooms.push(room("s1", "sp1", buildings[pick(2)]!)); + const tasks: PlanTask[] = []; + const projects = 2 + pick(2); + for (let p = 0; p < projects; p += 1) { + tasks.push(task(`p${p}`, "general")); + if (pick(3) > 0) tasks.push(task(`p${p}`, "sp1")); + } + return { rooms, tasks, slots: 4 + pick(2), sameGap: pick(2), crossGap: 1 + pick(2) }; +} + +function solved(inputs: PlanInputs) { + const result = aamoghCPSolver(inputs); + if (!result.ok) { + throw new Error(`expected a schedule: ${JSON.stringify(result.diagnostics)}`); + } + return result; +} + +/** 300 projects, 8 general rooms over three buildings, four sponsor rooms. */ +function largeEvent(): PlanInputs { + const rooms: PlanRoom[] = []; + for (let i = 0; i < 8; i += 1) rooms.push(room(`g${i}`, "general", `B${i % 3}`)); + for (let i = 0; i < 4; i += 1) rooms.push(room(`s${i}`, `sp${i}`, `B${i % 3}`)); + const tasks: PlanTask[] = []; + for (let n = 0; n < 300; n += 1) { + const id = `e${String(n).padStart(3, "0")}`; + tasks.push(task(id, "general")); + for (let k = 0; k < 4; k += 1) { + if ((n * 37 + k * 53) % 100 < 13) tasks.push(task(id, `sp${k}`)); + } + } + return { rooms, tasks, slots: 48, sameGap: 1, crossGap: 2 }; +} + +/** + * A tight event: every project presents to general and about a third also + * to each of three sponsors, rooms almost full, two buildings apart. + */ +function tightEvent(seed: number, projects: number, slots: number): PlanInputs { + const pick = lcg(seed); + const rooms: PlanRoom[] = []; + for (let i = 0; i < Math.ceil(projects / slots); i += 1) { + rooms.push(room(`g${i}`, "general", `B${i % 2}`)); + } + const tasks: PlanTask[] = []; + const count = [0, 0, 0]; + for (let p = 0; p < projects; p += 1) { + const id = `e${String(p).padStart(3, "0")}`; + tasks.push(task(id, "general")); + for (let k = 0; k < 3; k += 1) { + if (pick(3) === 0) { + tasks.push(task(id, `sp${k}`)); + count[k] = count[k]! + 1; + } + } + } + for (let k = 0; k < 3; k += 1) { + for (let j = 0; j < Math.max(1, Math.ceil(count[k]! / slots)); j += 1) { + rooms.push(room(`s${k}${j}`, `sp${k}`, `B${(k + j) % 2}`)); + } + } + return { rooms, tasks, slots, sameGap: 1, crossGap: 2 }; +} + +describe("aamoghCPSolver", () => { + it("finds the true optimum on every small instance", () => { + for (let seed = 1; seed <= 40; seed += 1) { + const inputs = smallInstance(seed); + const strict = bruteForce(inputs, false); + const optimum = strict ?? bruteForce(inputs, true); + const result = aamoghCPSolver(inputs); + if (!optimum) { + expect(result.ok, `seed ${seed}`).toBe(false); + continue; + } + if (!result.ok) throw new Error(`seed ${seed}: no schedule, optimum exists`); + expect(result.relaxed, `seed ${seed}`).toBe(strict === null); + expect(result.score, `seed ${seed}`).toEqual(optimum); + } + }); + + it("never claims a lower bound above the true optimum", () => { + for (let seed = 1; seed <= 40; seed += 1) { + const inputs = smallInstance(seed); + const optimum = bruteForce(inputs, false) ?? bruteForce(inputs, true); + const result = aamoghCPSolver(inputs); + if (!optimum || !result.ok) continue; + result.stats.lowerBounds.forEach((bound, goal) => { + if (bound !== null) expect(optimum[goal]!, `seed ${seed} goal ${goal}`).toBeGreaterThanOrEqual(bound); + }); + } + }); + + it("marks a goal proven only when it sits on its bound", () => { + const result = solved(largeEvent()); + result.stats.proven.forEach((proven, goal) => { + const bound = result.stats.lowerBounds[goal]; + expect(proven).toBe(bound !== null && bound !== undefined && result.score[goal] === bound); + }); + // Counting alone settles the first four goals on this event. + expect(result.stats.proven.slice(0, 4)).toEqual([true, true, true, true]); + }); + + it("shortens cross-building breaks only when nothing else fits, and says so", () => { + // "a" must present in ENG and HEC with a 3-slot walk, in a 3-slot window. + const inputs: PlanInputs = { + rooms: [room("g1", "general", "ENG"), room("s1", "sp1", "HEC")], + tasks: [task("a", "general"), task("a", "sp1")], + slots: 3, + sameGap: 1, + crossGap: 3, + }; + const result = solved(inputs); + expect(result.relaxed).toBe(true); + expect(result.reducedBreaks).toHaveLength(1); + expect(validateSchedule(inputs, result.placements, { relaxed: true }).valid).toBe(true); + expect(validateSchedule(inputs, result.placements, { relaxed: false }).valid).toBe(false); + }); + + it("reports capacity it can count instead of searching", () => { + const result = aamoghCPSolver({ + rooms: [room("g1", "general", "ENG")], + tasks: [task("a", "general"), task("b", "general"), task("c", "general")], + slots: 2, + sameGap: 1, + crossGap: 2, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.diagnostics[0]).toMatchObject({ kind: "capacity" }); + }); + + it("finishes a tight schedule that the repair alone leaves incomplete", () => { + // The sweep and the repair leave presentations unplaced here, even with + // relaxed breaks; the completion search finds a schedule that keeps the + // full cross-building break. + const inputs = tightEvent(3, 16, 5); + const result = solved(inputs); + expect(result.relaxed).toBe(false); + expect(result.placements).toHaveLength(inputs.tasks.length); + expect(validateSchedule(inputs, result.placements, { relaxed: false }).valid).toBe(true); + // The search draws from the seeded generator, so a save still lands on + // what was previewed. + expect(solved(inputs).placements).toEqual(result.placements); + }); + + it("polishes the best schedule again after the kicks", () => { + // The kicks keep the best schedule they settle on, which here still has + // a trade open that buys one more break. + const result = solved(tightEvent(19, 10, 5)); + expect(compareScores(result.score, [5, 2, 5, 0, -13])).toBeLessThanOrEqual(0); + }); + + it("names what it could not place when no schedule exists", () => { + // A project's two presentations need a slot between them, which in a + // three-slot day means slots 0 and 2: three projects cannot all fit. + const tasks: PlanTask[] = []; + for (const id of ["a", "b", "c"]) tasks.push(task(id, "general"), task(id, "sp1")); + const result = aamoghCPSolver({ + rooms: [room("g1", "general", "ENG"), room("s1", "sp1", "ENG")], + tasks, + slots: 3, + sameGap: 1, + crossGap: 1, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.diagnostics.length).toBeGreaterThan(0); + for (const d of result.diagnostics) expect(d.kind).toBe("unplaced"); + } + }); + + it("gives the same schedule for the same inputs", () => { + const inputs = largeEvent(); + expect(solved(inputs).placements).toEqual(solved(inputs).placements); + }); + + it("only improves on a schedule it is given", () => { + const inputs = largeEvent(); + const greedy = solvePlan(inputs); + if (!greedy.ok) throw new Error("greedy found no schedule"); + const result = aamoghCPSolver(inputs, { start: greedy.placements }); + if (!result.ok) throw new Error("no schedule from a valid start"); + expect(compareScores(result.score, greedy.score)).toBeLessThanOrEqual(0); + }); + + it("schedules 300 projects in 12 rooms validly and quickly", () => { + const inputs = largeEvent(); + const started = performance.now(); + const result = solved(inputs); + const elapsed = performance.now() - started; + expect(validateSchedule(inputs, result.placements, { relaxed: result.relaxed }).valid).toBe(true); + expect(result.placements).toHaveLength(inputs.tasks.length); + // About half a second on a laptop; the margin is for slow CI machines. + expect(elapsed).toBeLessThan(10_000); + }); +}); diff --git a/packages/api/src/services/judging/schedule/aamogh-cp-solver.ts b/packages/api/src/services/judging/schedule/aamogh-cp-solver.ts new file mode 100644 index 00000000..a15df25c --- /dev/null +++ b/packages/api/src/services/judging/schedule/aamogh-cp-solver.ts @@ -0,0 +1,1708 @@ +import { taskKey } from "./inputs"; +import type { Placement, PlanInputs } from "./inputs"; +import { compareScores, scoreSchedule, validateSchedule } from "./rules"; +import type { PlanScore } from "./rules"; +import { capacityProblems } from "./solve"; +import type { PlanDiagnostic, PlanResult } from "./solve"; + +// aamoghCPSolver: the judging scheduler, built for this one problem rather +// than for constraint programming in general. +// +// Same rules and the same five goals as the CP-SAT model in +// packages/api/scripts/judging-schedule/cpsat.py, solved a different way: +// +// bounds Before searching, every goal gets a lower bound from counting +// alone (room capacity per challenge, each project's chain of +// presentations, the fewest buildings a project can stay in). A +// goal that reaches its bound is proven optimal and the search +// moves on. Without these, CP-SAT spent most of its time limit +// proving what the counting already said. +// symmetry Rooms that judge the same challenge in the same building are +// interchangeable, so a value is "this class at this slot", not +// "this room at this slot". On a typical event that divides the +// branching by the number of rooms per building. +// sweep The first schedule fills the grid slot by slot, sponsor +// presentations first, each free room taking the most +// constrained project that may present there. +// repair Large-neighbourhood search aimed at one goal at a time, in +// priority order: free the presentations that hold the goal +// where it is (everything in the last slot, a sponsor room's +// gaps, a project's building hops) plus a window around them, +// and re-place them with a small exact search: fail-first +// variable order, forward-checked domains capped so the freed +// part must beat the goal, and goal-aware value order. +// polish Building changes and breaks only ever involve two projects +// per move, so they get a cheap trade-and-move climb with +// per-project costs, then exact re-seating one challenge at a +// time, before the exact repair takes a fixed number of kicks. +// complete When the repair cannot finish a schedule, a min-conflicts +// search looks for any complete one before breaks are relaxed, +// and the repair improves that instead. +// +// Budgets count work, not time, and the only randomness is a generator seeded +// from the inputs, so the same inputs always give the same schedule: a save +// re-solves on the server and must land on what the organiser previewed. + +/** Which goal each position of a score is (rules.ts PlanScore order). */ +export const GOAL_NAMES = [ + "sponsorFinish", + "sponsorIdle", + "finish", + "buildingChanges", + "breaks", +] as const; + +export type AamoghStats = { + /** Lower bounds per goal, in PlanScore order; null where none is known. */ + lowerBounds: (number | null)[]; + /** Goals whose value equals its lower bound, so no schedule does better. */ + proven: boolean[]; + iterations: number; + improvements: number; + /** Values tried by the repair search, summed. */ + nodes: number; +}; + +export type AamoghOptions = { + /** A schedule to start from instead of the sweep; invalid rows are dropped. */ + start?: readonly Placement[]; + /** Neighbourhoods tried per attempt. Defaults by problem size. */ + iterations?: number; +}; + +export type AamoghResult = PlanResult & { stats: AamoghStats }; + +// Unplaced presentations are a sixth goal, ahead of the five public ones, so +// the same search that improves a schedule also completes a partial one. +const UNPLACED = 0; +const SPONSOR_FINISH = 1; +const SPONSOR_IDLE = 2; +const FINISH = 3; +const CHANGES = 4; +const BREAKS = 5; +const GOALS = 6; + +/** + * Backtracking allowed per freed presentation. Small on purpose: many cheap + * neighbourhoods beat a few exhaustive ones, and a failed repair that stops + * early costs almost nothing. + */ +const NODES_PER_TASK = 3; +/** Candidate values kept per variable after ordering. */ +const BRANCH = 3; +/** + * Trials one presentation gets per polishing round. The scan runs from the + * outermost slots in, where the gains are, so past this it is mostly + * confirming that nothing helps. + */ +const TRIALS_PER_TASK = 96; +/** Most presentations one neighbourhood may free. */ +const MAX_FREED = 48; +/** Neighbourhoods without progress before a goal is left as it stands. */ +const PATIENCE = 400; +/** The same for completing a schedule before the completion search tries. */ +const UNPLACED_PATIENCE = 60; +/** Most rounds of re-seating then sponsor moves when settling a schedule. */ +const SETTLE_ROUNDS = 8; +/** + * Building changes and breaks are settled by exact re-seating, which no + * small repair improves on, so the exact repair instead gets a fixed number + * of kicks: re-place a neighbourhood however it fits, settle again, keep the + * result if it is no worse. + */ +const KICKS = 20; +/** + * Cells the completion search may look at per presentation before the + * relaxed retry takes over. Where a complete schedule existed, the + * benchmarks needed under half of this. + */ +const COMPLETE_CELLS = 500; +/** Least number of moves a displaced presentation waits to retake its cell. */ +const TENURE = 10; + +/** Small, fast, seedable; good enough to pick neighbourhoods. */ +function generator(seed: number) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function hashOf(text: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i += 1) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +/** The problem as typed arrays, indexes instead of ids. */ +function compile(inputs: PlanInputs, relaxed: boolean) { + const R = inputs.rooms.length; + const T = inputs.tasks.length; + const S = inputs.slots; + + const challengeIndex = new Map(); + const buildingIndex = new Map(); + const indexOf = (map: Map, key: string) => { + let value = map.get(key); + if (value === undefined) { + value = map.size; + map.set(key, value); + } + return value; + }; + + const roomChallenge = new Int32Array(R); + const roomBuilding = new Int32Array(R); + for (let r = 0; r < R; r += 1) { + roomChallenge[r] = indexOf(challengeIndex, inputs.rooms[r]!.challengeId); + roomBuilding[r] = indexOf(buildingIndex, inputs.rooms[r]!.buildingId); + } + + const entryIndex = new Map(); + const taskChallenge = new Int32Array(T); + const taskEntry = new Int32Array(T); + const taskSponsor = new Uint8Array(T); + for (let t = 0; t < T; t += 1) { + const task = inputs.tasks[t]!; + taskChallenge[t] = indexOf(challengeIndex, task.challengeId); + taskEntry[t] = indexOf(entryIndex, task.entryId); + taskSponsor[t] = task.sponsor ? 1 : 0; + } + const C = challengeIndex.size; + const E = entryIndex.size; + + const entryTasks: number[][] = Array.from({ length: E }, () => []); + for (let t = 0; t < T; t += 1) entryTasks[taskEntry[t]!]!.push(t); + const challengeTasks: number[][] = Array.from({ length: C }, () => []); + for (let t = 0; t < T; t += 1) challengeTasks[taskChallenge[t]!]!.push(t); + + // Symmetry classes: one per (challenge, building), each with its rooms. + const classesOf: number[][][] = Array.from({ length: C }, () => []); + const classKey = new Map(); + for (let r = 0; r < R; r += 1) { + const key = `${roomChallenge[r]}:${roomBuilding[r]}`; + let rooms = classKey.get(key); + if (!rooms) { + rooms = []; + classKey.set(key, rooms); + classesOf[roomChallenge[r]!]!.push(rooms); + } + rooms.push(r); + } + const roomsOf: number[][] = Array.from({ length: C }, () => []); + for (let r = 0; r < R; r += 1) roomsOf[roomChallenge[r]!]!.push(r); + const classOfRoom: number[][] = Array.from( + { length: R }, + (_, r) => classKey.get(`${roomChallenge[r]}:${roomBuilding[r]}`)!, + ); + + return { + R, + T, + S, + C, + E, + sameGap: inputs.sameGap, + crossGap: relaxed ? inputs.sameGap : inputs.crossGap, + roomChallenge, + roomBuilding, + taskChallenge, + taskEntry, + taskSponsor, + entryTasks, + challengeTasks, + classesOf, + classOfRoom, + roomsOf, + buildings: buildingIndex.size, + }; +} + +type Model = ReturnType; + +/** Lower bounds per internal goal; -Infinity where nothing useful is known. */ +function lowerBounds(m: Model): number[] { + const busiest = (sponsorOnly: boolean) => { + const count = new Int32Array(m.C); + for (let t = 0; t < m.T; t += 1) { + if (!sponsorOnly || m.taskSponsor[t]) count[m.taskChallenge[t]!]! += 1; + } + let most = 0; + for (let c = 0; c < m.C; c += 1) { + if (count[c]! > 0) { + most = Math.max(most, Math.ceil(count[c]! / m.roomsOf[c]!.length)); + } + } + return most; + }; + const chain = (n: number) => (n === 0 ? 0 : n + (n - 1) * m.sameGap); + + let sponsorChain = 0; + let longestChain = 0; + let changes = 0; + for (const tasks of m.entryTasks) { + longestChain = Math.max(longestChain, chain(tasks.length)); + sponsorChain = Math.max( + sponsorChain, + chain(tasks.filter((t) => m.taskSponsor[t]).length), + ); + changes += fewestBuildings(m, tasks) - 1; + } + + const sponsorFinish = Math.max(busiest(true), sponsorChain); + return [ + 0, + sponsorFinish, + 0, + Math.max(busiest(false), longestChain, sponsorFinish), + Math.max(0, changes), + -Infinity, + ]; +} + +/** + * The fewest buildings a project's presentations can be spread over: the + * smallest set of buildings that has a room for each of its challenges. A + * project visits each building once at best, so this minus one is the fewest + * building changes it can make. + */ +function fewestBuildings(m: Model, tasks: readonly number[]): number { + if (tasks.length < 2) return 1; + const options = tasks.map((t) => { + let mask = 0; + for (const r of m.roomsOf[m.taskChallenge[t]!]!) mask |= 1 << m.roomBuilding[r]!; + return mask; + }); + // Exact over subsets while buildings are few; otherwise a safe bound of 1. + if (m.buildings > 16) return 1; + let best = Infinity; + for (let set = 1; set < 1 << m.buildings; set += 1) { + if (options.every((mask) => (mask & set) !== 0)) { + let size = 0; + for (let s = set; s; s &= s - 1) size += 1; + best = Math.min(best, size); + if (best === 1) break; + } + } + return best === Infinity ? 1 : best; +} + +/** The working schedule: who is where, both ways round. */ +class Board { + readonly roomOf: Int32Array; + readonly slotOf: Int32Array; + readonly grid: Int32Array; + + constructor(private readonly m: Model) { + this.roomOf = new Int32Array(m.T).fill(-1); + this.slotOf = new Int32Array(m.T).fill(-1); + this.grid = new Int32Array(m.R * m.S).fill(-1); + } + + copyFrom(other: Board) { + this.roomOf.set(other.roomOf); + this.slotOf.set(other.slotOf); + this.grid.set(other.grid); + } + + place(t: number, r: number, s: number) { + this.roomOf[t] = r; + this.slotOf[t] = s; + this.grid[r * this.m.S + s] = t; + } + + lift(t: number) { + const r = this.roomOf[t]!; + if (r < 0) return; + this.grid[r * this.m.S + this.slotOf[t]!] = -1; + this.roomOf[t] = -1; + this.slotOf[t] = -1; + } + + /** Whether `t` may present in room `r` at slot `s`, given everyone placed. */ + fits(t: number, r: number, s: number): boolean { + if (this.grid[r * this.m.S + s] !== -1) return false; + return this.spaced(t, this.m.roomBuilding[r]!, s); + } + + /** + * Whether `t`'s placed project-mates leave it the breaks it needs at slot + * `s` in building `b`. The same for every room there, free or not. + */ + spaced(t: number, b: number, s: number): boolean { + const m = this.m; + for (const u of m.entryTasks[m.taskEntry[t]!]!) { + if (u === t) continue; + const ru = this.roomOf[u]!; + if (ru < 0) continue; + const gap = m.roomBuilding[ru] === b ? m.sameGap : m.crossGap; + const apart = Math.abs(s - this.slotOf[u]!) - 1; + if (apart < gap) return false; + } + return true; + } + + /** The six goals, unplaced first, lower is better. */ + score(out: Float64Array) { + const m = this.m; + let unplaced = 0; + let sponsorFinish = 0; + let finish = 0; + for (let t = 0; t < m.T; t += 1) { + const s = this.slotOf[t]!; + if (s < 0) { + unplaced += 1; + continue; + } + if (s + 1 > finish) finish = s + 1; + if (m.taskSponsor[t] && s + 1 > sponsorFinish) sponsorFinish = s + 1; + } + + let idle = 0; + for (let r = 0; r < m.R; r += 1) { + let first = -1; + let last = -1; + let held = 0; + const row = r * m.S; + for (let s = 0; s < m.S; s += 1) { + const t = this.grid[row + s]!; + if (t < 0 || !m.taskSponsor[t]) continue; + if (first < 0) first = s; + last = s; + held += 1; + } + if (held > 0) idle += last - first + 1 - held; + } + + let changes = 0; + let lessBreaks = 0; + const order: number[] = []; + for (const tasks of m.entryTasks) { + if (tasks.length < 2) continue; + order.length = 0; + for (const t of tasks) if (this.slotOf[t]! >= 0) insertBySlot(order, this.slotOf, t); + for (let i = 1; i < order.length; i += 1) { + const a = order[i - 1]!; + const b = order[i]!; + if (m.roomBuilding[this.roomOf[a]!] !== m.roomBuilding[this.roomOf[b]!]) { + changes += 1; + } + lessBreaks -= this.slotOf[b]! - this.slotOf[a]! - 1; + } + } + + out[UNPLACED] = unplaced; + out[SPONSOR_FINISH] = sponsorFinish; + out[SPONSOR_IDLE] = idle; + out[FINISH] = finish; + out[CHANGES] = changes; + out[BREAKS] = lessBreaks; + } +} + +/** + * Adds presentation t to `list`, kept in slot order. Insertion beats a sort + * call for the handful of presentations one project has. + */ +function insertBySlot(list: number[], slotOf: Int32Array, t: number) { + let i = list.length; + list.push(t); + while (i > 0 && slotOf[list[i - 1]!]! > slotOf[t]!) { + list[i] = list[i - 1]!; + i -= 1; + } + list[i] = t; +} + +function lexCompare(a: Float64Array, b: Float64Array): number { + for (let i = 0; i < GOALS; i += 1) if (a[i] !== b[i]) return a[i]! - b[i]!; + return 0; +} + +/** + * First schedule: walk the grid slot by slot; every free room takes the + * waiting presentation that is hardest to place (most presentations for its + * project, then the one keeping its project in one building), sponsor + * presentations in a first pass so sponsor judging packs at the start. + */ +function sweep(m: Model, board: Board) { + const priority = (t: number) => m.entryTasks[m.taskEntry[t]!]!.length; + for (const sponsorPass of [true, false]) { + const waiting: number[][] = Array.from({ length: m.C }, () => []); + for (let t = 0; t < m.T; t += 1) { + if (board.roomOf[t]! < 0 && !!m.taskSponsor[t] === sponsorPass) { + waiting[m.taskChallenge[t]!]!.push(t); + } + } + for (const list of waiting) list.sort((a, b) => priority(b) - priority(a) || a - b); + + for (let s = 0; s < m.S; s += 1) { + for (let r = 0; r < m.R; r += 1) { + const list = waiting[m.roomChallenge[r]!]!; + if (list.length === 0 || board.grid[r * m.S + s] !== -1) continue; + let pick = -1; + let pickHops = Infinity; + for (let i = 0; i < list.length; i += 1) { + const t = list[i]!; + if (!board.fits(t, r, s)) continue; + const hops = buildingHops(m, board, t, r); + if (hops < pickHops) { + pick = i; + pickHops = hops; + if (hops === 0) break; + } + } + if (pick < 0) continue; + const [t] = list.splice(pick, 1); + board.place(t!, r, s); + } + } + } +} + +/** + * Finishes a schedule the repair could not, by min-conflicts search. A + * waiting presentation takes the cell where it displaces the least: the + * cell's occupant and any project-mates it would now be too close to, each + * counted at its weight. Whoever it displaces waits instead and may not + * retake the cell it lost for a while. Every move adds one to the weight of + * everyone still waiting, so the hardest presentation goes next, the ones + * that are hard to seat become expensive to displace, and the search does + * not circle. Gives up after about `budget` cells looked at, leaving the + * board as it found it. True if everything is placed. + */ +function complete( + m: Model, + board: Board, + random: () => number, + budget: number, + counter: { nodes: number }, +): boolean { + const start = new Board(m); + start.copyFrom(board); + const weight = new Float64Array(m.T).fill(1); + const lostCell = new Int32Array(m.T).fill(-1); + const lostUntil = new Int32Array(m.T); + const waiting: number[] = []; + for (let t = 0; t < m.T; t += 1) if (board.roomOf[t]! < 0) waiting.push(t); + const displaced: number[] = []; + // The project-mates of t too close to slot s in building b, or their + // total weight. + const clashes = (t: number, b: number, s: number, out: number[] | null) => { + let cost = 0; + for (const u of m.entryTasks[m.taskEntry[t]!]!) { + const ru = board.roomOf[u]!; + if (u === t || ru < 0) continue; + const gap = m.roomBuilding[ru] === b ? m.sameGap : m.crossGap; + if (Math.abs(s - board.slotOf[u]!) - 1 >= gap) continue; + cost += weight[u]!; + out?.push(u); + } + return cost; + }; + + let work = 0; + for (let step = 0; waiting.length > 0 && work < budget; step += 1) { + let pick = 0; + for (let i = 1; i < waiting.length; i += 1) { + if (weight[waiting[i]!]! > weight[waiting[pick]!]!) pick = i; + } + const t = waiting[pick]!; + + // The cheapest cell, ties broken at random. + let bestCost = Infinity; + let bestCell = -1; + let ties = 0; + for (const rooms of m.classesOf[m.taskChallenge[t]!]!) { + const b = m.roomBuilding[rooms[0]!]!; + for (let s = 0; s < m.S; s += 1) { + work += 1; + const cost = clashes(t, b, s, null); + if (cost > bestCost) continue; + for (const r of rooms) { + const cell = r * m.S + s; + if (cell === lostCell[t] && step < lostUntil[t]!) continue; + const u = board.grid[cell]!; + const total = u < 0 ? cost : cost + weight[u]!; + if (total < bestCost) { + bestCost = total; + bestCell = cell; + ties = 1; + } else if (total === bestCost && random() * (ties += 1) < 1) { + bestCell = cell; + } + // Free rooms of a class are alike: the first stands for the rest. + if (u < 0) break; + } + } + } + if (bestCell < 0) break; + + const r = Math.floor(bestCell / m.S); + const s = bestCell % m.S; + displaced.length = 0; + if (board.grid[bestCell]! >= 0) displaced.push(board.grid[bestCell]!); + clashes(t, m.roomBuilding[r]!, s, displaced); + waiting[pick] = waiting[waiting.length - 1]!; + waiting.pop(); + for (const u of displaced) { + lostCell[u] = board.roomOf[u]! * m.S + board.slotOf[u]!; + lostUntil[u] = step + TENURE + Math.floor(random() * TENURE); + board.lift(u); + waiting.push(u); + } + board.place(t, r, s); + for (const u of waiting) weight[u] += 1; + } + counter.nodes += work; + if (waiting.length === 0) return true; + board.copyFrom(start); + return false; +} + +/** How many of `t`'s placed project-mates are in another building than `r`. */ +function buildingHops(m: Model, board: Board, t: number, r: number): number { + let hops = 0; + for (const u of m.entryTasks[m.taskEntry[t]!]!) { + if (u === t || board.roomOf[u]! < 0) continue; + if (m.roomBuilding[board.roomOf[u]!] !== m.roomBuilding[r]) hops += 1; + } + return hops; +} + +/** Nearest placed project-mate, in slots; S when there is none. */ +function nearestMate(m: Model, board: Board, t: number, s: number): number { + let nearest = m.S; + for (const u of m.entryTasks[m.taskEntry[t]!]!) { + if (u === t || board.slotOf[u]! < 0) continue; + nearest = Math.min(nearest, Math.abs(s - board.slotOf[u]!)); + } + return nearest; +} + +/** + * The exact part. Places every task in `freed`, each inside the caps, by + * depth-first search: always the task with the fewest values left (and stop + * at once if one has none), trying its best few values in goal order. Gives + * up after the node budget. With `partial`, a task with no value is skipped + * rather than failing the search, for completing an unfinished schedule. + */ +function repair( + m: Model, + board: Board, + freed: number[], + goal: number, + caps: { sponsor: number; all: number }, + partial: boolean, + counter: { nodes: number }, +): boolean { + const budgetEnd = counter.nodes + NODES_PER_TASK * freed.length + 8; + const values = new Int32Array(m.R * m.S * 2); + const keys = new Float64Array(m.R * m.S); + const capOf = (t: number) => + m.taskSponsor[t] ? Math.min(caps.sponsor, caps.all) : caps.all; + + // Every value for t inside the caps, one room per symmetry class and slot. + const enumerate = (t: number): number => { + const cap = capOf(t); + let count = 0; + for (const rooms of m.classesOf[m.taskChallenge[t]!]!) { + for (let s = 0; s < cap; s += 1) { + for (const r of rooms) { + if (board.grid[r * m.S + s] !== -1) continue; + // Rooms in a class differ only by occupancy: the first free one + // stands for all of them, so either it fits or none does. + if (board.fits(t, r, s)) { + values[count * 2] = r; + values[count * 2 + 1] = s; + count += 1; + } + break; + } + } + } + return count; + }; + + // How many values each freed task has, kept current as the search places + // and lifts, so picking the next task is a scan rather than a recount. + // Placing t at (r, s) changes only its project-mates' values (recounted) + // and, when it took the last free room of its class at s, costs each other + // task of its challenge that value. + const n = freed.length; + const waiting = new Uint8Array(n).fill(1); + let left = n; + const sizes = new Int32Array(n); + for (let i = 0; i < n; i += 1) sizes[i] = enumerate(freed[i]!); + const savedSizes = new Int32Array(n * (n + 1)); + let depth = 0; + const update = (t: number, r: number, s: number) => { + const e = m.taskEntry[t]!; + const c = m.taskChallenge[t]!; + const b = m.roomBuilding[r]!; + let full = true; + for (const q of m.classOfRoom[r]!) { + if (board.grid[q * m.S + s] === -1) { + full = false; + break; + } + } + for (let i = 0; i < n; i += 1) { + if (!waiting[i]) continue; + const u = freed[i]!; + if (m.taskEntry[u] === e) sizes[i] = enumerate(u); + else if (full && m.taskChallenge[u] === c && s < capOf(u) && board.spaced(u, b, s)) { + sizes[i] -= 1; + } + } + }; + + const keyOf = (t: number, r: number, s: number) => { + const hops = buildingHops(m, board, t, r); + switch (goal) { + case CHANGES: + return hops * m.S + s; + case BREAKS: + // Furthest from its project-mates, then earliest. + return -nearestMate(m, board, t, s) * m.S + s + hops * m.S * m.S; + default: + return s * 4 + hops; + } + }; + + const search = (): boolean => { + if (left === 0) return true; + if (counter.nodes > budgetEnd) return false; + + // Fail-first: fewest values, counted up to 64 so that big domains tie + // and go in the order they were freed. + let pick = -1; + let pickCount = Infinity; + for (let i = 0; i < n; i += 1) { + if (!waiting[i]) continue; + const count = Math.min(sizes[i]!, 64); + if (count < pickCount) { + pickCount = count; + pick = i; + if (count === 0) break; + } + } + const t = freed[pick]!; + waiting[pick] = 0; + left -= 1; + + if (pickCount === 0) { + if (partial && search()) return true; + waiting[pick] = 1; + left += 1; + return false; + } + + const count = enumerate(t); + for (let i = 0; i < count; i += 1) { + keys[i] = keyOf(t, values[i * 2]!, values[i * 2 + 1]!); + } + // Best few by key, kept in order (count is at most a few hundred). + const chosen: number[] = []; + for (let i = 0; i < count; i += 1) { + let at = chosen.length; + while (at > 0 && keys[chosen[at - 1]!]! > keys[i]!) at -= 1; + if (at < BRANCH) { + chosen.splice(at, 0, i); + if (chosen.length > BRANCH) chosen.pop(); + } + } + const tries = chosen.map((i) => [values[i * 2]!, values[i * 2 + 1]!] as const); + + const at = depth * n; + for (let i = 0; i < n; i += 1) savedSizes[at + i] = sizes[i]!; + depth += 1; + for (const [r, s] of tries) { + counter.nodes += 1; + board.place(t, r, s); + update(t, r, s); + if (search()) return true; + board.lift(t); + for (let i = 0; i < n; i += 1) sizes[i] = savedSizes[at + i]!; + if (counter.nodes > budgetEnd) break; + } + depth -= 1; + waiting[pick] = 1; + left += 1; + return false; + }; + + return search(); +} + +/** Picks what to free so that `goal` has room to improve. */ +function neighbourhood( + m: Model, + board: Board, + goal: number, + score: Float64Array, + random: () => number, +): number[] { + const freed = new Set(); + // Breaks only need small shuffles; a smaller neighbourhood repairs faster. + const most = goal === BREAKS ? MAX_FREED / 2 : MAX_FREED; + const add = (t: number) => { + if (t >= 0 && freed.size < most) freed.add(t); + }; + const withMates = (t: number) => { + add(t); + for (const u of m.entryTasks[m.taskEntry[t]!]!) add(u); + }; + const slotTasks = (s: number, sponsorOnly: boolean) => { + for (let r = 0; r < m.R; r += 1) { + const t = board.grid[r * m.S + s]!; + if (t >= 0 && (!sponsorOnly || m.taskSponsor[t])) withMates(t); + } + }; + const window = (width: number, below: number) => { + const end = Math.max(1, below); + const from = Math.floor(random() * Math.max(1, end - width + 1)); + for (let s = from; s < Math.min(end, from + width); s += 1) slotTasks(s, false); + }; + + switch (goal) { + case UNPLACED: { + for (let t = 0; t < m.T; t += 1) if (board.roomOf[t]! < 0) withMates(t); + window(3, m.S); + break; + } + case SPONSOR_FINISH: + slotTasks(score[SPONSOR_FINISH]! - 1, true); + window(2 + Math.floor(random() * 3), score[SPONSOR_FINISH]! - 1); + break; + case FINISH: + slotTasks(score[FINISH]! - 1, false); + window(2 + Math.floor(random() * 3), score[FINISH]! - 1); + break; + case SPONSOR_IDLE: { + // A sponsor room with a hole: everything in its busy span. + const holed: number[] = []; + for (let r = 0; r < m.R; r += 1) { + let first = -1; + let last = -1; + let held = 0; + for (let s = 0; s < m.S; s += 1) { + const t = board.grid[r * m.S + s]!; + if (t < 0 || !m.taskSponsor[t]) continue; + if (first < 0) first = s; + last = s; + held += 1; + } + if (held > 0 && last - first + 1 > held) holed.push(r); + } + if (holed.length === 0) break; + const r = holed[Math.floor(random() * holed.length)]!; + for (let s = 0; s < m.S; s += 1) { + const t = board.grid[r * m.S + s]!; + if (t >= 0 && m.taskSponsor[t]) withMates(t); + } + break; + } + case CHANGES: + case BREAKS: { + const candidates: number[] = []; + for (let e = 0; e < m.E; e += 1) { + const tasks = m.entryTasks[e]!; + if (tasks.length < 2) continue; + if (goal === BREAKS) { + candidates.push(e); + continue; + } + const building = m.roomBuilding[board.roomOf[tasks[0]!]!]; + if (tasks.some((t) => m.roomBuilding[board.roomOf[t]!] !== building)) { + candidates.push(e); + } + } + if (goal === BREAKS) { + // The projects with the least breathing room have the most to gain: + // draw from the tightest fifth. + const spread = (e: number) => { + let first = m.S; + let last = -1; + for (const t of m.entryTasks[e]!) { + first = Math.min(first, board.slotOf[t]!); + last = Math.max(last, board.slotOf[t]!); + } + return last - first - m.entryTasks[e]!.length; + }; + const tight = candidates + .map((e) => [spread(e), e] as const) + .sort((a, b) => a[0] - b[0] || a[1] - b[1]); + candidates.length = 0; + const pool = Math.max(4, Math.ceil(tight.length / 5)); + for (let i = 0; i < Math.min(pool, tight.length); i += 1) candidates.push(tight[i]![1]); + } + for (let k = 0; k < 4 && candidates.length > 0; k += 1) { + const e = candidates[Math.floor(random() * candidates.length)]!; + for (const t of m.entryTasks[e]!) withMates(t); + } + window(2 + Math.floor(random() * 2), score[FINISH]!); + break; + } + } + return [...freed]; +} + +/** + * Building changes and breaks are per project, and a project's breaks are + * just (last slot - first slot) - (presentations - 1): only its ends matter. + * So trading two presentations of one challenge, or moving a general one to + * a free cell, changes two projects at most, and its effect costs a handful + * of operations to measure instead of a full rescore. Trades leave every + * room's occupancy as it was, so sponsor finish, sponsor idle and the finish + * cannot move; moves stay inside the current finish. + * + * Works through a queue of every project; a project that changes goes back + * on the queue along with its trade partner, so later rounds touch only + * what moved. Fewer changes first, then more breaks. Returns the tries + * spent, at most `budget`. + */ +function polish( + m: Model, + board: Board, + finish: number, + budget: number, +): number { + // Lower is better. One building change outweighs any difference in spread + // two projects can make between them. + const weight = 2 * m.S + 1; + const visits: number[] = []; + const changesOf = new Int32Array(m.E); + const cost = (e: number) => { + visits.length = 0; + for (const t of m.entryTasks[e]!) { + if (board.slotOf[t]! >= 0) insertBySlot(visits, board.slotOf, t); + } + if (visits.length < 2) return 0; + let changes = 0; + for (let i = 1; i < visits.length; i += 1) { + const before = m.roomBuilding[board.roomOf[visits[i - 1]!]!]; + if (before !== m.roomBuilding[board.roomOf[visits[i]!]!]) changes += 1; + } + changesOf[e] = changes; + const spread = board.slotOf[visits[visits.length - 1]!]! - board.slotOf[visits[0]!]!; + return changes * weight - spread; + }; + + const cached = new Float64Array(m.E); + const queued = new Uint8Array(m.E); + const queue: number[] = []; + const enqueue = (e: number) => { + if (queued[e] || m.entryTasks[e]!.length < 2) return; + queued[e] = 1; + queue.push(e); + }; + for (let e = 0; e < m.E; e += 1) { + cached[e] = cost(e); + enqueue(e); + } + + // A project already at its own fewest possible building changes can only + // gain breaks, and breaks only grow by moving outside its current span. + const leastChanges = m.entryTasks.map((tasks) => fewestBuildings(m, tasks) - 1); + + // Tries trading `t` with whoever sits in (r, s), or moving it there if the + // cell is free (general presentations only: sponsor rooms keep their + // occupancy so sponsor idle cannot move). Keeps the change if the two + // projects' cost drops; true if it did. + const attempt = (t: number, e: number, r: number, s: number): boolean => { + const u = board.grid[r * m.S + s]!; + if (u === t) return false; + const r1 = board.roomOf[t]!; + const s1 = board.slotOf[t]!; + // The trial costs overwrite the cached change counts; a refused change + // puts them back. + const changesE = changesOf[e]!; + if (u < 0) { + if (m.taskSponsor[t]) return false; + tries += 1; + board.lift(t); + if (board.fits(t, r, s)) { + board.place(t, r, s); + const ce = cost(e); + if (ce < cached[e]!) { + cached[e] = ce; + return true; + } + board.lift(t); + } + board.place(t, r1, s1); + changesOf[e] = changesE; + return false; + } + const f = m.taskEntry[u]!; + if (f === e) return false; + tries += 1; + const changesF = changesOf[f]!; + const before = cached[e]! + cached[f]!; + board.lift(t); + board.lift(u); + if (board.fits(t, r, s)) { + board.place(t, r, s); + if (board.fits(u, r1, s1)) { + board.place(u, r1, s1); + const ce = cost(e); + const cf = cost(f); + if (ce + cf < before) { + cached[e] = ce; + cached[f] = cf; + enqueue(f); + return true; + } + board.lift(u); + } + board.lift(t); + } + board.place(t, r1, s1); + board.place(u, r, s); + changesOf[e] = changesE; + changesOf[f] = changesF; + return false; + }; + + let tries = 0; + for (let head = 0; head < queue.length && tries < budget; head += 1) { + const e = queue[head]!; + queued[e] = 0; + let first = m.S; + let last = -1; + for (const v of m.entryTasks[e]!) { + first = Math.min(first, board.slotOf[v]!); + last = Math.max(last, board.slotOf[v]!); + } + const tangled = changesOf[e]! > leastChanges[e]!; + + let moved = false; + for (const t of m.entryTasks[e]!) { + const classes = m.classesOf[m.taskChallenge[t]!]!; + const stop = tries + TRIALS_PER_TASK; + if (tangled) { + // Every cell of the challenge: untangling may need a move inward. + for (let s = 0; s < finish && !moved && tries < stop; s += 1) { + for (const rooms of classes) { + // The same for every room of a class: one check rules it out. + if (!board.spaced(t, m.roomBuilding[rooms[0]!]!, s)) continue; + for (const r of rooms) { + if (attempt(t, e, r, s)) { + moved = true; + break; + } + } + if (moved) break; + } + } + } else { + // Outside the span only, furthest first: the biggest gains come + // first and the first one that helps is taken. + for (let k = 0; k < finish && !moved && tries < stop; k += 1) { + const s = k % 2 === 0 ? k >> 1 : finish - 1 - (k >> 1); + if (s >= first && s <= last) continue; + for (const rooms of classes) { + if (!board.spaced(t, m.roomBuilding[rooms[0]!]!, s)) continue; + for (const r of rooms) { + if (attempt(t, e, r, s)) { + moved = true; + break; + } + } + if (moved) break; + } + } + } + if (moved) break; + } + if (moved) enqueue(e); + } + return tries; +} + +/** + * Prices one presentation at many cells in a row, for the moves below: its + * project-mates are gathered once, in slot order, and each price is then a + * short scan. A price is the project's building changes times `weight`, + * minus how far apart its first and last presentations are, so lower is + * better and, with a weight above any spread the moves at hand can gain, + * changes come first and breaks second; Infinity where a mate is too close. + * Every presentation of the project must be placed. + */ +class Mates { + private readonly slots: number[] = []; + private readonly halls: number[] = []; + private count = 0; + private changes = 0; + + constructor( + private readonly m: Model, + private readonly board: Board, + private readonly weight: number, + ) {} + + /** Gathers the project-mates of presentation t. */ + of(t: number) { + const { m, board, slots, halls } = this; + let n = 0; + for (const u of m.entryTasks[m.taskEntry[t]!]!) { + if (u === t) continue; + const s = board.slotOf[u]!; + const hall = m.roomBuilding[board.roomOf[u]!]!; + let j = n - 1; + while (j >= 0 && slots[j]! > s) { + slots[j + 1] = slots[j]!; + halls[j + 1] = halls[j]!; + j -= 1; + } + slots[j + 1] = s; + halls[j + 1] = hall; + n += 1; + } + let changes = 0; + for (let i = 1; i < n; i += 1) if (halls[i] !== halls[i - 1]) changes += 1; + this.count = n; + this.changes = changes; + } + + /** The project's price with the presentation at slot s in building b. */ + price(b: number, s: number): number { + const { m, slots, halls, count: n } = this; + if (n === 0) return 0; + let at = 0; + for (let i = 0; i < n; i += 1) { + const gap = Math.abs(s - slots[i]!) - 1; + if (gap < (halls[i] === b ? m.sameGap : m.crossGap)) return Infinity; + if (slots[i]! < s) at = i + 1; + } + let changes = this.changes; + if (at > 0 && at < n && halls[at - 1] !== halls[at]) changes -= 1; + if (at > 0 && halls[at - 1] !== b) changes += 1; + if (at < n && halls[at] !== b) changes += 1; + return changes * this.weight - (Math.max(slots[n - 1]!, s) - Math.min(slots[0]!, s)); + } +} + +/** + * Sponsor idle is a room-by-room matter: each sponsor room's presentations + * should run unbroken. This moves sponsor presentations to free cells of + * their challenge before the sponsor finish, each to the cell that cuts the + * most idle, then the most building changes, then adds the most breaks, + * round after round until no move helps or `budget` trials are spent. A move + * touches two rooms and one project, so a trial costs two row scans and one + * short itinerary. While the finish is still open (`settled` false), only + * moves that cut idle are made: spreading sponsor presentations out for + * breaks can leave the general ones no room to finish earlier. Returns the + * trials spent. + */ +function compact( + m: Model, + board: Board, + sponsorFinish: number, + settled: boolean, + budget: number, +): number { + // Each room's sponsor presentations: first and last slot, and how many. + const firstOf = new Int32Array(m.R); + const lastOf = new Int32Array(m.R); + const heldOf = new Int32Array(m.R); + const sponsorAt = (r: number, s: number) => { + const t = board.grid[r * m.S + s]!; + return t >= 0 && m.taskSponsor[t] === 1; + }; + const measure = (r: number) => { + firstOf[r] = -1; + lastOf[r] = -1; + heldOf[r] = 0; + for (let s = 0; s < m.S; s += 1) { + if (!sponsorAt(r, s)) continue; + if (firstOf[r]! < 0) firstOf[r] = s; + lastOf[r] = s; + heldOf[r] += 1; + } + }; + for (let r = 0; r < m.R; r += 1) measure(r); + const idleOf = (first: number, last: number, held: number) => + held > 0 ? last - first + 1 - held : 0; + // The same with one more presentation at slot s. + const idleWith = (first: number, last: number, held: number, s: number) => + held > 0 ? Math.max(last, s) - Math.min(first, s) - held : 0; + const mates = new Mates(m, board, 2 * m.S + 1); + + let tries = 0; + let moved = true; + while (moved && tries < budget) { + moved = false; + for (let x = 0; x < m.T && tries < budget; x += 1) { + if (!m.taskSponsor[x]) continue; + const r0 = board.roomOf[x]!; + const s0 = board.slotOf[x]!; + const idle0 = idleOf(firstOf[r0]!, lastOf[r0]!, heldOf[r0]!); + // Room r0 without x. + let first0 = firstOf[r0]!; + let last0 = lastOf[r0]!; + const held0 = heldOf[r0]! - 1; + if (held0 > 0 && s0 === first0) while (!sponsorAt(r0, ++first0)); + if (held0 > 0 && s0 === last0) while (!sponsorAt(r0, --last0)); + const idleLeft = idleOf(first0, last0, held0) - idle0; + mates.of(x); + const cost0 = mates.price(m.roomBuilding[r0]!, s0); + let bestIdle = 0; + let bestCost = 0; + let bestRoom = -1; + let bestSlot = -1; + for (const r of m.roomsOf[m.taskChallenge[x]!]!) { + const b = m.roomBuilding[r]!; + const idleHere = idleOf(firstOf[r]!, lastOf[r]!, heldOf[r]!); + for (let s = 0; s < sponsorFinish; s += 1) { + if (board.grid[r * m.S + s] !== -1) continue; + const price = mates.price(b, s); + if (price === Infinity) continue; + tries += 1; + const idle = + r === r0 + ? idleWith(first0, last0, held0, s) - idle0 + : idleLeft + idleWith(firstOf[r]!, lastOf[r]!, heldOf[r]!, s) - idleHere; + if (idle > bestIdle) continue; + const cost = price - cost0; + if (idle < bestIdle || (cost < bestCost && (idle < 0 || settled))) { + bestIdle = idle; + bestCost = cost; + bestRoom = r; + bestSlot = s; + } + } + } + if (bestRoom < 0) continue; + board.lift(x); + board.place(x, bestRoom, bestSlot); + measure(r0); + measure(bestRoom); + moved = true; + } + + // A hole no single move fills may still close by sliding everything on + // one side of it a slot towards it. Each presentation that slides needs + // only its own project's breaks one slot over, and the room's span + // shrinks by one either way, so the cheapest slide in the room wins. + for (let r = 0; r < m.R && tries < budget; r += 1) { + const row = r * m.S; + const b = m.roomBuilding[r]!; + let first = -1; + let last = -1; + for (let s = 0; s < m.S; s += 1) { + const t = board.grid[row + s]!; + if (t < 0) continue; + if (!m.taskSponsor[t]) { + first = -1; + break; + } + if (first < 0) first = s; + last = s; + } + if (first < 0) continue; + let bestCost = Infinity; + let bestFrom = -1; + let bestTo = -1; + let bestStep = 0; + for (let h = first + 1; h < last; h += 1) { + if (board.grid[row + h] !== -1) continue; + for (const step of [-1, 1]) { + const from = step < 0 ? h + 1 : first; + const to = step < 0 ? last : h - 1; + let cost = 0; + for (let s = from; s <= to && cost < bestCost; s += 1) { + const t = board.grid[row + s]!; + if (t < 0) continue; + tries += 1; + mates.of(t); + cost += mates.price(b, s + step) - mates.price(b, s); + } + if (cost < bestCost) { + bestCost = cost; + bestFrom = from; + bestTo = to; + bestStep = step; + } + } + } + if (bestStep === 0) continue; + const sliding: number[] = []; + for (let s = bestFrom; s <= bestTo; s += 1) { + if (board.grid[row + s]! >= 0) sliding.push(board.grid[row + s]!, s); + } + for (let i = 0; i < sliding.length; i += 2) board.lift(sliding[i]!); + for (let i = 0; i < sliding.length; i += 2) { + board.place(sliding[i]!, r, sliding[i + 1]! + bestStep); + } + measure(r); + moved = true; + } + } + return tries; +} + +/** + * Within one challenge every presentation belongs to a different project, + * so with the other challenges held still each presentation has a fixed + * cost in each cell, and re-seating the whole challenge is an assignment + * problem. This solves it exactly, one challenge at a time, by cancelling + * negative cycles over (class, slot) cells: one presentation moves to + * another cell, the one there moves on, and so on back to the start or + * into a free seat, whenever together they make fewer building changes or + * more breaks. Sponsor challenges keep the cells they hold, so sponsor idle + * and the sponsor finish stay as they are; general presentations may take + * any free cell before the finish. Rounds repeat while any challenge + * improves, up to `budget` units of work. Returns the work spent. + */ +function reassign( + m: Model, + board: Board, + finish: number, + budget: number, + seated: { board: Board; finish: number }, +): number { + // A single move shifts at most one project's spread, by less than S, so + // one building change outweighs any cycle's worth of spread. + const mates = new Mates(m, board, m.S * m.T + 1); + // `seated` is the last schedule on which no challenge could improve. A + // challenge's problem is its own cells and its projects' other + // presentations, so only challenges with a project that has moved since + // need solving again, and each move made here dirties its project's. + const dirty = new Uint8Array(m.C); + const touch = (t: number) => { + for (const u of m.entryTasks[m.taskEntry[t]!]!) dirty[m.taskChallenge[u]!] = 1; + }; + if (seated.finish !== finish) dirty.fill(1); + else { + for (let t = 0; t < m.T; t += 1) { + if (board.roomOf[t] !== seated.board.roomOf[t] || board.slotOf[t] !== seated.board.slotOf[t]) { + touch(t); + } + } + } + // Round and round the challenges, so each gets a turn before any second. + const nextDirty = (from: number) => { + for (let i = 0; i < m.C; i += 1) if (dirty[(from + i) % m.C]) return (from + i) % m.C; + return -1; + }; + let work = 0; + for (let c = nextDirty(0); c >= 0 && work < budget; c = nextDirty(c + 1)) { + const classes = m.classesOf[c]!; + const fixed = m.challengeTasks[c]!.some((t) => m.taskSponsor[t]); + // Cell (k, s) is node k * finish + s; node K is the pool of free seats. + const K = classes.length * finish; + const N = K + 1; + const cap = new Int32Array(K); + const load = new Int32Array(K); + const nodeHall = new Int32Array(K); + const nodeSlot = new Int32Array(K); + for (let u = 0; u < K; u += 1) { + for (const r of classes[Math.floor(u / finish)]!) { + if (board.grid[r * m.S + (u % finish)]! >= 0) load[u] += 1; + } + cap[u] = fixed ? load[u]! : classes[Math.floor(u / finish)]!.length; + nodeHall[u] = m.roomBuilding[classes[Math.floor(u / finish)]![0]!]!; + nodeSlot[u] = u % finish; + } + + // weights[u * N + v]: the cheapest move of one presentation from u to + // v, and moves[u * N + v] which one it is. + const weights = new Float64Array(N * N); + const moves = new Int32Array(N * N); + const edgesFrom = (u: number) => { + weights.fill(Infinity, u * N, u * N + N); + if (load[u]! < cap[u]!) weights[u * N + K] = 0; + weights[K * N + u] = load[u]! > 0 ? 0 : Infinity; + const s = u % finish; + for (const r of classes[Math.floor(u / finish)]!) { + const t = board.grid[r * m.S + s]!; + if (t < 0) continue; + mates.of(t); + const here = mates.price(m.roomBuilding[r]!, s); + for (let v = 0; v < K; v += 1) { + if (v === u || cap[v] === 0) continue; + work += 1; + const delta = mates.price(nodeHall[v]!, nodeSlot[v]!) - here; + if (delta < weights[u * N + v]!) { + weights[u * N + v] = delta; + moves[u * N + v] = t; + } + } + } + }; + weights.fill(Infinity, K * N, N * N); + for (let u = 0; u < K; u += 1) edgesFrom(u); + + const dist = new Float64Array(N); + const pred = new Int32Array(N); + const mark = new Int32Array(N); + const queue = new Int32Array(N); + const inQueue = new Uint8Array(N); + const cycle: number[] = []; + const vacated: number[] = []; + let solved = false; + while (work < budget) { + // Bellman-Ford from every node at once, queue-driven so that only + // nodes whose distance fell are rescanned. Any cycle among the + // predecessors is a negative one, so they are checked every few scans + // (a check costs about one scan); an empty queue means there is none. + dist.fill(0); + pred.fill(-1); + let head = 0; + let queued = N; + for (let u = 0; u < N; u += 1) { + queue[u] = u; + inQueue[u] = 1; + } + let x = -1; + for (let scans = 1; queued > 0 && x < 0; scans += 1) { + const u = queue[head]!; + head = head + 1 === N ? 0 : head + 1; + queued -= 1; + inQueue[u] = 0; + const du = dist[u]!; + const row = u * N; + for (let v = 0; v < N; v += 1) { + const w = weights[row + v]!; + if (du + w < dist[v]!) { + dist[v] = du + w; + pred[v] = u; + if (!inQueue[v]) { + queue[(head + queued) % N] = v; + inQueue[v] = 1; + queued += 1; + } + } + } + work += N; + if (scans % 8 !== 0) continue; + mark.fill(-1); + for (let start = 0; start < N && x < 0; start += 1) { + let y = start; + while (y >= 0 && mark[y] === -1) { + mark[y] = start; + y = pred[y]!; + } + if (y >= 0 && mark[y] === start) x = y; + } + } + if (x < 0) { + solved = true; + break; + } + cycle.length = 0; + let y = x; + do { + cycle.push(y); + y = pred[y]!; + } while (y !== x); + + // Lift everyone moving, then seat each in a cell its predecessor + // left (a sponsor room keeps its occupancy) or else a free one. + const moving: number[] = []; + for (const v of cycle) { + const u = pred[v]!; + if (u === K || v === K) continue; + const t = moves[u * N + v]!; + moving.push(t, v); + load[u] -= 1; + load[v] += 1; + } + vacated.length = 0; + for (let i = 0; i < moving.length; i += 2) { + const t = moving[i]!; + vacated.push(board.roomOf[t]! * m.S + board.slotOf[t]!); + board.lift(t); + } + for (let i = 0; i < moving.length; i += 2) { + const v = moving[i + 1]!; + const s = v % finish; + const rooms = classes[Math.floor(v / finish)]!; + let room = -1; + for (let j = 0; j < rooms.length && room < 0; j += 1) { + const cell = rooms[j]! * m.S + s; + if (vacated.includes(cell) && board.grid[cell] === -1) room = rooms[j]!; + } + for (let j = 0; j < rooms.length && room < 0; j += 1) { + if (board.grid[rooms[j]! * m.S + s] === -1) room = rooms[j]!; + } + board.place(moving[i]!, room, s); + } + for (let i = 0; i < moving.length; i += 2) touch(moving[i]!); + for (const v of cycle) if (v !== K) edgesFrom(v); + } + if (solved) dirty[c] = 0; + } + if (dirty.indexOf(1) < 0) { + seated.board.copyFrom(board); + seated.finish = finish; + } + return work; +} + +const patienceFor = (goal: number) => + goal === UNPLACED ? UNPLACED_PATIENCE : PATIENCE; + +function improve( + m: Model, + board: Board, + bounds: number[], + iterations: number, + seed: number, + stats: { iterations: number; improvements: number; nodes: number }, +) { + const random = generator(seed); + const best = new Board(m); + best.copyFrom(board); + const current = new Float64Array(GOALS); + const bestScore = new Float64Array(GOALS); + const trial = new Float64Array(GOALS); + const next = new Float64Array(GOALS); + board.score(current); + bestScore.set(current); + + const saved = new Board(m); + const stalled = new Int32Array(GOALS); + const counter = { nodes: 0 }; + // Polishing tries per presentation; trades are cheap, so this is generous. + const polishBudget = 400 * m.T; + // Re-seating work per round: a few hundred cost checks per presentation + // covers the cells of every class it might move to, many times over. + const reassignBudget = 2000 * m.T; + let compacted = false; + let polished = false; + let kicks = 0; + const seated = { board: new Board(m), finish: -1 }; + + // Keeps the board if it scores no worse than `current`, otherwise puts + // `saved` back. True if it scored better. + const keepIfNoWorse = (): boolean => { + board.score(trial); + const order = lexCompare(trial, current); + if (order > 0) { + board.copyFrom(saved); + return false; + } + current.set(trial); + if (lexCompare(current, bestScore) < 0) { + bestScore.set(current); + best.copyFrom(board); + stats.improvements += 1; + } + return order < 0; + }; + + // Sponsor moves, then exact re-seating and sponsor moves in turn for as + // long as the pair helps. + const settle = () => { + stats.nodes += compact(m, board, current[SPONSOR_FINISH]!, true, polishBudget); + board.score(trial); + for (let round = 0; round < SETTLE_ROUNDS; round += 1) { + stats.nodes += reassign(m, board, current[FINISH]!, reassignBudget, seated); + stats.nodes += compact(m, board, current[SPONSOR_FINISH]!, true, polishBudget); + board.score(next); + if (lexCompare(next, trial) >= 0) break; + trial.set(next); + } + }; + + for (let i = 0; i < iterations; i += 1) { + let goal = -1; + for (let g = 0; g < GOALS; g += 1) { + if (current[g]! > bounds[g]! && stalled[g]! < patienceFor(g)) { + goal = g; + break; + } + } + // Every goal at its bound or out of ideas. An unfinished schedule never + // gets here: unplaced presentations make everything else meaningless. + if (goal < 0 || (goal > UNPLACED && current[UNPLACED]! > 0)) break; + + // Once the sponsor finish has settled, sponsor idle gets single moves + // before any exact repair. + if (goal >= SPONSOR_IDLE && !compacted) { + compacted = true; + saved.copyFrom(board); + const settled = goal > FINISH || current[FINISH]! <= bounds[FINISH]!; + stats.nodes += compact(m, board, current[SPONSOR_FINISH]!, settled, polishBudget); + keepIfNoWorse(); + continue; + } + + // Changes and breaks go to the cheap trades first, then to settling. + if (goal >= CHANGES && !polished) { + polished = true; + saved.copyFrom(board); + stats.nodes += polish(m, board, current[FINISH]!, polishBudget); + settle(); + keepIfNoWorse(); + continue; + } + // After that each round is a kick: the exact repair re-places a + // neighbourhood any way it can inside the finishes, whatever that does + // to changes and breaks, and settling from there is kept if it lands + // no worse than before. + if (goal >= CHANGES && (kicks += 1) > KICKS) break; + stats.iterations += 1; + + const freed = neighbourhood(m, board, goal, current, random); + if (freed.length === 0) { + stalled[goal] = PATIENCE; + continue; + } + + saved.copyFrom(board); + for (const t of freed) board.lift(t); + const caps = { + sponsor: + goal === SPONSOR_FINISH ? current[SPONSOR_FINISH]! - 1 : current[SPONSOR_FINISH]! || m.S, + all: goal === FINISH ? current[FINISH]! - 1 : current[UNPLACED]! > 0 ? m.S : current[FINISH]!, + }; + if (goal === UNPLACED) caps.sponsor = m.S; + const done = repair(m, board, freed, goal, caps, goal === UNPLACED, counter); + if (!done) { + board.copyFrom(saved); + stalled[goal] += 1; + continue; + } + if (goal >= CHANGES) settle(); + if (keepIfNoWorse()) stalled.fill(0, goal); + else stalled[goal] += 1; + } + + stats.nodes += counter.nodes; + board.copyFrom(best); + // A last polish of the best schedule, which the kicks may have left with + // trades still open. + if (bestScore[UNPLACED] === 0 && polished) { + stats.nodes += polish(m, board, bestScore[FINISH]!, polishBudget); + board.score(trial); + if (lexCompare(trial, bestScore) < 0) bestScore.set(trial); + else board.copyFrom(best); + } + return bestScore; +} + +function placementsOf(m: Model, inputs: PlanInputs, board: Board): Placement[] { + const out: Placement[] = []; + for (let t = 0; t < m.T; t += 1) { + if (board.roomOf[t]! < 0) continue; + const task = inputs.tasks[t]!; + out.push({ + entryId: task.entryId, + challengeId: task.challengeId, + roomId: inputs.rooms[board.roomOf[t]!]!.id, + slot: board.slotOf[t]!, + }); + } + return out; +} + +function seedFrom(m: Model, inputs: PlanInputs, board: Board, start: readonly Placement[]) { + const roomIndex = new Map(inputs.rooms.map((room, i) => [room.id, i])); + const taskIndex = new Map( + inputs.tasks.map((task, i) => [taskKey(task.entryId, task.challengeId), i]), + ); + for (const p of start) { + const t = taskIndex.get(taskKey(p.entryId, p.challengeId)); + const r = roomIndex.get(p.roomId); + if (t === undefined || r === undefined || board.roomOf[t]! >= 0) continue; + if (m.roomChallenge[r] !== m.taskChallenge[t]) continue; + if (!Number.isInteger(p.slot) || p.slot < 0 || p.slot >= m.S) continue; + if (board.fits(t, r, p.slot)) board.place(t, r, p.slot); + } +} + +/** + * Builds a schedule: full cross-building breaks first, and when no complete + * schedule is found that way, the same-building break everywhere, flagged as + * relaxed. Every schedule returned has passed `validateSchedule`. Same shape + * as `solvePlan`, plus what the bounds proved. + */ +export function aamoghCPSolver( + inputs: PlanInputs, + options: AamoghOptions = {}, +): AamoghResult { + const stats = { iterations: 0, improvements: 0, nodes: 0 }; + const report = (bounds: number[], score: Float64Array | null): AamoghStats => ({ + ...stats, + lowerBounds: bounds.slice(1).map((b) => (Number.isFinite(b) ? b : null)), + proven: bounds.slice(1).map((b, g) => score !== null && score[g + 1] === b), + }); + + const capacity = capacityProblems(inputs); + if (capacity.length > 0) { + return { + ok: false, + diagnostics: capacity, + stats: report(lowerBounds(compile(inputs, false)), null), + }; + } + + const seed = hashOf( + inputs.tasks.map((t) => taskKey(t.entryId, t.challengeId)).join("|") + + `#${inputs.slots}:${inputs.sameGap}:${inputs.crossGap}`, + ); + const iterations = + options.iterations ?? Math.min(20_000, 2_000 + inputs.tasks.length * 12); + + let unplaced: PlanDiagnostic[] = []; + let bounds: number[] = []; + for (const relaxed of [false, true]) { + if (relaxed && inputs.crossGap === inputs.sameGap) break; + const m = compile(inputs, relaxed); + bounds = lowerBounds(m); + const board = new Board(m); + if (options.start) seedFrom(m, inputs, board, options.start); + sweep(m, board); + let score = improve(m, board, bounds, iterations, seed, stats); + if (score[UNPLACED]! > 0 && complete(m, board, generator(seed), COMPLETE_CELLS * m.T, stats)) { + score = improve(m, board, bounds, iterations, seed, stats); + } + + if (score[UNPLACED]! > 0) { + unplaced = []; + for (let t = 0; t < m.T; t += 1) { + if (board.roomOf[t]! >= 0) continue; + unplaced.push({ + kind: "unplaced", + entryId: inputs.tasks[t]!.entryId, + challengeId: inputs.tasks[t]!.challengeId, + }); + } + continue; + } + + const placements = placementsOf(m, inputs, board); + const check = validateSchedule(inputs, placements, { relaxed }); + if (!check.valid) { + throw new Error( + `aamoghCPSolver built a schedule that breaks its own rules: ${check.errors.join(" ")}`, + ); + } + const publicScore: PlanScore = scoreSchedule(inputs, placements); + const mine: PlanScore = [score[1]!, score[2]!, score[3]!, score[4]!, score[5]!]; + if (compareScores(publicScore, mine) !== 0) { + throw new Error("aamoghCPSolver scored its schedule differently from scoreSchedule."); + } + return { + ok: true, + relaxed: check.reducedBreaks.length > 0, + placements, + score: publicScore, + reducedBreaks: check.reducedBreaks, + stats: report(bounds, score), + }; + } + + return { ok: false, diagnostics: unplaced, stats: report(bounds, null) }; +} diff --git a/packages/api/src/services/judging/schedule/board.test.ts b/packages/api/src/services/judging/schedule/board.test.ts new file mode 100644 index 00000000..4020ee2e --- /dev/null +++ b/packages/api/src/services/judging/schedule/board.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { staffedRoomIds, withStatus } from "./board"; +import type { BoardEvaluation, BoardPresence } from "./board"; + +const at = (time: string) => new Date(`2027-02-08T${time}:00Z`); + +const appointment = { + id: "apt", + entryId: "a", + challengeId: "mlh", + roomId: "r1", + startsAt: at("15:00"), + endsAt: at("15:10"), +}; + +const presence = (over: Partial): BoardPresence => ({ + judgeId: "j1", + roomId: "r1", + joinedAt: at("14:00"), + lastSeenAt: at("15:05"), + leftAt: null, + ...over, +}); + +const evaluation = (over: Partial): BoardEvaluation => ({ + judgeId: "j1", + entryId: "a", + challengeId: "mlh", + appointmentId: null, + ratingsComplete: true, + ...over, +}); + +const roots: Record = { mlh: "mlh", "mlh-ai": "mlh", general: "general" }; +const rootIdOf = (id: string) => roots[id] ?? null; + +describe("staffedRoomIds", () => { + it("counts judges still in a room and heard from in the last 15 minutes", () => { + const now = at("15:20"); + expect([ + ...staffedRoomIds( + [ + presence({ roomId: "r1", lastSeenAt: at("15:05") }), + presence({ roomId: "r2", lastSeenAt: at("15:04") }), + presence({ roomId: "r3", lastSeenAt: at("15:19"), leftAt: at("15:19") }), + ], + now, + ), + ]).toEqual(["r1"]); + }); +}); + +describe("withStatus", () => { + it("follows the clock with no evaluations, and allows moving future and missed ones", () => { + const [before] = withStatus([appointment], [], [], rootIdOf, at("14:59")); + expect(before).toMatchObject({ status: "future", canMove: true, evaluationCount: 0 }); + const [during] = withStatus([appointment], [], [], rootIdOf, at("15:05")); + expect(during).toMatchObject({ status: "pending", canMove: false }); + const [after] = withStatus([appointment], [], [], rootIdOf, at("15:30")); + expect(after).toMatchObject({ status: "missed", canMove: true }); + }); + + it("matches evaluations by appointment, or by project and challenge root", () => { + const evaluations = [ + evaluation({ judgeId: "j1", challengeId: "mlh-ai" }), + evaluation({ judgeId: "j2", appointmentId: "apt", challengeId: "mlh" }), + evaluation({ judgeId: "j3", appointmentId: "other" }), + evaluation({ judgeId: "j4", challengeId: "general" }), + evaluation({ judgeId: "j5", entryId: "b" }), + ]; + const [row] = withStatus([appointment], evaluations, [], rootIdOf, at("15:30")); + expect(row).toMatchObject({ evaluationCount: 2, status: "complete", canMove: false }); + }); + + it("is incomplete while a judge who was in the room has not submitted", () => { + const [row] = withStatus( + [appointment], + [evaluation({ judgeId: "j1" })], + [ + presence({ judgeId: "j1" }), + presence({ judgeId: "j2" }), + // Left before the slot: not expected to judge it. + presence({ judgeId: "j3", leftAt: at("14:30"), lastSeenAt: at("14:30") }), + // Another room. + presence({ judgeId: "j4", roomId: "r2" }), + // Arrived after it ended. + presence({ judgeId: "j5", joinedAt: at("15:10") }), + ], + rootIdOf, + at("15:30"), + ); + expect(row!.status).toBe("incomplete"); + + const [done] = withStatus( + [appointment], + [evaluation({ judgeId: "j1" }), evaluation({ judgeId: "j2" })], + [presence({ judgeId: "j1" }), presence({ judgeId: "j2" })], + rootIdOf, + at("15:30"), + ); + expect(done!.status).toBe("complete"); + }); +}); diff --git a/packages/api/src/services/judging/schedule/board.ts b/packages/api/src/services/judging/schedule/board.ts new file mode 100644 index 00000000..dee5cd8b --- /dev/null +++ b/packages/api/src/services/judging/schedule/board.ts @@ -0,0 +1,125 @@ +import { appointmentStatus } from "../appointment-status"; +import type { AppointmentStatus } from "../appointment-status"; + +// The organiser's view of a saved schedule: where each appointment stands and +// which rooms have someone in them right now. + +/** A room counts as staffed while a judge's heartbeat is this recent. */ +export const STAFFED_WITHIN_MS = 15 * 60_000; + +export type BoardEvaluation = { + judgeId: string; + entryId: string; + challengeId: string; + appointmentId: string | null; + /** Holds every rating (scoring.hasEveryRating), so placings count it. */ + ratingsComplete: boolean; +}; + +export type BoardPresence = { + judgeId: string; + roomId: string; + joinedAt: Date; + lastSeenAt: Date; + leftAt: Date | null; +}; + +type Booked = { + id: string; + entryId: string; + challengeId: string; + roomId: string; + startsAt: Date; + endsAt: Date; +}; + +/** + * Whether an evaluation or draft of the appointment's project belongs to the + * appointment: it names it, or, when it names none, its challenge sits under + * the appointment's. The caller has matched the project. + */ +export function belongsTo( + appointment: { id: string; challengeId: string }, + row: { challengeId: string; appointmentId: string | null }, + rootIdOf: (challengeId: string) => string | null, +): boolean { + return row.appointmentId + ? row.appointmentId === appointment.id + : rootIdOf(row.challengeId) === appointment.challengeId; +} + +/** Rooms with a judge still in them and heard from in the last 15 minutes. */ +export function staffedRoomIds( + presence: readonly BoardPresence[], + now: Date, +): Set { + const since = now.getTime() - STAFFED_WITHIN_MS; + return new Set( + presence + .filter((p) => !p.leftAt && p.lastSeenAt.getTime() >= since) + .map((p) => p.roomId), + ); +} + +/** + * Each appointment with its status, how many evaluations it has, and whether + * an organiser may move it: only before it starts, or once it has passed + * with nobody judging it. An evaluation belongs to an appointment when it + * names it, or, when it names none, when it is for the same project and its + * challenge sits under the appointment's. + */ +export function withStatus( + appointments: readonly A[], + evaluations: readonly BoardEvaluation[], + presence: readonly BoardPresence[], + rootIdOf: (challengeId: string) => string | null, + now: Date, +): (A & { + status: AppointmentStatus; + canMove: boolean; + evaluationCount: number; +})[] { + const byEntry = new Map(); + for (const evaluation of evaluations) { + const list = byEntry.get(evaluation.entryId); + if (list) list.push(evaluation); + else byEntry.set(evaluation.entryId, [evaluation]); + } + const byRoom = new Map(); + for (const row of presence) { + const list = byRoom.get(row.roomId); + if (list) list.push(row); + else byRoom.set(row.roomId, [row]); + } + + return appointments.map((appointment) => { + const mine = (byEntry.get(appointment.entryId) ?? []).filter((e) => + belongsTo(appointment, e, rootIdOf), + ); + // Seen in the room at some point during the slot. An open row's last + // heartbeat stands in for when the judge left. + const presentJudgeIds = (byRoom.get(appointment.roomId) ?? []) + .filter( + (p) => + p.joinedAt < appointment.endsAt && + (p.leftAt ?? p.lastSeenAt) >= appointment.startsAt, + ) + .map((p) => p.judgeId); + + const status = appointmentStatus( + { + startsAt: appointment.startsAt, + endsAt: appointment.endsAt, + evaluations: mine, + presentJudgeIds, + }, + now, + ); + return { + ...appointment, + status, + canMove: status === "future" || status === "missed", + evaluationCount: mine.length, + }; + }); +} diff --git a/packages/api/src/services/judging/schedule/cpsat-bridge.test.ts b/packages/api/src/services/judging/schedule/cpsat-bridge.test.ts new file mode 100644 index 00000000..8e28cf3a --- /dev/null +++ b/packages/api/src/services/judging/schedule/cpsat-bridge.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it } from "vitest"; +import { aamoghCPSolver } from "./aamogh-cp-solver"; +import { + cpsatAssignmentProblem, + cpsatProblem, + readCpsatAssignments, + readCpsatSolution, +} from "./cpsat-bridge"; +import type { Placement, PlanInputs } from "./inputs"; +import type { JudgeAssignment, JudgeInput } from "./judges"; + +const inputs: PlanInputs = { + rooms: [ + { id: "g1", challengeId: "general", buildingId: "ENG" }, + { id: "g2", challengeId: "general", buildingId: "HEC" }, + { id: "s1", challengeId: "sp1", buildingId: "HEC" }, + ], + tasks: [ + { entryId: "a", challengeId: "general", sponsor: false }, + { entryId: "a", challengeId: "sp1", sponsor: true }, + { entryId: "b", challengeId: "general", sponsor: false }, + { entryId: "b", challengeId: "sp1", sponsor: true }, + { entryId: "c", challengeId: "general", sponsor: false }, + ], + slots: 4, + sameGap: 1, + crossGap: 2, +}; + +// What `uv run cpsat.py problem.json --workers 1` wrote for cpsatProblem(inputs, +// "fp-test"), verbatim apart from the timing. +const solution = { + format: "query-judging-schedule-solution", + version: 1, + fingerprint: "fp-test", + status: "optimal", + relaxed: false, + objective: { sponsorFinish: 2, sponsorIdle: 0, finish: 4, buildingChanges: 0, breakSlots: 2 }, + placements: [ + { entryId: "a", challengeId: "general", roomId: "g2", slot: 2 }, + { entryId: "a", challengeId: "sp1", roomId: "s1", slot: 0 }, + { entryId: "b", challengeId: "general", roomId: "g2", slot: 3 }, + { entryId: "b", challengeId: "sp1", roomId: "s1", slot: 1 }, + { entryId: "c", challengeId: "general", roomId: "g2", slot: 0 }, + ], + reasons: [], + solver: { name: "cp-sat", ortools: "9.15.6755", seconds: 0 }, +}; + +const read = (value: unknown, fingerprint = "fp-test") => + readCpsatSolution(inputs, fingerprint, JSON.stringify(value)); + +describe("cpsatProblem", () => { + it("writes the file cpsat.py reads, rooms marked by their challenge", () => { + const problem = cpsatProblem(inputs, "fp-test"); + expect(problem).toMatchObject({ + format: "query-judging-schedule-problem", + version: 1, + fingerprint: "fp-test", + slotCount: 4, + sameGap: 1, + crossGap: 2, + }); + expect(problem.rooms.map((r) => r.sponsor)).toEqual([false, false, true]); + expect(problem.tasks).toHaveLength(5); + expect("hint" in problem).toBe(false); + // A file without judges is still the version-1 file it always was. + expect("judges" in problem).toBe(false); + expect("assignmentHint" in problem).toBe(false); + }); + + it("passes a starting schedule along as the hint", () => { + const problem = cpsatProblem(inputs, "fp-test", solution.placements); + expect(problem.hint).toEqual(solution.placements); + }); +}); + +describe("readCpsatSolution", () => { + it("accepts what the script wrote and scores it by the portal's rules", () => { + const result = read(solution); + expect(result).toMatchObject({ + ok: true, + status: "optimal", + relaxed: false, + score: [2, 0, 4, 0, -2], + }); + }); + + it("agrees with aamoghCPSolver on the optimum", () => { + const ours = aamoghCPSolver(inputs); + const theirs = read(solution); + if (!ours.ok || !theirs.ok) throw new Error("both should solve this"); + expect(ours.score).toEqual(theirs.score); + }); + + it("refuses a solution to a problem that has since changed", () => { + expect(read(solution, "fp-now")).toMatchObject({ ok: false }); + }); + + it("refuses files that are not a solution", () => { + expect(readCpsatSolution(inputs, "fp-test", "not json")).toMatchObject({ ok: false }); + expect(read({ hello: "world" })).toMatchObject({ ok: false }); + }); + + it("explains a proof of infeasibility and a timeout", () => { + const infeasible = read({ ...solution, status: "infeasible", placements: [], reasons: ["sp1: 3 presentations but only 2 room slots"] }); + expect(infeasible.ok).toBe(false); + if (!infeasible.ok) expect(infeasible.reason).toContain("sp1: 3 presentations"); + const unknown = read({ ...solution, status: "unknown", placements: [] }); + expect(unknown.ok).toBe(false); + if (!unknown.ok) expect(unknown.reason).toContain("--time-limit"); + }); + + it("re-checks the rules instead of trusting the file", () => { + const doubleBooked = { + ...solution, + placements: solution.placements.map((p) => + p.entryId === "c" ? { ...p, slot: 2 } : p, + ), + }; + const result = read(doubleBooked); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toContain("double-booked"); + }); + + it("flags a schedule that only works with shortened breaks", () => { + // a walks from HEC at slot 0 to ENG at slot 2: one empty slot, needs two. + const shortened = { + ...solution, + placements: solution.placements.map((p) => + p.entryId === "a" && p.challengeId === "general" ? { ...p, roomId: "g1" } : p, + ), + }; + const result = read(shortened); + expect(result).toMatchObject({ ok: true, relaxed: true }); + if (result.ok) expect(result.reducedBreaks).toHaveLength(1); + }); + + it("still reads a version-1 file written before judges", () => { + expect(read(solution)).toMatchObject({ + ok: true, + judging: { source: "in-app", assignments: [] }, + }); + }); +}); + +const judges: JudgeInput[] = [ + { id: "m1", kind: "member", challengeIds: ["general", "sp1"], roomId: null }, + { id: "m2", kind: "member", challengeIds: ["general", "sp1"], roomId: null }, + { id: "gs", kind: "guest", challengeIds: ["sp1"], roomId: "s1" }, +]; + +// What `uv run cpsat.py problem.json --workers 1` wrote for cpsatProblem(inputs, +// "fp-test", undefined, judges), verbatim apart from the timing. +const judged = { + format: "query-judging-schedule-solution", + version: 1, + fingerprint: "fp-test", + status: "optimal", + relaxed: false, + objective: { + judgeShortfall: 0, + sponsorFinish: 2, + sponsorIdle: 0, + finish: 4, + buildingChanges: 0, + breakSlots: 2, + }, + placements: [ + { entryId: "a", challengeId: "general", roomId: "g2", slot: 2 }, + { entryId: "a", challengeId: "sp1", roomId: "s1", slot: 0 }, + { entryId: "b", challengeId: "general", roomId: "g2", slot: 3 }, + { entryId: "b", challengeId: "sp1", roomId: "s1", slot: 1 }, + { entryId: "c", challengeId: "general", roomId: "g2", slot: 1 }, + ], + reasons: [], + solver: { name: "cp-sat", ortools: "9.15.6755", seconds: 0 }, + assignmentStatus: "optimal", + judging: { + unassigned: 0, + memberOnSponsor: 0, + maxLoad: 2, + loadSpread: 1, + repeats: 2, + dayPartsCovered: 5, + avgDistinctRooms: 1.0, + }, + assignments: [ + { entryId: "a", challengeId: "general", judgeId: "m1" }, + { entryId: "a", challengeId: "sp1", judgeId: "gs" }, + { entryId: "b", challengeId: "general", judgeId: "m2" }, + { entryId: "b", challengeId: "sp1", judgeId: "gs" }, + { entryId: "c", challengeId: "general", judgeId: "m2" }, + ], +}; + +const readJudged = (value: unknown, now: JudgeInput[] = judges) => + readCpsatSolution(inputs, "fp-test", JSON.stringify(value), now); + +describe("cpsatProblem with judges", () => { + it("lists the judges with their run limit", () => { + const problem = cpsatProblem(inputs, "fp-test", undefined, judges); + expect(problem.judges).toEqual([ + { id: "m1", kind: "member", challengeIds: ["general", "sp1"], roomId: null, maxConsecutive: 4 }, + { id: "m2", kind: "member", challengeIds: ["general", "sp1"], roomId: null, maxConsecutive: 4 }, + { id: "gs", kind: "guest", challengeIds: ["sp1"], roomId: "s1", maxConsecutive: 4 }, + ]); + expect("assignmentHint" in problem).toBe(false); + expect("judges" in cpsatProblem(inputs, "fp-test", undefined, [])).toBe(false); + }); + + it("sends where the in-app assignment would put the judges for a hint", () => { + const problem = cpsatProblem(inputs, "fp-test", judged.placements, [ + { ...judges[0]!, maxConsecutive: 2 }, + ...judges.slice(1), + ]); + expect(problem.judges![0]!.maxConsecutive).toBe(2); + const hint = (problem as { assignmentHint?: JudgeAssignment[] }).assignmentHint!; + expect(hint).toHaveLength(5); + expect(new Set(hint.map((h) => h.judgeId))).toEqual(new Set(["m1", "m2", "gs"])); + }); +}); + +describe("readCpsatSolution with judges", () => { + it("takes the judges the script chose, once they pass the rules", () => { + const result = readJudged(judged); + expect(result).toMatchObject({ + ok: true, + judging: { + source: "cp-sat", + assignments: judged.assignments, + metrics: { unassigned: 0, maxLoad: 2, loadSpread: 1, repeats: 2 }, + }, + }); + }); + + it("assigns the judges itself when the file has none", () => { + const { assignments: _dropped, ...without } = judged; + const result = readJudged(without); + if (!result.ok) throw new Error("the schedule is fine"); + expect(result.judging.source).toBe("in-app"); + expect(result.judging.metrics.unassigned).toBe(0); + expect(result.judging.assignments).toHaveLength(5); + }); + + it("assigns the judges itself when the file's break the rules for the judges now", () => { + // The guest has since gone: gs is not among the judges any more. + const result = readJudged(judged, judges.slice(0, 2)); + if (!result.ok) throw new Error("the placements are still fine"); + expect(result.judging.source).toBe("in-app"); + expect(result.judging.assignments.every((a) => a.judgeId !== "gs")).toBe(true); + }); + + it("assigns the judges itself when the file puts one judge in two places", () => { + const clashing = { + ...judged, + assignments: judged.assignments.map((a) => + a.entryId === "c" ? { ...a, judgeId: "gs" } : a, + ), + }; + const result = readJudged(clashing); + if (!result.ok) throw new Error("the placements are fine"); + expect(result.judging.source).toBe("in-app"); + }); + + it("leaves presentations without a judge rather than refuse the schedule", () => { + const result = readJudged(judged, []); + if (!result.ok) throw new Error("the placements are fine"); + expect(result.judging.assignments).toEqual([]); + expect(result.judging.metrics.unassigned).toBe(5); + }); +}); + +describe("assigning judges to a schedule already made", () => { + const placements: Placement[] = judged.placements; + const keep: JudgeAssignment[] = [{ entryId: "c", challengeId: "general", judgeId: "m1" }]; + const problem = cpsatAssignmentProblem(inputs, placements, judges, keep); + const answer = (over: Record = {}) => + JSON.stringify({ + ...judged, + fingerprint: problem.fingerprint, + assignments: [ + { entryId: "c", challengeId: "general", judgeId: "m1" }, + { entryId: "a", challengeId: "general", judgeId: "m2" }, + { entryId: "a", challengeId: "sp1", judgeId: "gs" }, + { entryId: "b", challengeId: "general", judgeId: "m2" }, + { entryId: "b", challengeId: "sp1", judgeId: "gs" }, + ], + ...over, + }); + const readAnswer = (text: string, kept = keep) => + readCpsatAssignments(inputs, placements, judges, kept, text); + + it("writes the schedule, the judges and what to keep into the problem", () => { + expect(problem.placements).toEqual(placements); + expect(problem.locked).toEqual(keep); + expect(problem.judges).toHaveLength(3); + expect(problem.assignmentHint).toHaveLength(5); + expect("locked" in cpsatAssignmentProblem(inputs, placements, judges)).toBe(false); + }); + + it("reads the assignments, scored", () => { + expect(readAnswer(answer())).toMatchObject({ + ok: true, + status: "optimal", + metrics: { unassigned: 0, memberOnSponsor: 0 }, + }); + }); + + it("refuses an answer for another schedule", () => { + expect(readAnswer(answer({ fingerprint: "fp-test" }))).toMatchObject({ ok: false }); + }); + + it("refuses a proof that nothing fits and a timeout", () => { + expect(readAnswer(answer({ status: "infeasible" }))).toMatchObject({ ok: false }); + expect(readAnswer(answer({ status: "unknown" }))).toMatchObject({ ok: false }); + }); + + it("refuses an answer with no assignments or no JSON", () => { + const { assignments: _dropped, ...without } = JSON.parse(answer()); + expect(readAnswer(JSON.stringify(without))).toMatchObject({ ok: false }); + expect(readAnswer("nope")).toMatchObject({ ok: false }); + expect(readAnswer("{}")).toMatchObject({ ok: false }); + }); + + it("refuses assignments that break a rule", () => { + // m2 at a:sp1 (slot 0) and c:general (slot 1) in different buildings. + const result = readAnswer( + answer({ + assignments: [ + { entryId: "c", challengeId: "general", judgeId: "m1" }, + { entryId: "a", challengeId: "sp1", judgeId: "m1" }, + ], + }), + ); + expect(result).toMatchObject({ ok: false }); + if (!result.ok) expect(result.reason).toContain("against the rules"); + }); + + it("refuses an answer that moves what it was told to keep", () => { + const result = readAnswer( + answer({ + assignments: [ + { entryId: "c", challengeId: "general", judgeId: "m2" }, + { entryId: "a", challengeId: "general", judgeId: "m1" }, + ], + }), + ); + expect(result).toMatchObject({ ok: false }); + if (!result.ok) expect(result.reason).toContain("keep"); + }); + + it("does not blame the script for a rule the kept assignments already broke", () => { + // m1 kept at b:sp1 (slot 1, HEC) and c:general (slot 1, HEC): two places + // at once, so before CP-SAT ran. + const hasty: JudgeAssignment[] = [ + { entryId: "b", challengeId: "sp1", judgeId: "m1" }, + { entryId: "c", challengeId: "general", judgeId: "m1" }, + ]; + const text = answer({ + assignments: [ + ...hasty, + { entryId: "a", challengeId: "general", judgeId: "m2" }, + { entryId: "a", challengeId: "sp1", judgeId: "gs" }, + { entryId: "b", challengeId: "general", judgeId: "m2" }, + ], + }); + expect(readAnswer(text, hasty)).toMatchObject({ ok: true }); + }); +}); diff --git a/packages/api/src/services/judging/schedule/cpsat-bridge.ts b/packages/api/src/services/judging/schedule/cpsat-bridge.ts new file mode 100644 index 00000000..696f35ab --- /dev/null +++ b/packages/api/src/services/judging/schedule/cpsat-bridge.ts @@ -0,0 +1,351 @@ +import { z } from "zod"; +import { candidateKey, taskKey } from "./inputs"; +import type { Placement, PlanInputs } from "./inputs"; +import { + MAX_CONSECUTIVE, + assignJudges, + scoreJudgeAssignment, + validateJudgeAssignment, +} from "./judges"; +import type { JudgeAssignment, JudgeInput, JudgeMetrics } from "./judges"; +import { scoreSchedule, validateSchedule } from "./rules"; +import type { PlanScore, ReducedBreak } from "./rules"; + +// The fallback for when the in-app solver cannot build a schedule: the +// organiser downloads the problem, runs the CP-SAT script on a laptop +// (packages/api/scripts/judging-schedule/cpsat.py), and uploads what it +// writes. Nothing in the upload is trusted: it must answer the problem as it +// stands now (same fingerprint) and pass validateSchedule like any schedule +// the server builds. A server with Python and OR-Tools can run the script +// itself (cpsat-runner.ts); what it prints is read here the same way. + +export const CPSAT_PROBLEM_FORMAT = "query-judging-schedule-problem"; +export const CPSAT_SOLUTION_FORMAT = "query-judging-schedule-solution"; + +/** The command shown next to the download. */ +export const CPSAT_COMMAND = + "uv run packages/api/scripts/judging-schedule/cpsat.py problem.json -o solution.json"; + +const judgesOf = (judges: readonly JudgeInput[]) => + judges.map((judge) => ({ + id: judge.id, + kind: judge.kind, + challengeIds: [...judge.challengeIds], + roomId: judge.roomId, + maxConsecutive: judge.maxConsecutive ?? MAX_CONSECUTIVE, + })); + +const assignmentsOf = (rows: readonly JudgeAssignment[]) => + rows.map((row) => ({ + entryId: row.entryId, + challengeId: row.challengeId, + judgeId: row.judgeId, + })); + +/** + * The problem file cpsat.py reads. `hint` is a schedule to start from, such + * as the best partial one the in-app solver found. `judges`, when there are + * any, asks for a judge for every presentation as well, and also keeps each + * challenge's presentations in any slot within its judges. + */ +export function cpsatProblem( + inputs: PlanInputs, + fingerprint: string, + hint?: readonly Placement[], + judges: readonly JudgeInput[] = [], +) { + const sponsorChallenges = new Set( + inputs.tasks.filter((t) => t.sponsor).map((t) => t.challengeId), + ); + return { + format: CPSAT_PROBLEM_FORMAT, + version: 1, + fingerprint, + slotCount: inputs.slots, + sameGap: inputs.sameGap, + crossGap: inputs.crossGap, + rooms: inputs.rooms.map((room) => ({ + id: room.id, + challengeId: room.challengeId, + buildingId: room.buildingId, + sponsor: sponsorChallenges.has(room.challengeId), + })), + tasks: inputs.tasks.map((task) => ({ + entryId: task.entryId, + challengeId: task.challengeId, + sponsor: task.sponsor, + })), + ...(hint && hint.length > 0 + ? { + hint: hint.map((p) => ({ + entryId: p.entryId, + challengeId: p.challengeId, + roomId: p.roomId, + slot: p.slot, + })), + } + : {}), + ...(judges.length > 0 + ? { + judges: judgesOf(judges), + // Where the in-app assignment would put the judges for that hint, + // for CP-SAT to improve on. + ...(hint && hint.length > 0 + ? { + assignmentHint: assignmentsOf( + assignJudges(inputs, hint, judges).assignment, + ), + } + : {}), + } + : {}), + }; +} + +/** Binds an assignment-only run to the placements it assigns judges to. */ +const assignmentFingerprint = (placements: readonly Placement[]) => + candidateKey("judges", false, placements); + +/** + * A problem that only assigns judges: the schedule is given and CP-SAT leaves + * it alone. `keep` stays as it is, such as the presentations already under + * way. `inputs` hold just the presentations in `placements`. + */ +export function cpsatAssignmentProblem( + inputs: PlanInputs, + placements: readonly Placement[], + judges: readonly JudgeInput[], + keep: readonly JudgeAssignment[] = [], +) { + const base = cpsatProblem(inputs, assignmentFingerprint(placements), undefined, judges); + return { + ...base, + placements: placements.map((p) => ({ + entryId: p.entryId, + challengeId: p.challengeId, + roomId: p.roomId, + slot: p.slot, + })), + ...(keep.length > 0 ? { locked: assignmentsOf(keep) } : {}), + assignmentHint: assignmentsOf( + assignJudges(inputs, placements, judges, { keep }).assignment, + ), + }; +} + +const solutionSchema = z.object({ + format: z.literal(CPSAT_SOLUTION_FORMAT), + version: z.literal(1), + fingerprint: z.string(), + status: z.enum(["optimal", "feasible", "infeasible", "unknown"]), + relaxed: z.boolean(), + placements: z.array( + z.object({ + entryId: z.string(), + challengeId: z.string(), + roomId: z.string(), + slot: z.number().int(), + }), + ), + reasons: z.array(z.string()).optional(), + assignments: z + .array( + z.object({ + entryId: z.string(), + challengeId: z.string(), + judgeId: z.string(), + }), + ) + .optional(), +}); + +/** Who grades what for a schedule, and who worked it out. */ +export type JudgeOutcome = { + assignments: JudgeAssignment[]; + metrics: JudgeMetrics; + /** "cp-sat" when the file's own assignments passed the checks. */ + source: "cp-sat" | "in-app"; +}; + +export type CpsatImport = + | { + ok: true; + /** "optimal" when CP-SAT proved no schedule beats this one. */ + status: "optimal" | "feasible"; + relaxed: boolean; + placements: Placement[]; + score: PlanScore; + reducedBreaks: ReducedBreak[]; + judging: JudgeOutcome; + } + | { ok: false; reason: string }; + +/** + * The file's assignments when it has some and they keep the rules for the + * judges as they are now; otherwise the in-app ones. + */ +function judgeOutcome( + inputs: PlanInputs, + placements: readonly Placement[], + judges: readonly JudgeInput[], + given: readonly JudgeAssignment[] | undefined, +): JudgeOutcome { + if ( + given && + given.length > 0 && + validateJudgeAssignment(inputs, placements, judges, given).valid + ) { + return { + assignments: assignmentsOf(given), + metrics: scoreJudgeAssignment(inputs, placements, judges, given), + source: "cp-sat", + }; + } + const made = assignJudges(inputs, placements, judges); + return { assignments: made.assignment, metrics: made.metrics, source: "in-app" }; +} + +/** + * Reads and checks an uploaded solution.json against the current problem. + * `judges` are the judges as they are now, whatever the problem listed. + */ +export function readCpsatSolution( + inputs: PlanInputs, + fingerprint: string, + text: string, + judges: readonly JudgeInput[] = [], +): CpsatImport { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return { ok: false, reason: "That file is not JSON. Upload the solution.json the script wrote." }; + } + const parsed = solutionSchema.safeParse(raw); + if (!parsed.success) { + return { + ok: false, + reason: "That file is not a solution from cpsat.py. Upload the solution.json the script wrote.", + }; + } + const solution = parsed.data; + + if (solution.fingerprint !== fingerprint) { + return { + ok: false, + reason: + "Projects, rooms, tables or timing changed since that problem was downloaded. Download it again and re-run the script.", + }; + } + if (solution.status === "infeasible") { + const why = solution.reasons?.length ? ` ${solution.reasons.join(" ")}` : ""; + return { + ok: false, + reason: `CP-SAT proved no schedule fits this window and these rooms.${why} Widen the window, shorten the slots or add rooms.`, + }; + } + if (solution.status === "unknown" || solution.placements.length === 0) { + return { + ok: false, + reason: "CP-SAT ran out of time before finding a schedule. Re-run it with a larger --time-limit.", + }; + } + + const placements: Placement[] = solution.placements.map((p) => ({ + entryId: p.entryId, + challengeId: p.challengeId, + roomId: p.roomId, + slot: p.slot, + })); + // Judge it by the rules, not by what the file claims about itself. + const strict = validateSchedule(inputs, placements, { relaxed: false }); + const check = strict.valid ? strict : validateSchedule(inputs, placements, { relaxed: true }); + if (!check.valid) { + return { + ok: false, + reason: `That schedule breaks the rules: ${check.errors.slice(0, 3).join(" ")}`, + }; + } + return { + ok: true, + status: solution.status, + relaxed: !strict.valid, + placements, + score: scoreSchedule(inputs, placements), + reducedBreaks: check.reducedBreaks, + judging: judgeOutcome(inputs, placements, judges, solution.assignments), + }; +} + +export type CpsatAssignmentImport = + | { + ok: true; + status: "optimal" | "feasible"; + assignments: JudgeAssignment[]; + metrics: JudgeMetrics; + } + | { ok: false; reason: string }; + +/** + * Reads what cpsat.py printed for a cpsatAssignmentProblem. Unlike a new + * schedule, nothing here is optional: the assignments must keep the rules + * for the judges, leave every one in `keep` as it was, and break no rule that + * `keep` did not already break. + */ +export function readCpsatAssignments( + inputs: PlanInputs, + placements: readonly Placement[], + judges: readonly JudgeInput[], + keep: readonly JudgeAssignment[], + text: string, +): CpsatAssignmentImport { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return { ok: false, reason: "CP-SAT did not print a solution." }; + } + const parsed = solutionSchema.safeParse(raw); + if (!parsed.success) { + return { ok: false, reason: "CP-SAT did not print a solution from cpsat.py." }; + } + const solution = parsed.data; + if (solution.fingerprint !== assignmentFingerprint(placements)) { + return { ok: false, reason: "CP-SAT answered a different schedule." }; + } + if (solution.status === "infeasible" || solution.status === "unknown") { + return { ok: false, reason: "CP-SAT found no assignment of judges in the time it had." }; + } + if (!solution.assignments) { + return { ok: false, reason: "CP-SAT did not assign judges." }; + } + + const assignments = assignmentsOf(solution.assignments); + const before = new Set( + validateJudgeAssignment(inputs, placements, judges, keep).problems, + ); + const broken = validateJudgeAssignment( + inputs, + placements, + judges, + assignments, + ).problems.filter((problem) => !before.has(problem)); + if (broken.length > 0) { + return { + ok: false, + reason: `CP-SAT assigned judges against the rules: ${broken.slice(0, 3).join(" ")}`, + }; + } + const given = new Map( + assignments.map((a) => [taskKey(a.entryId, a.challengeId), a.judgeId]), + ); + if (keep.some((k) => given.get(taskKey(k.entryId, k.challengeId)) !== k.judgeId)) { + return { ok: false, reason: "CP-SAT changed an assignment it was told to keep." }; + } + return { + ok: true, + status: solution.status, + assignments, + metrics: scoreJudgeAssignment(inputs, placements, judges, assignments), + }; +} diff --git a/packages/api/src/services/judging/schedule/cpsat-runner.test.ts b/packages/api/src/services/judging/schedule/cpsat-runner.test.ts new file mode 100644 index 00000000..9d7c611a --- /dev/null +++ b/packages/api/src/services/judging/schedule/cpsat-runner.test.ts @@ -0,0 +1,396 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { availableParallelism, tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type * as ChildProcess from "node:child_process"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + cpsatAssignmentProblem, + cpsatProblem, + readCpsatAssignments, + readCpsatSolution, +} from "./cpsat-bridge"; +import { CPSAT_UNAVAILABLE, cpsatAvailable, runCpsat } from "./cpsat-runner"; +import type { PlanInputs } from "./inputs"; + +// Node stands in for Python: each test writes a small script that behaves +// like one way cpsat.py can end. Node has no -I, so the spawn below drops it +// for Node only, and records what the runner asked for. +const spawned = vi.hoisted( + () => [] as { command: string; args: string[]; child: ChildProcess.ChildProcess }[], +); +vi.mock("node:child_process", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + spawn: (command: string, args: string[], options: ChildProcess.SpawnOptions) => { + const forNode = command === process.execPath ? args.filter((a) => a !== "-I") : args; + const child = real.spawn(command, forNode, options); + spawned.push({ command, args, child }); + return child; + }, + }; +}); + +const inputs: PlanInputs = { + rooms: [ + { id: "g1", challengeId: "general", buildingId: "ENG" }, + { id: "g2", challengeId: "general", buildingId: "HEC" }, + { id: "s1", challengeId: "sp1", buildingId: "HEC" }, + ], + tasks: [ + { entryId: "a", challengeId: "general", sponsor: false }, + { entryId: "a", challengeId: "sp1", sponsor: true }, + { entryId: "b", challengeId: "general", sponsor: false }, + { entryId: "b", challengeId: "sp1", sponsor: true }, + { entryId: "c", challengeId: "general", sponsor: false }, + ], + slots: 4, + sameGap: 1, + crossGap: 2, +}; + +// The optimum (see cpsat-bridge.test.ts), sent as the hint so the fake +// script can hand it back. +const problem = cpsatProblem(inputs, "fp-test", [ + { entryId: "a", challengeId: "general", roomId: "g2", slot: 2 }, + { entryId: "a", challengeId: "sp1", roomId: "s1", slot: 0 }, + { entryId: "b", challengeId: "general", roomId: "g2", slot: 3 }, + { entryId: "b", challengeId: "sp1", roomId: "s1", slot: 1 }, + { entryId: "c", challengeId: "general", roomId: "g2", slot: 0 }, +]); + +/** Reads the problem file it is given, then runs `body` with it as `problem`. */ +const reading = (body: string) => ` +{ + const problem = JSON.parse(require("node:fs").readFileSync(process.argv[2], "utf8")); + const answer = (status, placements) => + process.stdout.write(JSON.stringify({ + format: "query-judging-schedule-solution", + version: 1, + fingerprint: problem.fingerprint, + status, + relaxed: false, + placements, + reasons: status === "infeasible" ? ["challenge sp1: 3 presentations but only 2 room slots"] : [], + })); + ${body} +} +`; + +const dir = mkdtempSync(join(tmpdir(), "cpsat-runner-")); +let scripts = 0; + +/** Points JUDGING_CPSAT_SCRIPT at a new fake cpsat.py. */ +function fakeScript(source: string) { + const path = join(dir, `fake-${++scripts}.js`); + writeFileSync(path, source); + vi.stubEnv("JUDGING_CPSAT_SCRIPT", path); +} + +beforeEach(() => { + spawned.length = 0; + vi.stubEnv("JUDGING_CPSAT_PYTHON", process.execPath); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.useRealTimers(); +}); + +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +describe("cpsatAvailable", () => { + it("needs JUDGING_CPSAT_PYTHON", async () => { + fakeScript(""); + vi.stubEnv("JUDGING_CPSAT_PYTHON", ""); + expect(cpsatAvailable()).toBe(false); + expect(await runCpsat(problem)).toEqual({ + status: "failed", + reason: CPSAT_UNAVAILABLE, + log: [], + }); + expect(spawned).toHaveLength(0); + }); + + it("needs the script", () => { + vi.stubEnv("JUDGING_CPSAT_SCRIPT", join(dir, "missing.py")); + expect(cpsatAvailable()).toBe(false); + }); + + it("finds cpsat.py in the repo when JUDGING_CPSAT_SCRIPT is unset", () => { + vi.stubEnv("JUDGING_CPSAT_SCRIPT", ""); + expect(cpsatAvailable()).toBe(true); + }); +}); + +// Every case here starts a real process, which alone can take seconds on a +// busy machine; the default 5s made them flaky rather than wrong. +describe("runCpsat", { timeout: 30_000 }, () => { + it("passes the problem as a file and returns what the script printed", async () => { + fakeScript(reading(`console.error("strict: full cross-building breaks"); answer("optimal", problem.hint);`)); + const run = await runCpsat(problem); + expect(run).toMatchObject({ + status: "solved", + log: ["strict: full cross-building breaks"], + }); + if (run.status !== "solved") throw new Error("should have solved"); + + // The output is read like an upload: fingerprint and rules both checked. + expect(readCpsatSolution(inputs, "fp-test", run.solution)).toMatchObject({ + ok: true, + score: [2, 0, 4, 0, -2], + }); + // cpsat.py takes a path, the same as on an organiser's laptop. The file + // is gone once the run is over. + const file = spawned[0]!.args[2]!; + expect(file).toMatch(/problem\.json$/); + expect(existsSync(file)).toBe(false); + expect(spawned[0]!.command).toBe(process.execPath); + expect(spawned[0]!.args).toEqual([ + "-I", + join(dir, `fake-${scripts}.js`), + file, + "--time-limit", + "20", + "--workers", + String(Math.min(4, availableParallelism())), + ]); + }); + + it("keeps the time limit between 5 and 60 seconds", async () => { + fakeScript(reading(`answer("optimal", problem.hint);`)); + await runCpsat(problem, { seconds: 1 }); + await runCpsat(problem, { seconds: 600 }); + expect(spawned.map((s) => s.args[4])).toEqual(["5", "60"]); + }); + + it("passes on a proof of infeasibility and a timeout, with the file", async () => { + fakeScript(reading(`answer("infeasible", []); process.exitCode = 2;`)); + const infeasible = await runCpsat(problem); + expect(infeasible.status).toBe("infeasible"); + if (infeasible.status !== "infeasible") return; + const read = readCpsatSolution(inputs, "fp-test", infeasible.solution); + expect(read).toMatchObject({ ok: false }); + if (!read.ok) expect(read.reason).toContain("3 presentations but only 2 room slots"); + + fakeScript(reading(`answer("unknown", []); process.exitCode = 3;`)); + expect(await runCpsat(problem)).toMatchObject({ status: "timedOut" }); + }); + + it("fails with the end of the log when the script does", async () => { + fakeScript(` +for (let i = 1; i <= 30; i++) console.error("line " + i); +process.exitCode = 1; +`); + const run = await runCpsat(problem); + expect(run.status).toBe("failed"); + if (run.status !== "failed") return; + expect(run.log).toHaveLength(20); + expect(run.log[0]).toBe("line 11"); + expect(run.reason).toContain("exit code 1"); + expect(run.reason).toContain("line 30"); + }); + + it("fails when Python cannot start", async () => { + fakeScript(""); + vi.stubEnv("JUDGING_CPSAT_PYTHON", join(dir, "no-such-python")); + const run = await runCpsat(problem); + expect(run.status).toBe("failed"); + if (run.status === "failed") expect(run.reason).toContain("could not start"); + }); + + it("kills a run that outlives its time limit", async () => { + fakeScript(`setInterval(() => undefined, 1000);`); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const running = runCpsat(problem, { seconds: 5 }); + // The clock starts with the script, once the problem file is written. + while (spawned.length === 0) await new Promise((next) => setImmediate(next)); + // 5 seconds for each of two attempts, plus 60 to start and build. + await vi.advanceTimersByTimeAsync(69_000); + expect(spawned[0]!.child.killed).toBe(false); + await vi.advanceTimersByTimeAsync(1_000); + expect(spawned[0]!.child.killed).toBe(true); + + const run = await running; + expect(run.status).toBe("failed"); + if (run.status === "failed") expect(run.reason).toContain("after 70 seconds"); + }); + + it("allows one more time limit when the script also assigns judges", async () => { + fakeScript(`setInterval(() => undefined, 1000);`); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const judged = cpsatProblem(inputs, "fp-test", undefined, [ + { id: "m1", kind: "member", challengeIds: ["general", "sp1"], roomId: null }, + ]); + const running = runCpsat(judged, { seconds: 5 }); + while (spawned.length === 0) await new Promise((next) => setImmediate(next)); + // 5 seconds for each of two attempts and for the judges, plus 60. + await vi.advanceTimersByTimeAsync(74_000); + expect(spawned[0]!.child.killed).toBe(false); + await vi.advanceTimersByTimeAsync(1_000); + expect(spawned[0]!.child.killed).toBe(true); + + const run = await running; + if (run.status === "failed") expect(run.reason).toContain("after 75 seconds"); + }); + + it("allows a single time limit when the schedule is given and only judges are assigned", async () => { + fakeScript(`setInterval(() => undefined, 1000);`); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const placements = [{ entryId: "a", challengeId: "general", roomId: "g1", slot: 0 }]; + const judged = cpsatAssignmentProblem( + { ...inputs, tasks: [inputs.tasks[0]!] }, + placements, + [{ id: "m1", kind: "member", challengeIds: ["general"], roomId: null }], + ); + const running = runCpsat(judged, { seconds: 5 }); + while (spawned.length === 0) await new Promise((next) => setImmediate(next)); + await vi.advanceTimersByTimeAsync(64_000); + expect(spawned[0]!.child.killed).toBe(false); + await vi.advanceTimersByTimeAsync(1_000); + expect(spawned[0]!.child.killed).toBe(true); + + const run = await running; + if (run.status === "failed") expect(run.reason).toContain("after 65 seconds"); + }); + + it("kills a run when its signal fires, and removes the problem file", async () => { + // Would run for the full 70 seconds if nothing stopped it. + fakeScript(`console.error("loading"); setInterval(() => undefined, 1000);`); + const abort = new AbortController(); + const running = runCpsat(problem, { seconds: 5, signal: abort.signal }); + while (spawned.length === 0) await new Promise((next) => setImmediate(next)); + const child = spawned[0]!.child; + const file = spawned[0]!.args[2]!; + expect(existsSync(file)).toBe(true); + + const started = Date.now(); + abort.abort(); + const run = await running; + + expect(Date.now() - started).toBeLessThan(5_000); + expect(child.killed).toBe(true); + expect(child.exitCode !== null || child.signalCode !== null).toBe(true); + expect(run).toMatchObject({ status: "failed" }); + if (run.status === "failed") expect(run.reason).toContain("cancelled"); + expect(existsSync(file)).toBe(false); + }); + + it("does not start when its signal has already fired", async () => { + fakeScript(reading(`answer("optimal", problem.hint);`)); + const run = await runCpsat(problem, { signal: AbortSignal.abort() }); + expect(run).toMatchObject({ status: "failed" }); + if (run.status === "failed") expect(run.reason).toContain("cancelled"); + expect(spawned).toHaveLength(0); + }); + + it("ignores a signal that fires after the run has ended", async () => { + fakeScript(reading(`answer("optimal", problem.hint);`)); + const abort = new AbortController(); + const run = await runCpsat(problem, { signal: abort.signal }); + expect(run.status).toBe("solved"); + abort.abort(); + expect(spawned[0]!.child.killed).toBe(false); + }); +}); + +// The real thing, when a Python with OR-Tools is at hand: +// CPSAT_TEST_PYTHON=/path/to/python npx vitest run +// eslint-disable-next-line turbo/no-undeclared-env-vars +const realPython = process.env.CPSAT_TEST_PYTHON; + +describe.runIf(realPython)("runCpsat with cpsat.py", () => { + it("solves the bundled example", { timeout: 60_000 }, async () => { + vi.stubEnv("JUDGING_CPSAT_PYTHON", realPython!); + vi.stubEnv("JUDGING_CPSAT_SCRIPT", ""); + const example = JSON.parse( + readFileSync( + fileURLToPath( + new URL("../../../../scripts/judging-schedule/example-problem.json", import.meta.url), + ), + "utf8", + ), + ); + const run = await runCpsat(example, { seconds: 5 }); + expect(run).toMatchObject({ status: "solved" }); + if (run.status !== "solved") return; + + const exampleInputs: PlanInputs = { + rooms: example.rooms.map((r: PlanInputs["rooms"][number]) => ({ + id: r.id, + challengeId: r.challengeId, + buildingId: r.buildingId, + })), + tasks: example.tasks, + slots: example.slotCount, + sameGap: example.sameGap, + crossGap: example.crossGap, + }; + expect(readCpsatSolution(exampleInputs, example.fingerprint, run.solution)).toMatchObject({ + ok: true, + }); + }); + + it("assigns judges to the bundled example", { timeout: 120_000 }, async () => { + vi.stubEnv("JUDGING_CPSAT_PYTHON", realPython!); + vi.stubEnv("JUDGING_CPSAT_SCRIPT", ""); + const example = JSON.parse( + readFileSync( + fileURLToPath( + new URL("../../../../scripts/judging-schedule/example-problem.json", import.meta.url), + ), + "utf8", + ), + ); + const exampleInputs: PlanInputs = { + rooms: example.rooms.map((r: PlanInputs["rooms"][number]) => ({ + id: r.id, + challengeId: r.challengeId, + buildingId: r.buildingId, + })), + tasks: example.tasks, + slots: example.slotCount, + sameGap: example.sameGap, + crossGap: example.crossGap, + }; + const everything = [...new Set(example.tasks.map((t: { challengeId: string }) => t.challengeId))]; + const judges = Array.from({ length: 8 }, (_, n) => ({ + id: `judge-${n}`, + kind: "member" as const, + challengeIds: everything as string[], + roomId: null, + })); + + const run = await runCpsat( + cpsatProblem(exampleInputs, example.fingerprint, undefined, judges), + { seconds: 5 }, + ); + expect(run).toMatchObject({ status: "solved" }); + if (run.status !== "solved") return; + const read = readCpsatSolution(exampleInputs, example.fingerprint, run.solution, judges); + expect(read).toMatchObject({ ok: true, judging: { source: "cp-sat" } }); + if (!read.ok) return; + + // Then the same schedule again, only the judges: one assignment is kept. + const keep = read.judging.assignments.slice(0, 1); + const again = await runCpsat( + cpsatAssignmentProblem(exampleInputs, read.placements, judges, keep), + { seconds: 5 }, + ); + expect(again).toMatchObject({ status: "solved" }); + if (again.status !== "solved") return; + const assigned = readCpsatAssignments( + exampleInputs, + read.placements, + judges, + keep, + again.solution, + ); + expect(assigned).toMatchObject({ ok: true }); + if (assigned.ok) { + expect(assigned.assignments).toEqual(expect.arrayContaining(keep)); + expect(assigned.assignments).toHaveLength(read.placements.length); + } + }); +}); diff --git a/packages/api/src/services/judging/schedule/cpsat-runner.ts b/packages/api/src/services/judging/schedule/cpsat-runner.ts new file mode 100644 index 00000000..01bbebe4 --- /dev/null +++ b/packages/api/src/services/judging/schedule/cpsat-runner.ts @@ -0,0 +1,216 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { availableParallelism, tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +// Runs the CP-SAT script on this server, for hosts that have a Python with +// OR-Tools, so an organiser does not have to download the problem and run it +// on a laptop. It is the same script fed the same problem file, and what it +// prints is read exactly like an upload (readCpsatSolution, or +// readCpsatAssignments when only the judges are assigned): this file only +// starts the process and reports how it ended, it never decides whether a +// schedule is good. +// +// JUDGING_CPSAT_PYTHON the Python to run it with: a command ("python3") +// or a path. Unset means CP-SAT is unavailable here. +// JUDGING_CPSAT_SCRIPT where cpsat.py is. By default, the first +// packages/api/scripts/judging-schedule/cpsat.py found +// walking up from the working directory (Next runs +// from sites/mainweb, tests from the repo root). + +const SCRIPT_IN_REPO = join("packages", "api", "scripts", "judging-schedule", "cpsat.py"); + +export const CPSAT_UNAVAILABLE = + "CP-SAT is not installed on this server. Download the problem and run the script instead."; + +/** CP-SAT's time limit for each attempt (strict, then relaxed), in seconds. */ +export const CPSAT_SECONDS = { min: 5, max: 60, default: 20 } as const; + +/** Lines of the script's log kept for error messages. */ +const LOG_LINES = 20; + +/** + * How many times the script may use its whole time limit on `problem`: twice + * to place the presentations (strict, then relaxed) unless the schedule is + * given, and once more to assign judges when there are any. + */ +function timeLimits(problem: unknown): number { + const shape = problem as { judges?: unknown; placements?: unknown } | null; + const judged = Array.isArray(shape?.judges) && shape.judges.length > 0; + return (Array.isArray(shape?.placements) ? 0 : 2) + (judged ? 1 : 0); +} + +type Command = { python: string; script: string }; + +let probe: { env: string; command: Command | null } | undefined; + +/** + * The Python and script to run, or null when CP-SAT is unavailable. Found + * once per process (again only if the variables change, as tests do) and + * without starting Python, so asking costs nothing. + */ +function cpsatCommand(): Command | null { + // Settings of the running server; nothing turbo builds depends on them. + // eslint-disable-next-line turbo/no-undeclared-env-vars + const python = process.env.JUDGING_CPSAT_PYTHON?.trim() ?? ""; + // eslint-disable-next-line turbo/no-undeclared-env-vars + const configured = process.env.JUDGING_CPSAT_SCRIPT?.trim() ?? ""; + const env = `${python}\n${configured}`; + if (probe?.env !== env) { + const script = !python ? null : configured ? resolve(configured) : findScript(); + probe = { + env, + command: script && existsSync(script) ? { python, script } : null, + }; + } + return probe.command; +} + +function findScript(): string | null { + for (let dir = process.cwd(); ; dir = dirname(dir)) { + const candidate = join(dir, SCRIPT_IN_REPO); + if (existsSync(candidate)) return candidate; + if (dirname(dir) === dir) return null; + } +} + +/** Whether this server can run CP-SAT itself. */ +export function cpsatAvailable(): boolean { + return cpsatCommand() !== null; +} + +export type CpsatRun = + | { + /** + * How cpsat.py exited: 0 a schedule, 2 proof that none exists, 3 out of + * time before finding one. It prints a solution file in all three. + */ + status: "solved" | "infeasible" | "timedOut"; + /** What it printed. Untrusted: read it with readCpsatSolution. */ + solution: string; + /** The end of its log (stderr). */ + log: string[]; + } + | { status: "failed"; reason: string; log: string[] }; + +const cancelled = (log: string[] = []): CpsatRun => ({ + status: "failed", + reason: "CP-SAT was cancelled before it finished.", + log, +}); + +/** + * Runs cpsat.py on `problem` (a cpsatProblem) and waits for it. The problem + * goes in as a file, as when an organiser runs the script, and the solution + * comes back on stdout. CP-SAT stops itself at its time limit; the process is + * killed if it is still running well past that, or as soon as `signal` fires + * (the request that asked for the run went away). + */ +export async function runCpsat( + problem: unknown, + options: { seconds?: number; signal?: AbortSignal } = {}, +): Promise { + const command = cpsatCommand(); + if (!command) return { status: "failed", reason: CPSAT_UNAVAILABLE, log: [] }; + const { signal } = options; + if (signal?.aborted) return cancelled(); + const seconds = Math.min( + CPSAT_SECONDS.max, + Math.max(CPSAT_SECONDS.min, Math.round(options.seconds ?? CPSAT_SECONDS.default)), + ); + const workers = Math.min(4, availableParallelism()); + // Up to two attempts of `seconds` each, and one more for the judges. + // Starting Python, loading OR-Tools and building the model for each attempt + // come on top and do not depend on `seconds`: on a busy machine they took + // close to 30 seconds for a 2,000-presentation edition, so the margin leaves + // room for twice that. + const killAfter = seconds * timeLimits(problem) + 60; + + // A directory of its own, so two runs at once never share a file. + const dir = await mkdtemp(join(tmpdir(), "cpsat-")); + try { + const file = join(dir, "problem.json"); + await writeFile(file, JSON.stringify(problem)); + return await new Promise((done) => { + // It may have fired while the file was being written. + if (signal?.aborted) return done(cancelled()); + const child = spawn( + command.python, + ["-I", command.script, file, "--time-limit", String(seconds), "--workers", String(workers)], + { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, + ); + const stdout: Buffer[] = []; + let stderr = ""; + let killed = false; + let aborted = false; + let settled = false; + + const timer = setTimeout(() => { + killed = true; + child.kill("SIGKILL"); + }, killAfter * 1000); + // The run settles once the process has exited (close), not when it is + // told to, so the temp directory is never removed from under it. + const onAbort = () => { + aborted = true; + child.kill("SIGKILL"); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + const finish = (run: CpsatRun) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + done(run); + }; + const log = () => + stderr + .split(/\r?\n/) + .filter((line) => line.trim() !== "") + .slice(-LOG_LINES); + + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + // Only the end is ever shown. + stderr = (stderr + chunk).slice(-16_384); + }); + + child.on("error", (error) => + finish({ + status: "failed", + reason: `CP-SAT could not start (${command.python}): ${error.message}`, + log: log(), + }), + ); + child.on("close", (code, exitSignal) => { + if (aborted) return finish(cancelled(log())); + if (killed) { + return finish({ + status: "failed", + reason: `CP-SAT was still running after ${killAfter} seconds, so the server stopped it. Download the problem and run the script instead.`, + log: log(), + }); + } + const status = + code === 0 ? "solved" : code === 2 ? "infeasible" : code === 3 ? "timedOut" : null; + if (status) { + return finish({ status, solution: Buffer.concat(stdout).toString("utf8"), log: log() }); + } + // 1 is bad input or a broken install; anything else is a crash. + const how = code === null ? `signal ${exitSignal}` : `exit code ${code}`; + const lines = log(); + finish({ + status: "failed", + reason: `CP-SAT stopped with ${how}.${lines.length > 0 ? `\n${lines.join("\n")}` : ""}`, + log: lines, + }); + }); + }); + } finally { + // A file left in the temp directory harms nothing, so a failed cleanup + // never replaces the run's result. + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } +} diff --git a/packages/api/src/services/judging/schedule/inputs.test.ts b/packages/api/src/services/judging/schedule/inputs.test.ts new file mode 100644 index 00000000..e098fa42 --- /dev/null +++ b/packages/api/src/services/judging/schedule/inputs.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + candidateKey, + planFingerprint, + planInputs, + scheduledChallengeIds, + selectPlan, +} from "./inputs"; +import type { SourceChallenge, SourceRoom } from "./inputs"; +import type { ScheduleTiming } from "./slots"; + +const challenges: SourceChallenge[] = [ + { id: "general", parentId: null, mode: "scheduled", isGeneral: true }, + { id: "mongo", parentId: null, mode: "scheduled", isGeneral: false }, + { id: "mlh", parentId: null, mode: "unscheduled", isGeneral: false }, + { id: "mlh-ai", parentId: "mlh", mode: "scheduled", isGeneral: false }, + { id: "remote", parentId: null, mode: "remote", isGeneral: false }, +]; + +const rooms: SourceRoom[] = [ + { id: "r2", challengeId: "general", buildingId: "eng", archivedAt: null }, + { id: "r1", challengeId: "general", buildingId: "eng", archivedAt: null }, + { id: "r3", challengeId: "general", buildingId: null, archivedAt: null }, + { id: "r4", challengeId: "mongo", buildingId: "hec", archivedAt: new Date() }, + { id: "r5", challengeId: "mlh", buildingId: "hec", archivedAt: null }, +]; + +const memberships = [ + { entryId: "b", challengeId: "general" }, + { entryId: "a", challengeId: "mongo" }, + { entryId: "a", challengeId: "general" }, + { entryId: "a", challengeId: "mlh" }, + { entryId: "a", challengeId: "mlh-ai" }, + { entryId: "a", challengeId: "remote" }, +]; + +const timing: ScheduleTiming = { + windowStart: new Date("2027-02-08T14:00:00Z"), + windowEnd: new Date("2027-02-08T16:00:00Z"), + setupMinutes: 2, + judgingMinutes: 6, + teardownMinutes: 2, + sameBuildingBreakMinutes: 10, + crossBuildingBreakMinutes: 20, +}; + +describe("selectPlan", () => { + it("schedules only top-level challenges judged on the schedule", () => { + expect([...scheduledChallengeIds(challenges)]).toEqual(["general", "mongo"]); + }); + + it("keeps rooms in service with a building, sorted by id", () => { + expect(selectPlan({ challenges, rooms, memberships }).rooms).toEqual([ + { id: "r1", challengeId: "general", buildingId: "eng" }, + { id: "r2", challengeId: "general", buildingId: "eng" }, + ]); + }); + + it("makes one task per project and scheduled challenge, sponsors flagged", () => { + expect(selectPlan({ challenges, rooms, memberships }).tasks).toEqual([ + { entryId: "a", challengeId: "general", sponsor: false }, + { entryId: "a", challengeId: "mongo", sponsor: true }, + { entryId: "b", challengeId: "general", sponsor: false }, + ]); + }); + + it("counts a challenge whose parent link loops as unscheduled", () => { + expect([ + ...scheduledChallengeIds([ + { id: "x", parentId: "y", mode: "scheduled", isGeneral: false }, + { id: "y", parentId: "x", mode: "scheduled", isGeneral: false }, + ]), + ]).toEqual([]); + }); +}); + +describe("fingerprint and candidate key", () => { + const plan = selectPlan({ challenges, rooms, memberships }); + + it("puts the timing on the grid", () => { + expect(planInputs(plan, timing)).toMatchObject({ + slots: 12, + sameGap: 1, + crossGap: 2, + }); + }); + + it("ignores input order but not content", () => { + const shuffled = { + rooms: [...plan.rooms].reverse(), + tasks: [...plan.tasks].reverse(), + }; + expect(planFingerprint(shuffled, timing)).toBe(planFingerprint(plan, timing)); + expect( + planFingerprint({ ...plan, tasks: plan.tasks.slice(1) }, timing), + ).not.toBe(planFingerprint(plan, timing)); + expect( + planFingerprint(plan, { ...timing, crossBuildingBreakMinutes: 30 }), + ).not.toBe(planFingerprint(plan, timing)); + }); + + it("changes with any placement or the relaxed flag", () => { + const fp = planFingerprint(plan, timing); + const placements = [ + { entryId: "a", challengeId: "general", roomId: "r1", slot: 3 }, + { entryId: "b", challengeId: "general", roomId: "r1", slot: 0 }, + ]; + const key = candidateKey(fp, false, placements); + expect(key).toMatch(/^[0-9a-f]{64}$/); + expect(candidateKey(fp, false, [...placements].reverse())).toBe(key); + expect(candidateKey(fp, true, placements)).not.toBe(key); + expect( + candidateKey(fp, false, [{ ...placements[0]!, slot: 4 }, placements[1]!]), + ).not.toBe(key); + }); +}); diff --git a/packages/api/src/services/judging/schedule/inputs.ts b/packages/api/src/services/judging/schedule/inputs.ts new file mode 100644 index 00000000..b313a523 --- /dev/null +++ b/packages/api/src/services/judging/schedule/inputs.ts @@ -0,0 +1,174 @@ +import { createHash } from "node:crypto"; +import { isSponsorChallenge, rootOf } from "../scope"; +import type { ChallengeMode } from "../scope"; +import { gridOf } from "./slots"; +import type { ScheduleTiming } from "./slots"; + +// What the solver is given. Only rooms that can host a timed presentation +// count: not archived, in a building (travel time depends on it), and serving +// a top-level challenge judged on the schedule. A task is one project +// presenting for one such challenge. + +export type PlanRoom = { id: string; challengeId: string; buildingId: string }; + +/** `sponsor` is every challenge but the general ones; those finish first. */ +export type PlanTask = { entryId: string; challengeId: string; sponsor: boolean }; + +export type PlanInputs = { + rooms: PlanRoom[]; + tasks: PlanTask[]; + slots: number; + /** Empty slots between two presentations in the same building. */ + sameGap: number; + /** Empty slots when the project walks to another building. */ + crossGap: number; +}; + +/** One task in one room at one slot. */ +export type Placement = { + entryId: string; + challengeId: string; + roomId: string; + slot: number; +}; + +export type SourceChallenge = { + id: string; + parentId: string | null; + mode: ChallengeMode; + isGeneral: boolean; +}; + +export type SourceRoom = { + id: string; + challengeId: string; + buildingId: string | null; + archivedAt: Date | null; +}; + +/** A judging_entry_challenge row whose entry is not deleted. */ +export type SourceMembership = { entryId: string; challengeId: string }; + +export const taskKey = (entryId: string, challengeId: string) => + `${entryId}:${challengeId}`; + +const byId = (a: { id: string }, b: { id: string }) => + a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + +const byTask = (a: PlanTask, b: PlanTask) => + a.entryId !== b.entryId + ? a.entryId < b.entryId + ? -1 + : 1 + : a.challengeId < b.challengeId + ? -1 + : a.challengeId > b.challengeId + ? 1 + : 0; + +/** Ids of the challenges that get timed appointments. */ +export function scheduledChallengeIds( + challenges: readonly SourceChallenge[], +): Set { + return new Set( + challenges + .filter( + (c) => c.mode === "scheduled" && rootOf(challenges, c.id)?.id === c.id, + ) + .map((c) => c.id), + ); +} + +/** Rooms and tasks for the solver, sorted by id so its output is stable. */ +export function selectPlan(source: { + challenges: readonly SourceChallenge[]; + rooms: readonly SourceRoom[]; + memberships: readonly SourceMembership[]; +}): { rooms: PlanRoom[]; tasks: PlanTask[] } { + const scheduled = scheduledChallengeIds(source.challenges); + const sponsor = new Set( + source.challenges.filter(isSponsorChallenge).map((c) => c.id), + ); + + const rooms = source.rooms + .filter( + (room) => + !room.archivedAt && !!room.buildingId && scheduled.has(room.challengeId), + ) + .map((room) => ({ + id: room.id, + challengeId: room.challengeId, + buildingId: room.buildingId!, + })) + .sort(byId); + + const seen = new Set(); + const tasks: PlanTask[] = []; + for (const row of source.memberships) { + const key = taskKey(row.entryId, row.challengeId); + if (!scheduled.has(row.challengeId) || seen.has(key)) continue; + seen.add(key); + tasks.push({ + entryId: row.entryId, + challengeId: row.challengeId, + sponsor: sponsor.has(row.challengeId), + }); + } + tasks.sort(byTask); + + return { rooms, tasks }; +} + +export function planInputs( + plan: { rooms: PlanRoom[]; tasks: PlanTask[] }, + timing: ScheduleTiming, +): PlanInputs { + return { ...plan, ...gridOf(timing) }; +} + +const sha256 = (value: unknown) => + createHash("sha256").update(JSON.stringify(value)).digest("hex"); + +/** + * Everything a solve depends on. Two previews with the same fingerprint have + * the same answer, because the solver is deterministic. + */ +export function planFingerprint( + plan: { rooms: PlanRoom[]; tasks: PlanTask[] }, + timing: ScheduleTiming, +): string { + return sha256({ + timing: [ + timing.windowStart.toISOString(), + timing.windowEnd.toISOString(), + timing.setupMinutes, + timing.judgingMinutes, + timing.teardownMinutes, + timing.sameBuildingBreakMinutes, + timing.crossBuildingBreakMinutes, + ], + rooms: [...plan.rooms] + .sort(byId) + .map((r) => [r.id, r.challengeId, r.buildingId]), + tasks: [...plan.tasks] + .sort(byTask) + .map((t) => [t.entryId, t.challengeId, t.sponsor]), + }); +} + +/** + * Binds a save to the exact schedule the organiser reviewed: the inputs it + * came from, whether breaks were shortened, and every placement. + */ +export function candidateKey( + fingerprint: string, + relaxed: boolean, + placements: readonly Placement[], +): string { + const rows = placements + .map((p) => [p.entryId, p.challengeId, p.roomId, p.slot] as const) + .sort((a, b) => + a[0] !== b[0] ? (a[0] < b[0] ? -1 : 1) : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0, + ); + return sha256({ fingerprint, relaxed, rows }); +} diff --git a/packages/api/src/services/judging/schedule/judge-plan.test.ts b/packages/api/src/services/judging/schedule/judge-plan.test.ts new file mode 100644 index 00000000..74edd1d0 --- /dev/null +++ b/packages/api/src/services/judging/schedule/judge-plan.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from "vitest"; +import { + judgeChanges, + judgeChoices, + judgePlanOf, + rosterOf, +} from "./judge-plan"; +import type { JudgeRow, SavedAppointment } from "./judge-plan"; +import type { ChallengeRow } from "../evaluation-store"; + +const challenge = ( + id: string, + over: Partial = {}, +): ChallengeRow => ({ + id, + parentId: null, + mode: "scheduled", + label: id, + isGeneral: false, + isGroup: false, + ...over, +}); + +// General and one sponsor are scheduled; MLH is a group of two unscheduled prizes. +const challenges: ChallengeRow[] = [ + challenge("general", { isGeneral: true }), + challenge("acme"), + challenge("mlh", { isGroup: true, mode: "unscheduled" }), + challenge("mongo", { parentId: "mlh", mode: "unscheduled" }), +]; + +const rooms = [ + { id: "g1", challengeId: "general", buildingId: "ENG" }, + { id: "a1", challengeId: "acme", buildingId: "HEC" }, +]; + +const judgeRows: JudgeRow[] = [ + { id: "m1", kind: "member", userId: "u1", displayName: "Ada" }, + { id: "gs", kind: "guest", userId: null, displayName: "Grace" }, + { id: "lost", kind: "guest", userId: null, displayName: "Nobody" }, +]; + +describe("rosterOf", () => { + const roster = (over: Partial[0]> = {}) => + rosterOf({ + hackathonId: "h", + judges: judgeRows, + seats: [ + { + judgeId: "gs", + roomId: "a1", + createdAt: new Date("2027-02-06T12:00:00Z"), + }, + ], + challenges, + rooms, + ...over, + }); + + it("lets a member grade every scheduled challenge and a guest only their room's", () => { + const { judges } = roster(); + expect(judges).toEqual([ + { id: "gs", kind: "guest", challengeIds: ["acme"], roomId: "a1" }, + { + id: "m1", + kind: "member", + challengeIds: ["acme", "general"], + roomId: null, + }, + ]); + }); + + it("leaves out a guest with no session, or whose room takes no appointments", () => { + expect(roster({ seats: [] }).judges.map((j) => j.id)).toEqual(["m1"]); + expect( + roster({ + seats: [ + { judgeId: "gs", roomId: "unscheduled-room", createdAt: new Date() }, + ], + }).judges.map((j) => j.id), + ).toEqual(["m1"]); + }); + + it("seats a guest in the room of their latest session", () => { + const { judges } = roster({ + seats: [ + { + judgeId: "gs", + roomId: "g1", + createdAt: new Date("2027-02-06T09:00:00Z"), + }, + { + judgeId: "gs", + roomId: "a1", + createdAt: new Date("2027-02-06T13:00:00Z"), + }, + { + judgeId: "gs", + roomId: "g1", + createdAt: new Date("2027-02-06T11:00:00Z"), + }, + ], + }); + expect(judges.find((j) => j.id === "gs")).toMatchObject({ + roomId: "a1", + challengeIds: ["acme"], + }); + }); + + it("names every judge, including those it cannot assign", () => { + expect(roster().names.get("lost")).toBe("Nobody"); + }); +}); + +const appointment = ( + id: string, + over: Partial = {}, +): SavedAppointment => ({ + id, + entryId: `e-${id}`, + challengeId: "general", + roomId: "g1", + slot: 0, + assignedJudgeId: null, + removed: false, + open: true, + ...over, +}); + +describe("judgePlanOf", () => { + const plan = (appointments: SavedAppointment[]) => + judgePlanOf({ + rooms: [ + ...rooms, + { id: "old", challengeId: "general", buildingId: null }, + ], + sponsorChallengeIds: new Set(["acme"]), + grid: { slots: 10, sameGap: 1, crossGap: 2 }, + appointments, + judgeIds: new Set(["m1", "m2"]), + }); + + it("plans the appointments that are open or already have a judge", () => { + const result = plan([ + appointment("open"), + appointment("kept", { open: false, assignedJudgeId: "m1", slot: 3 }), + appointment("under-way", { open: false, slot: 5 }), + appointment("gone", { removed: true, assignedJudgeId: "m1", slot: 6 }), + ]); + expect(result.placements.map((p) => p.entryId)).toEqual([ + "e-open", + "e-kept", + ]); + expect(result.inputs.tasks).toHaveLength(2); + expect(result.keep).toEqual([ + { entryId: "e-kept", challengeId: "general", judgeId: "m1" }, + ]); + expect(result.current).toEqual(result.keep); + }); + + it("marks sponsor challenges and gives a room without a building its own", () => { + const result = plan([ + appointment("s", { challengeId: "acme", roomId: "a1" }), + ]); + expect(result.inputs.tasks[0]).toMatchObject({ sponsor: true }); + expect(result.inputs.rooms.find((r) => r.id === "old")!.buildingId).toBe( + "room:old", + ); + expect(result.inputs).toMatchObject({ slots: 10, sameGap: 1, crossGap: 2 }); + }); + + it("counts a judge on an open appointment as current, not kept", () => { + const result = plan([appointment("o", { assignedJudgeId: "m1" })]); + expect(result.keep).toEqual([]); + expect(result.current).toHaveLength(1); + }); + + it("drops an appointment under way whose judge has left the roster", () => { + // Kept, it would hold a presentation for a judge nobody can plan around, + // and CP-SAT's answer would be refused for not keeping it. + const result = plan([ + appointment("gone-judge", { + open: false, + assignedJudgeId: "g9", + slot: 2, + }), + appointment("open-gone-judge", { assignedJudgeId: "g9", slot: 4 }), + ]); + expect(result.placements.map((p) => p.entryId)).toEqual([ + "e-open-gone-judge", + ]); + expect(result.keep).toEqual([]); + }); +}); + +describe("judgeChanges", () => { + it("lists the open appointments whose judge differs, null where nobody is left", () => { + const appointments = [ + appointment("same", { assignedJudgeId: "m1" }), + appointment("new"), + appointment("moved", { assignedJudgeId: "m1" }), + appointment("dropped", { assignedJudgeId: "m1" }), + appointment("closed", { open: false, assignedJudgeId: "m1" }), + ]; + const changes = judgeChanges(appointments, [ + { entryId: "e-same", challengeId: "general", judgeId: "m1" }, + { entryId: "e-new", challengeId: "general", judgeId: "m2" }, + { entryId: "e-moved", challengeId: "general", judgeId: "m2" }, + { entryId: "e-closed", challengeId: "general", judgeId: "m2" }, + ]); + expect(changes).toEqual([ + { id: "new", assignedJudgeId: "m2" }, + { id: "moved", assignedJudgeId: "m2" }, + { id: "dropped", assignedJudgeId: null }, + ]); + }); +}); + +describe("judgeChoices", () => { + const roster = rosterOf({ + hackathonId: "h", + judges: judgeRows, + seats: [{ judgeId: "gs", roomId: "a1", createdAt: new Date() }], + challenges, + rooms, + }); + + it("offers the judges who may grade the appointment there, and marks the booked", () => { + const target = appointment("t", { slot: 2 }); + const all = [ + target, + appointment("other", { + roomId: "a1", + challengeId: "acme", + slot: 2, + assignedJudgeId: "m1", + }), + ]; + expect(judgeChoices(roster, target, all)).toEqual([ + { judgeId: "m1", bookedWith: "other" }, + ]); + // Free once their other appointment is at another time. + all[1] = { ...all[1]!, slot: 4 }; + expect(judgeChoices(roster, target, all)).toEqual([ + { judgeId: "m1", bookedWith: null }, + ]); + }); + + it("offers a guest only their own room", () => { + const sponsor = appointment("s", { challengeId: "acme", roomId: "a1" }); + expect( + judgeChoices(roster, sponsor, [sponsor]).map((c) => c.judgeId), + ).toEqual(["gs", "m1"]); + }); + + it("ignores a booking for a project no longer entered", () => { + const target = appointment("t", { slot: 2 }); + const gone = appointment("g", { + removed: true, + slot: 2, + assignedJudgeId: "m1", + roomId: "a1", + challengeId: "acme", + }); + expect(judgeChoices(roster, target, [target, gone])[0]).toMatchObject({ + bookedWith: null, + }); + }); +}); diff --git a/packages/api/src/services/judging/schedule/judge-plan.ts b/packages/api/src/services/judging/schedule/judge-plan.ts new file mode 100644 index 00000000..24169e4c --- /dev/null +++ b/packages/api/src/services/judging/schedule/judge-plan.ts @@ -0,0 +1,222 @@ +import { inJudgeScope } from "../evaluation-store"; +import type { ChallengeRow } from "../evaluation-store"; +import type { Judge } from "../principal"; +import { scheduledChallengeIds, taskKey } from "./inputs"; +import type { Placement, PlanInputs, PlanRoom } from "./inputs"; +import { mayGrade } from "./judges"; +import type { JudgeAssignment, JudgeInput } from "./judges"; + +// What the router hands the judge assigner and reads back: who can judge, +// which saved appointments are up for assignment, and the numbers the board +// shows. No database in here; the router reads the rows and writes the result. + +export type JudgeRow = { + id: string; + kind: "member" | "guest"; + userId: string | null; + displayName: string; +}; + +/** A guest's session: the room they scanned in at. */ +export type GuestSeat = { judgeId: string; roomId: string; createdAt: Date }; + +export type Roster = { + /** Judges who can be given presentations: members, and guests with a room. */ + judges: JudgeInput[]; + /** Every judge's name, those who cannot be given presentations too. */ + names: Map; +}; + +/** + * The judges an edition can assign. A member may grade whatever the judge + * desk lets them see; a guest only their room's challenge (inJudgeScope), + * and only in that room, which is the room of their latest session. A guest + * with no session, or whose room takes no appointments, has nothing to grade. + */ +export function rosterOf(source: { + hackathonId: string; + judges: readonly JudgeRow[]; + /** Sessions that have not been revoked. */ + seats: readonly GuestSeat[]; + challenges: readonly ChallengeRow[]; + /** The rooms that take appointments. */ + rooms: readonly PlanRoom[]; +}): Roster { + const scheduled = [...scheduledChallengeIds(source.challenges)].sort(); + const rooms = new Map(source.rooms.map((room) => [room.id, room])); + + const seatOf = new Map(); + for (const seat of source.seats) { + const current = seatOf.get(seat.judgeId); + if (!current || seat.createdAt > current.createdAt) + seatOf.set(seat.judgeId, seat); + } + + const judges: JudgeInput[] = []; + for (const row of source.judges) { + if (row.kind === "member") { + const principal: Judge = { + kind: "member", + hackathonId: source.hackathonId, + userId: row.userId ?? "", + judgeId: row.id, + displayName: row.displayName, + }; + judges.push({ + id: row.id, + kind: "member", + challengeIds: scheduled.filter((id) => + inJudgeScope(principal, source.challenges, id), + ), + roomId: null, + }); + continue; + } + const room = rooms.get(seatOf.get(row.id)?.roomId ?? ""); + if (!room) continue; + const principal: Judge = { + kind: "guest", + hackathonId: source.hackathonId, + sessionId: "", + linkId: "", + roomId: room.id, + roomChallengeId: room.challengeId, + judgeId: row.id, + displayName: row.displayName, + }; + judges.push({ + id: row.id, + kind: "guest", + challengeIds: scheduled.filter((id) => + inJudgeScope(principal, source.challenges, id), + ), + roomId: room.id, + }); + } + judges.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + + return { + judges, + names: new Map(source.judges.map((row) => [row.id, row.displayName])), + }; +} + +export type SavedAppointment = { + id: string; + entryId: string; + challengeId: string; + roomId: string; + slot: number; + assignedJudgeId: string | null; + /** Entered no more: needs no judge. */ + removed: boolean; + /** Not started, nothing evaluated or drafted: its judge may be chosen anew. */ + open: boolean; +}; + +/** + * The saved schedule as the judge assigner takes it: the appointments still + * entered that are open or already have a judge. `keep` is the judges of those + * that are not open, which stay; `current` is every judge there is now. + */ +export function judgePlanOf(source: { + /** Every room, archived ones too; a room with no building counts as its own. */ + rooms: readonly { + id: string; + challengeId: string; + buildingId: string | null; + }[]; + sponsorChallengeIds: ReadonlySet; + grid: Pick; + appointments: readonly SavedAppointment[]; + /** The judges on the roster: those who can be given presentations. */ + judgeIds: ReadonlySet; +}) { + // One that is over or under way with nobody assigned has no judge to find, + // and one whose judge has left the roster ties up nobody who is still on it. + const live = source.appointments.filter( + (a) => + !a.removed && + (a.open || + (a.assignedJudgeId !== null && source.judgeIds.has(a.assignedJudgeId))), + ); + const inputs: PlanInputs = { + ...source.grid, + rooms: source.rooms.map((room) => ({ + id: room.id, + challengeId: room.challengeId, + buildingId: room.buildingId ?? `room:${room.id}`, + })), + tasks: live.map((a) => ({ + entryId: a.entryId, + challengeId: a.challengeId, + sponsor: source.sponsorChallengeIds.has(a.challengeId), + })), + }; + const placements: Placement[] = live.map((a) => ({ + entryId: a.entryId, + challengeId: a.challengeId, + roomId: a.roomId, + slot: a.slot, + })); + const given = (a: SavedAppointment): JudgeAssignment => ({ + entryId: a.entryId, + challengeId: a.challengeId, + judgeId: a.assignedJudgeId!, + }); + return { + inputs, + placements, + keep: live.filter((a) => !a.open && a.assignedJudgeId).map(given), + current: live.filter((a) => a.assignedJudgeId).map(given), + }; +} + +/** + * What `assignment` changes on the open appointments: the new judge of every + * one whose judge differs, null where it now has none. + */ +export function judgeChanges( + appointments: readonly SavedAppointment[], + assignment: readonly JudgeAssignment[], +): { id: string; assignedJudgeId: string | null }[] { + const chosen = new Map( + assignment.map((a) => [taskKey(a.entryId, a.challengeId), a.judgeId]), + ); + return appointments + .filter((a) => a.open && !a.removed) + .flatMap((a) => { + const assignedJudgeId = + chosen.get(taskKey(a.entryId, a.challengeId)) ?? null; + return assignedJudgeId === a.assignedJudgeId + ? [] + : [{ id: a.id, assignedJudgeId }]; + }); +} + +export type JudgeChoice = { + judgeId: string; + /** The appointment of theirs in the same slot, if they are already booked. */ + bookedWith: string | null; +}; + +/** Judges who may grade `appointment`, and whether each is free in its slot. */ +export function judgeChoices( + roster: Roster, + appointment: Pick, + all: readonly SavedAppointment[], +): JudgeChoice[] { + return roster.judges + .filter((judge) => mayGrade(judge, appointment)) + .map((judge) => ({ + judgeId: judge.id, + bookedWith: + all.find( + (other) => + other.id !== appointment.id && + !other.removed && + other.assignedJudgeId === judge.id && + other.slot === appointment.slot, + )?.id ?? null, + })); +} diff --git a/packages/api/src/services/judging/schedule/judge-progress.test.ts b/packages/api/src/services/judging/schedule/judge-progress.test.ts new file mode 100644 index 00000000..aa27a690 --- /dev/null +++ b/packages/api/src/services/judging/schedule/judge-progress.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "vitest"; +import { DEADLINE_GRACE_MS, takeoverAt } from "../timing"; +import { judgeProgress, judgeProgressById } from "./judge-progress"; + +const at = (time: string) => new Date(`2027-02-08T${time}:00Z`); +const startsAt = at("15:00"); +const deadlineAt = at("15:20"); +const JUDGE = "ada"; +const OTHER = "bea"; + +const input = (over: Partial[0]> = {}) => ({ + startsAt, + deadlineAt, + assignedJudgeId: JUDGE, + evaluations: [], + drafts: [], + ...over, +}); +// The desk stamps a presentation's draft with its deadline. +const draft = ( + judgeId: string, + time = "15:02", + draftDeadline: Date | null = deadlineAt, +) => ({ + judgeId, + updatedAt: at(time), + deadlineAt: draftDeadline, +}); + +describe("judgeProgress", () => { + it("says nothing without an assigned judge", () => { + expect( + judgeProgress( + input({ assignedJudgeId: null, evaluations: [{ judgeId: OTHER }] }), + at("16:00"), + ), + ).toBeNull(); + }); + + it("says nothing before the presentation starts", () => { + expect(judgeProgress(input(), at("14:59"))).toBeNull(); + // A judge cannot have begun, so nothing could show anyway. + expect( + judgeProgress(input({ drafts: [draft(JUDGE)] }), at("14:59")), + ).toBeNull(); + }); + + it("says nothing while the assigned judge still has the presentation to themselves", () => { + expect(judgeProgress(input(), at("15:00"))).toBeNull(); + expect(judgeProgress(input(), at("15:04"))).toBeNull(); + }); + + it("flags an assigned judge who has not begun once the takeover time passes", () => { + // The time the judge desk opens the presentation to everyone else. + const opens = takeoverAt(startsAt); + expect(judgeProgress(input(), new Date(opens.getTime() - 1))).toBeNull(); + expect(judgeProgress(input(), opens)).toBe("not_started"); + expect(judgeProgress(input(), at("15:05"))).toBe("not_started"); + // And stays so once the slot is over. + expect(judgeProgress(input(), at("18:00"))).toBe("not_started"); + }); + + it("is grading while the assigned judge has a draft open", () => { + expect(judgeProgress(input({ drafts: [draft(JUDGE)] }), at("15:01"))).toBe( + "grading", + ); + // Past the takeover time too: they began, so nothing was missed. + expect(judgeProgress(input({ drafts: [draft(JUDGE)] }), at("15:30"))).toBe( + "grading", + ); + }); + + it("is graded once the assigned judge has submitted", () => { + expect( + judgeProgress(input({ evaluations: [{ judgeId: JUDGE }] }), at("15:08")), + ).toBe("graded"); + // An evaluation outranks a draft left beside it. + expect( + judgeProgress( + input({ evaluations: [{ judgeId: JUDGE }], drafts: [draft(JUDGE)] }), + at("15:08"), + ), + ).toBe("graded"); + }); + + it("is taken over when another judge has submitted or has a draft", () => { + expect( + judgeProgress(input({ evaluations: [{ judgeId: OTHER }] }), at("15:09")), + ).toBe("taken_over"); + expect(judgeProgress(input({ drafts: [draft(OTHER)] }), at("15:09"))).toBe( + "taken_over", + ); + }); + + it("reads the judge who saved a draft last as the one who has it", () => { + // Ada's draft was left alone and Bea took over; both rows remain. + expect( + judgeProgress( + input({ drafts: [draft(JUDGE, "15:01"), draft(OTHER, "15:12")] }), + at("15:13"), + ), + ).toBe("taken_over"); + // Or Ada came back and saved after Bea. + expect( + judgeProgress( + input({ drafts: [draft(OTHER, "15:12"), draft(JUDGE, "15:20")] }), + at("15:21"), + ), + ).toBe("grading"); + }); + + it("stops counting a draft that lost its deadline once judging for the presentation is over", () => { + // The deadline and the grace a last write gets. + const over = new Date(deadlineAt.getTime() + DEADLINE_GRACE_MS); + const before = new Date(over.getTime() - 1); + // Opened and left empty: it is kept without its deadline and never + // submitted. + const empty = input({ drafts: [draft(JUDGE, "15:02", null)] }); + expect(judgeProgress(empty, before)).toBe("grading"); + expect(judgeProgress(empty, over)).toBe("not_started"); + // Another judge's, likewise: nobody took it over. + expect( + judgeProgress(input({ drafts: [draft(OTHER, "15:12", null)] }), over), + ).toBe("not_started"); + // One still carrying its deadline is about to be submitted for its judge. + expect(judgeProgress(input({ drafts: [draft(JUDGE)] }), over)).toBe( + "grading", + ); + expect( + judgeProgress(input({ drafts: [draft(OTHER, "15:12")] }), over), + ).toBe("taken_over"); + }); +}); + +describe("judgeProgressById", () => { + const roots: Record = { + general: "general", + "general-ai": "general", + acme: "acme", + }; + const rootIdOf = (id: string) => roots[id] ?? null; + const appointment = (over: Record = {}) => ({ + id: "apt", + entryId: "e1", + challengeId: "general", + startsAt, + deadlineAt, + assignedJudgeId: JUDGE, + ...over, + }); + const row = (over: Record = {}) => ({ + judgeId: JUDGE, + entryId: "e1", + challengeId: "general", + appointmentId: null as string | null, + ...over, + }); + const run = ( + appointments: ReturnType[], + evaluations: ReturnType[], + drafts: (ReturnType & { + updatedAt: Date; + deadlineAt: Date | null; + })[], + now = at("15:30"), + ) => judgeProgressById(appointments, evaluations, drafts, rootIdOf, now); + + it("matches by appointment, or by project and challenge root when none is named", () => { + const progress = run( + [appointment()], + [ + // Another judge's, on a challenge under the appointment's root. + row({ judgeId: OTHER, challengeId: "general-ai" }), + ], + [], + ); + expect(progress.get("apt")).toBe("taken_over"); + + // Written against another appointment, another project, another root. + const unrelated = run( + [appointment()], + [ + row({ appointmentId: "other" }), + row({ entryId: "e2" }), + row({ challengeId: "acme" }), + ], + [], + ); + expect(unrelated.get("apt")).toBe("not_started"); + + const named = run( + [appointment()], + [row({ appointmentId: "apt", challengeId: "acme" })], + [], + ); + expect(named.get("apt")).toBe("graded"); + }); + + it("reads each appointment on its own rows, and leaves out the ones with nothing to say", () => { + const progress = run( + [ + appointment({ id: "a1", entryId: "e1" }), + appointment({ id: "a2", entryId: "e2", assignedJudgeId: OTHER }), + appointment({ id: "a3", entryId: "e3", assignedJudgeId: null }), + appointment({ id: "a4", entryId: "e4", startsAt: at("16:00") }), + ], + [row({ entryId: "e1" })], + [ + { + ...row({ entryId: "e2", judgeId: OTHER }), + updatedAt: at("15:10"), + deadlineAt, + }, + ], + ); + expect(Object.fromEntries(progress)).toEqual({ + a1: "graded", + a2: "grading", + }); + }); +}); diff --git a/packages/api/src/services/judging/schedule/judge-progress.ts b/packages/api/src/services/judging/schedule/judge-progress.ts new file mode 100644 index 00000000..d6533eaf --- /dev/null +++ b/packages/api/src/services/judging/schedule/judge-progress.ts @@ -0,0 +1,130 @@ +import { DEADLINE_GRACE_MS, takeoverAt } from "../timing"; +import { belongsTo } from "./board"; + +// How far the judge assigned to a presentation has got, for the organiser's +// live board. Pure: the router loads the rows, this decides. Organisers see +// judge names on the board already, but this says only whether the assigned +// judge or someone else is on the presentation, not who. +// +// From the presentation's start its assigned judge has until its takeover time +// (timing.ts takeoverAt) to begin. After that any judge who may grade it can +// take it over, which the judge desk allows; the assignment itself is never +// rewritten. An evaluation or a draft is the sign a judge has begun. +// +// Once the deadline and the grace a last write gets have passed, a draft still +// carrying its deadline is about to be submitted for its judge +// (evaluation-store reconcileDueDrafts). One that lost it never will be: it +// was empty, or could not be submitted, so it no longer counts as a start. + +/** + * Where a started presentation's grading stands against its assigned judge: + * "grading" (their draft is open), "graded" (they submitted), "taken_over" + * (another judge has a draft or an evaluation), "not_started" (nobody has + * begun and the takeover time has passed). + */ +export type JudgeProgress = "grading" | "graded" | "taken_over" | "not_started"; + +type Started = { judgeId: string }; +type Draft = Started & { updatedAt: Date; deadlineAt: Date | null }; + +/** + * Null when there is nothing to say yet: no assigned judge, a presentation + * that has not started, or one nobody has begun within the takeover window. + */ +export function judgeProgress( + input: { + startsAt: Date; + /** The presentation's deadline: judging for it ends here. */ + deadlineAt: Date; + assignedJudgeId: string | null; + /** Judges who submitted an evaluation of the presentation. */ + evaluations: readonly Started[]; + /** Judges with a draft of it open. */ + drafts: readonly Draft[]; + }, + now: Date, +): JudgeProgress | null { + const { assignedJudgeId } = input; + if (!assignedJudgeId || now < input.startsAt) return null; + + if (input.evaluations.length > 0) { + return input.evaluations.some((e) => e.judgeId === assignedJudgeId) + ? "graded" + : "taken_over"; + } + const ended = now.getTime() >= input.deadlineAt.getTime() + DEADLINE_GRACE_MS; + const drafts = ended + ? input.drafts.filter((draft) => draft.deadlineAt !== null) + : input.drafts; + // Drafts a judge left behind when another took over stay in the table, so + // the one saved last is the judge who has it. + const holder = drafts.reduce( + (latest, draft) => + !latest || draft.updatedAt > latest.updatedAt ? draft : latest, + null, + ); + if (holder) + return holder.judgeId === assignedJudgeId ? "grading" : "taken_over"; + + return now >= takeoverAt(input.startsAt) ? "not_started" : null; +} + +type Booked = { + id: string; + entryId: string; + challengeId: string; + startsAt: Date; + deadlineAt: Date; + assignedJudgeId: string | null; +}; + +type Row = { + entryId: string; + challengeId: string; + appointmentId: string | null; +}; + +/** + * The progress of every appointment that has something to say, by id. One pass + * over the edition's evaluations and drafts, matched to appointments the way + * the board matches them (board.ts belongsTo). + */ +export function judgeProgressById( + appointments: readonly Booked[], + evaluations: readonly (Row & Started)[], + drafts: readonly (Row & Draft)[], + rootIdOf: (challengeId: string) => string | null, + now: Date, +): Map { + const byEntry = (rows: readonly T[]) => { + const map = new Map(); + for (const row of rows) { + const list = map.get(row.entryId); + if (list) list.push(row); + else map.set(row.entryId, [row]); + } + return map; + }; + const evaluationsOf = byEntry(evaluations); + const draftsOf = byEntry(drafts); + + const result = new Map(); + for (const appointment of appointments) { + const mine = (rows: Map) => + (rows.get(appointment.entryId) ?? []).filter((row) => + belongsTo(appointment, row, rootIdOf), + ); + const progress = judgeProgress( + { + startsAt: appointment.startsAt, + deadlineAt: appointment.deadlineAt, + assignedJudgeId: appointment.assignedJudgeId, + evaluations: mine(evaluationsOf), + drafts: mine(draftsOf), + }, + now, + ); + if (progress) result.set(appointment.id, progress); + } + return result; +} diff --git a/packages/api/src/services/judging/schedule/judges.test.ts b/packages/api/src/services/judging/schedule/judges.test.ts new file mode 100644 index 00000000..dc66a53c --- /dev/null +++ b/packages/api/src/services/judging/schedule/judges.test.ts @@ -0,0 +1,532 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_CONSECUTIVE, + assignJudges, + compareJudgeMetrics, + describeShortfall, + judgeShortfalls, + mayGrade, + scoreJudgeAssignment, + validateJudgeAssignment, +} from "./judges"; +import type { JudgeAssignment, JudgeInput } from "./judges"; +import type { Placement, PlanInputs } from "./inputs"; + +const inputs: PlanInputs = { + rooms: [ + { id: "g1", challengeId: "general", buildingId: "ENG" }, + { id: "g2", challengeId: "general", buildingId: "ENG" }, + { id: "g3", challengeId: "general", buildingId: "HEC" }, + { id: "s1", challengeId: "sponsor", buildingId: "HEC" }, + ], + tasks: [ + { entryId: "a", challengeId: "general", sponsor: false }, + { entryId: "b", challengeId: "general", sponsor: false }, + { entryId: "c", challengeId: "general", sponsor: false }, + { entryId: "d", challengeId: "general", sponsor: false }, + { entryId: "a", challengeId: "sponsor", sponsor: true }, + { entryId: "b", challengeId: "sponsor", sponsor: true }, + ], + slots: 12, + sameGap: 1, + crossGap: 2, +}; + +const placed = ( + entryId: string, + challengeId: string, + roomId: string, + slot: number, +): Placement => ({ entryId, challengeId, roomId, slot }); + +const member = (id: string, extra: Partial = {}): JudgeInput => ({ + id, + kind: "member", + challengeIds: ["general", "sponsor"], + roomId: null, + ...extra, +}); + +const guest = ( + id: string, + roomId: string | null, + challengeIds = ["sponsor"], +): JudgeInput => ({ id, kind: "guest", challengeIds, roomId }); + +const row = ( + entryId: string, + challengeId: string, + judgeId: string, +): JudgeAssignment => ({ entryId, challengeId, judgeId }); + +const placements: Placement[] = [ + placed("a", "general", "g1", 0), + placed("b", "general", "g2", 0), + placed("c", "general", "g1", 3), + placed("d", "general", "g3", 3), + placed("a", "sponsor", "s1", 6), + placed("b", "sponsor", "s1", 7), +]; + +describe("mayGrade", () => { + it("lets a member grade the challenges they are given, in any room", () => { + const judge = member("m", { challengeIds: ["general"] }); + expect(mayGrade(judge, { challengeId: "general", roomId: "g2" })).toBe( + true, + ); + expect(mayGrade(judge, { challengeId: "sponsor", roomId: "s1" })).toBe( + false, + ); + }); + + it("keeps a guest to their own room, and a guest with no room out", () => { + expect( + mayGrade(guest("x", "s1"), { challengeId: "sponsor", roomId: "s1" }), + ).toBe(true); + expect( + mayGrade(guest("x", "s1"), { challengeId: "sponsor", roomId: "s2" }), + ).toBe(false); + expect( + mayGrade(guest("x", null), { challengeId: "sponsor", roomId: "s1" }), + ).toBe(false); + }); +}); + +describe("validateJudgeAssignment", () => { + const check = ( + judges: JudgeInput[], + assignment: JudgeAssignment[], + spots = placements, + ) => validateJudgeAssignment(inputs, spots, judges, assignment); + + it("accepts a judge who keeps every rule, and does not mind presentations left out", () => { + const judges = [member("m1"), guest("g", "s1")]; + const result = check(judges, [ + row("a", "general", "m1"), + row("c", "general", "m1"), + row("a", "sponsor", "g"), + ]); + expect(result).toEqual({ valid: true, problems: [] }); + }); + + it("refuses a challenge or a room the judge may not grade", () => { + const judges = [ + member("m1", { challengeIds: ["general"] }), + guest("g", "s1"), + ]; + const problems = check(judges, [ + row("a", "sponsor", "m1"), + row("a", "general", "g"), + ]).problems.join(" "); + expect(problems).toContain( + "Judge m1 may not grade a:sponsor: wrong challenge.", + ); + expect(problems).toContain( + "Judge g may not grade a:general: wrong challenge.", + ); + const wrongRoom = check( + [guest("g", "s1", ["general", "sponsor"])], + [row("a", "general", "g")], + ); + expect(wrongRoom.problems).toEqual([ + "Judge g may not grade a:general: wrong room.", + ]); + }); + + it("refuses unknown presentations and judges, and two judges for one presentation", () => { + const { problems } = check( + [member("m1"), member("m2")], + [ + row("zz", "general", "m1"), + row("a", "general", "nobody"), + row("c", "general", "m1"), + row("c", "general", "m2"), + ], + ); + expect(problems).toContain("No such presentation: zz:general."); + expect(problems).toContain("No such judge: nobody."); + expect(problems).toContain("c:general has more than one judge."); + }); + + it("refuses a judge in two rooms at once", () => { + const { problems } = check( + [member("m1")], + [row("a", "general", "m1"), row("b", "general", "m1")], + ); + expect(problems).toEqual(["Judge m1 is booked twice in slot 1."]); + }); + + it("needs the same-building break to change rooms and the longer one to change buildings", () => { + const both = (second: Placement) => + validateJudgeAssignment( + inputs, + [placed("a", "general", "g1", 0), second], + [member("m1")], + [ + row("a", "general", "m1"), + row(second.entryId, second.challengeId, "m1"), + ], + ); + + // g1 to g2 is one building: one free slot between (slots 0 and 2). + expect(both(placed("b", "general", "g2", 1)).problems).toHaveLength(1); + expect(both(placed("b", "general", "g2", 2)).valid).toBe(true); + // g1 to g3 is another building: two free slots between. + expect(both(placed("b", "general", "g3", 2)).problems).toHaveLength(1); + expect(both(placed("b", "general", "g3", 3)).valid).toBe(true); + }); + + it("lets a judge stay in one room without a break, up to the run limit", () => { + const wide: PlanInputs = { + ...inputs, + tasks: ["a", "b", "c", "d", "e", "f"].map((entryId) => ({ + entryId, + challengeId: "general", + sponsor: false, + })), + }; + const run = (count: number, judge: JudgeInput) => { + const spots = ["a", "b", "c", "d", "e", "f"] + .slice(0, count) + .map((entryId, slot) => placed(entryId, "general", "g1", slot)); + return validateJudgeAssignment( + wide, + spots, + [judge], + spots.map((p) => row(p.entryId, "general", judge.id)), + ); + }; + expect(MAX_CONSECUTIVE).toBe(4); + expect(run(4, member("m1")).valid).toBe(true); + expect(run(5, member("m1")).problems).toEqual([ + "Judge m1 grades more than 4 slots in a row.", + ]); + expect(run(5, member("m1", { maxConsecutive: 5 })).valid).toBe(true); + expect(run(6, member("m1", { maxConsecutive: 2 })).valid).toBe(false); + }); + + it("allows a free slot to end a run", () => { + const spots = [0, 1, 2, 3, 5].map((slot, i) => + placed(["a", "b", "c", "d", "e"][i]!, "general", "g1", slot), + ); + const wide: PlanInputs = { + ...inputs, + tasks: spots.map((p) => ({ + entryId: p.entryId, + challengeId: "general", + sponsor: false, + })), + }; + const result = validateJudgeAssignment( + wide, + spots, + [member("m1")], + spots.map((p) => row(p.entryId, "general", "m1")), + ); + expect(result.valid).toBe(true); + }); +}); + +describe("scoreJudgeAssignment", () => { + it("counts what is left out, the loads, the rooms and the parts of the day", () => { + const judges = [member("m1"), member("m2"), guest("g", "s1")]; + const metrics = scoreJudgeAssignment(inputs, placements, judges, [ + row("a", "general", "m1"), + row("c", "general", "m1"), + row("b", "general", "m2"), + row("a", "sponsor", "g"), + row("b", "sponsor", "m2"), + ]); + expect(metrics).toMatchObject({ + unassigned: 1, + memberOnSponsor: 1, + maxLoad: 2, + // Members: 2 against 2; the guest is a pool of one. + loadSpread: 0, + // m1 is in g1 twice. + repeats: 1, + loads: { m1: 2, m2: 2, g: 1 }, + }); + // m1: g1; m2: g2, s1; g: s1. + expect(metrics.avgDistinctRooms).toBeCloseTo(4 / 3); + }); + + it("ranks fewer left out ahead of everything else", () => { + const judges = [member("m1"), member("m2")]; + const some = scoreJudgeAssignment(inputs, placements, judges, [ + row("a", "general", "m1"), + ]); + const more = scoreJudgeAssignment(inputs, placements, judges, [ + row("a", "general", "m1"), + row("c", "general", "m1"), + ]); + expect(compareJudgeMetrics(more, some)).toBeLessThan(0); + expect(compareJudgeMetrics(some, some)).toBe(0); + }); +}); + +describe("assignJudges", () => { + it("gives every presentation a judge who may grade it, keeping the rules", () => { + const judges = [member("m1"), member("m2"), guest("g", "s1")]; + const { assignment, metrics } = assignJudges(inputs, placements, judges); + expect(assignment).toHaveLength(placements.length); + expect(metrics.unassigned).toBe(0); + expect( + validateJudgeAssignment(inputs, placements, judges, assignment), + ).toEqual({ + valid: true, + problems: [], + }); + }); + + it("leaves sponsor presentations to the sponsor's judge while that judge can take them", () => { + const judges = [member("m1"), member("m2"), guest("g", "s1")]; + const { assignment, metrics } = assignJudges(inputs, placements, judges); + const sponsor = assignment.filter((r) => r.challengeId === "sponsor"); + expect(sponsor.map((r) => r.judgeId)).toEqual(["g", "g"]); + expect(metrics.memberOnSponsor).toBe(0); + }); + + it("asks a table judge to cover the sponsor when no sponsor judge can", () => { + // Both sponsor presentations at once: the one guest can take only one. + const clash = [ + placed("a", "sponsor", "s1", 6), + placed("b", "sponsor", "s1", 7), + placed("a", "general", "g1", 0), + ]; + const tight = { ...inputs, sameGap: 1 }; + const judges = [member("m1"), guest("g", "s1")]; + const { assignment, metrics } = assignJudges(tight, clash, judges); + // Back to back in one room needs no break: the guest takes both. + expect(assignment.filter((r) => r.judgeId === "g")).toHaveLength(2); + expect(metrics.memberOnSponsor).toBe(0); + + const together = [ + placed("a", "sponsor", "s1", 6), + placed("b", "sponsor", "s2", 6), + ]; + const withTwoRooms: PlanInputs = { + ...tight, + rooms: [ + ...tight.rooms, + { id: "s2", challengeId: "sponsor", buildingId: "HEC" }, + ], + }; + const result = assignJudges(withTwoRooms, together, [ + member("m1"), + guest("g", "s1"), + ]); + expect(result.metrics.unassigned).toBe(0); + expect(result.metrics.memberOnSponsor).toBe(1); + expect(result.assignment.find((r) => r.entryId === "b")!.judgeId).toBe( + "m1", + ); + }); + + it("evens the load between judges of one pool", () => { + const only = placements.filter((p) => p.challengeId === "general"); + const judges = [member("m1"), member("m2")]; + const { metrics } = assignJudges(inputs, only, judges); + expect(metrics.loads).toEqual({ m1: 2, m2: 2 }); + expect(metrics.maxLoad).toBe(2); + expect(metrics.loadSpread).toBe(0); + }); + + it("spreads one judge over rooms and over the day", () => { + const wide: PlanInputs = { + ...inputs, + sameGap: 0, + crossGap: 0, + slots: 8, + tasks: ["a", "b", "c", "d"].map((entryId) => ({ + entryId, + challengeId: "general", + sponsor: false, + })), + }; + // Two presentations in each of two rooms, early then late. + const spots = [ + placed("a", "general", "g1", 0), + placed("b", "general", "g2", 1), + placed("c", "general", "g1", 6), + placed("d", "general", "g2", 7), + ]; + const { metrics, assignment } = assignJudges(wide, spots, [ + member("m1"), + member("m2"), + ]); + expect(metrics.loads).toEqual({ m1: 2, m2: 2 }); + // Each judge sees both rooms and both ends of the day. + expect(metrics.repeats).toBe(0); + expect(metrics.avgDistinctRooms).toBe(2); + expect(metrics.dayPartsCovered).toBe(4); + expect(new Set(assignment.map((r) => r.judgeId)).size).toBe(2); + }); + + it("reports the slot where judges run out", () => { + const rush = [ + placed("a", "general", "g1", 0), + placed("b", "general", "g2", 0), + placed("c", "general", "g3", 0), + ]; + const judges = [member("m1"), member("m2")]; + const { assignment, metrics } = assignJudges(inputs, rush, judges); + expect(metrics.unassigned).toBe(1); + const shortfalls = judgeShortfalls(inputs, rush, judges, assignment); + expect(shortfalls).toEqual([ + { slot: 0, challengeId: "general", presentations: 3, judgesFree: 2 }, + ]); + expect(describeShortfall(shortfalls[0]!, "General")).toBe( + "Slot 1: 3 presentations in General, only 2 judges free", + ); + expect( + describeShortfall( + { slot: 4, challengeId: "x", presentations: 1, judgesFree: 1 }, + "Acme", + ), + ).toBe("Slot 5: 1 presentation in Acme, only 1 judge free"); + }); + + it("does not count a judge who cannot walk there as free", () => { + // m1 grades in ENG at slot 0; slot 1 is HEC and needs two free slots. + const spots = [ + placed("a", "general", "g1", 0), + placed("b", "general", "g3", 1), + ]; + const judges = [member("m1")]; + const { assignment } = assignJudges(inputs, spots, judges); + expect(assignment).toHaveLength(1); + const shortfalls = judgeShortfalls(inputs, spots, judges, assignment); + expect(shortfalls).toEqual([ + { + slot: assignment[0]!.entryId === "a" ? 1 : 0, + challengeId: "general", + presentations: 1, + judgesFree: 0, + }, + ]); + }); + + it("keeps assignments it is told to keep, and plans around them", () => { + const only = placements.filter((p) => p.challengeId === "general"); + const judges = [member("m1"), member("m2")]; + const keep = [row("a", "general", "m1"), row("c", "general", "m1")]; + const { assignment } = assignJudges(inputs, only, judges, { keep }); + expect(assignment).toHaveLength(4); + expect(assignment).toEqual(expect.arrayContaining(keep)); + // b and d share a slot with the kept ones, so only m2 is free for them. + const rest = assignment.filter( + (r) => r.entryId === "b" || r.entryId === "d", + ); + expect(rest.map((r) => r.judgeId)).toEqual(["m2", "m2"]); + }); + + it("gives the same answer however its inputs are ordered", () => { + const judges = [member("m1"), member("m2"), member("m3"), guest("g", "s1")]; + const first = assignJudges(inputs, placements, judges); + const second = assignJudges( + inputs, + [...placements].reverse(), + [...judges].reverse(), + ); + expect(second.assignment).toEqual(first.assignment); + expect(second.metrics).toEqual(first.metrics); + }); + + it("assigns nothing without judges", () => { + const result = assignJudges(inputs, placements, []); + expect(result.assignment).toEqual([]); + expect(result.metrics.unassigned).toBe(placements.length); + expect(result.metrics.avgDistinctRooms).toBe(0); + }); +}); + +// A small deterministic generator, so these cases never change between runs. +function lcg(seed: number) { + let state = seed >>> 0; + return (n: number) => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state % n; + }; +} + +function randomCase(seed: number) { + const next = lcg(seed); + const rooms = [ + { id: "r1", challengeId: "c1", buildingId: "A" }, + { id: "r2", challengeId: "c1", buildingId: "A" }, + { id: "r3", challengeId: "c1", buildingId: "B" }, + { id: "r4", challengeId: "c2", buildingId: "B" }, + ]; + const slots = 8; + const taken = new Set(); + const spots: Placement[] = []; + for (let n = 0; n < 7; n += 1) { + const room = rooms[next(rooms.length)]!; + const slot = next(slots); + if (taken.has(`${room.id}@${slot}`)) continue; + taken.add(`${room.id}@${slot}`); + spots.push(placed(`e${n}`, room.challengeId, room.id, slot)); + } + const judges: JudgeInput[] = [ + { id: "m1", kind: "member", challengeIds: ["c1", "c2"], roomId: null }, + { id: "m2", kind: "member", challengeIds: ["c1", "c2"], roomId: null }, + { id: "g4", kind: "guest", challengeIds: ["c2"], roomId: "r4" }, + ]; + const plan: PlanInputs = { + rooms, + tasks: spots.map((p) => ({ + entryId: p.entryId, + challengeId: p.challengeId, + sponsor: p.challengeId === "c2", + })), + slots, + sameGap: next(2), + crossGap: 1 + next(2), + }; + plan.crossGap = Math.max(plan.crossGap, plan.sameGap); + return { plan, spots, judges }; +} + +/** The fewest presentations any valid assignment leaves without a judge. */ +function fewestLeftOut( + plan: PlanInputs, + spots: Placement[], + judges: JudgeInput[], +): number { + let best = spots.length; + const rows: JudgeAssignment[] = []; + const go = (i: number) => { + if (i === spots.length) { + best = Math.min(best, spots.length - rows.length); + return; + } + go(i + 1); + const p = spots[i]!; + for (const judge of judges) { + if (!mayGrade(judge, p)) continue; + rows.push(row(p.entryId, p.challengeId, judge.id)); + if (validateJudgeAssignment(plan, spots, judges, rows).valid) go(i + 1); + rows.pop(); + } + }; + go(0); + return best; +} + +describe("assignJudges against every possibility", () => { + it("keeps the rules and leaves out no more than it must", () => { + for (let seed = 1; seed <= 40; seed += 1) { + const { plan, spots, judges } = randomCase(seed); + const result = assignJudges(plan, spots, judges); + expect( + validateJudgeAssignment(plan, spots, judges, result.assignment) + .problems, + `seed ${seed}`, + ).toEqual([]); + expect(result.metrics.unassigned, `seed ${seed}`).toBe( + fewestLeftOut(plan, spots, judges), + ); + } + }); +}); diff --git a/packages/api/src/services/judging/schedule/judges.ts b/packages/api/src/services/judging/schedule/judges.ts new file mode 100644 index 00000000..6a5f0310 --- /dev/null +++ b/packages/api/src/services/judging/schedule/judges.ts @@ -0,0 +1,784 @@ +import { taskKey } from "./inputs"; +import type { Placement, PlanInputs } from "./inputs"; + +// The second scheduling stage. Once every presentation has a time and a room, +// each one gets exactly one judge, so every project has a time, a place and a +// grader. A presentation is a project in one root challenge, and that +// challenge is graded once, by one judge. +// +// Table judges (members) handle general judging; a sponsor's judges are other +// people, tied to the sponsor's room, who grade only that sponsor's challenge. +// A judge is in one place per slot, needs the schedule's breaks to walk to +// another room, and takes a rest after a long run of presentations. Judge bias +// is calibrated on the assumption that every judge sees a random sample of +// projects, so after everything is graded the aim is an even load, then each +// judge spread over as many rooms and as much of the day as possible. +// +// Pure and deterministic, written apart from the CP-SAT script on purpose: the +// script's answer is re-checked here (validateJudgeAssignment), and a server +// without OR-Tools builds its assignment here (assignJudges). + +/** Most slots in a row a judge grades before at least one free slot. */ +export const MAX_CONSECUTIVE = 4; + +/** + * The day is cut into this many equal parts when asking whether a judge's + * presentations are spread across it. cpsat.py cuts it the same way. + */ +export const DAY_PARTS = 4; + +export type JudgeInput = { + id: string; + kind: "member" | "guest"; + /** The root challenges this judge may grade. */ + challengeIds: readonly string[]; + /** The one room a guest grades in; null for a member, who moves about. */ + roomId: string | null; + /** Defaults to MAX_CONSECUTIVE. */ + maxConsecutive?: number; +}; + +/** The judge of one presentation. */ +export type JudgeAssignment = { + entryId: string; + challengeId: string; + judgeId: string; +}; + +/** Lower is better, except the last, which counts what the judges cover. */ +export type JudgeMetrics = { + /** Presentations with no judge. */ + unassigned: number; + /** Sponsor presentations graded by a table judge rather than a sponsor's. */ + memberOnSponsor: number; + /** Presentations of the busiest judge. */ + maxLoad: number; + /** How far apart the judges of one pool are, summed over the pools. */ + loadSpread: number; + /** Times a judge grades in a room they have already graded in. */ + repeats: number; + /** Rooms a judge grades in, averaged over the judges who grade at all. */ + avgDistinctRooms: number; + /** Parts of the day a judge grades in, summed over the judges. Higher is better. */ + dayPartsCovered: number; + /** Presentations per judge, for every judge given. */ + loads: Record; +}; + +/** + * Judges with the same challenges and room share a pool: they are + * interchangeable, so only within a pool can the load be evened out. + */ +const poolKey = (judge: JudgeInput) => + `${[...judge.challengeIds].sort().join(",")}|${judge.roomId ?? ""}`; + +/** Whether `judge` may grade a presentation of `challengeId` held in `roomId`. */ +export function mayGrade( + judge: JudgeInput, + presentation: { challengeId: string; roomId: string }, +): boolean { + if (!judge.challengeIds.includes(presentation.challengeId)) return false; + // A guest grades only in their own room, and with no room grades nowhere. + if (judge.roomId !== null) return judge.roomId === presentation.roomId; + return judge.kind !== "guest"; +} + +// Same hash for every run, so ties break the same way each time. +function stableHash(text: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i += 1) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash; +} + +const byHash = (a: string, b: string) => + stableHash(a) - stableHash(b) || (a < b ? -1 : a > b ? 1 : 0); + +type Item = { + index: number; + key: string; + entryId: string; + challengeId: string; + roomId: string; + buildingId: string; + slot: number; + part: number; + sponsor: boolean; +}; + +type Prepared = { + input: JudgeInput; + max: number; + /** Judges are numbered in id order, the same for any input order. */ + pool: number; +}; + +type Context = { + items: Item[]; + judges: Prepared[]; + sameGap: number; + crossGap: number; + maxGap: number; + itemIndex: Map; + judgeIndex: Map; + /** Judges who may grade each item, in judge order. */ + eligible: number[][]; + pools: number[][]; +}; + +function contextOf( + inputs: PlanInputs, + placements: readonly Placement[], + judges: readonly JudgeInput[], +): Context { + const buildings = new Map(inputs.rooms.map((r) => [r.id, r.buildingId])); + const sponsor = new Map( + inputs.tasks.map((t) => [taskKey(t.entryId, t.challengeId), t.sponsor]), + ); + + const items: Item[] = []; + const seen = new Set(); + for (const p of placements) { + const key = taskKey(p.entryId, p.challengeId); + const buildingId = buildings.get(p.roomId); + if (seen.has(key) || buildingId === undefined) continue; + seen.add(key); + items.push({ + index: 0, + key, + entryId: p.entryId, + challengeId: p.challengeId, + roomId: p.roomId, + buildingId, + slot: p.slot, + part: Math.min( + DAY_PARTS - 1, + Math.max( + 0, + Math.floor((p.slot * DAY_PARTS) / Math.max(1, inputs.slots)), + ), + ), + sponsor: sponsor.get(key) ?? false, + }); + } + items.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); + items.forEach((item, index) => { + item.index = index; + }); + + const unique = [...new Map(judges.map((j) => [j.id, j])).values()].sort( + (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + ); + const poolIds = new Map(); + const prepared = unique.map((input) => { + const key = poolKey(input); + if (!poolIds.has(key)) poolIds.set(key, poolIds.size); + return { + input, + max: Math.max(1, input.maxConsecutive ?? MAX_CONSECUTIVE), + pool: poolIds.get(key)!, + }; + }); + const pools: number[][] = [...poolIds.keys()].map(() => []); + prepared.forEach((judge, j) => pools[judge.pool]!.push(j)); + + return { + items, + judges: prepared, + sameGap: inputs.sameGap, + crossGap: inputs.crossGap, + maxGap: Math.max(inputs.sameGap, inputs.crossGap), + itemIndex: new Map(items.map((item) => [item.key, item.index])), + judgeIndex: new Map(prepared.map((judge, j) => [judge.input.id, j])), + eligible: items.map((item) => + prepared.flatMap((judge, j) => (mayGrade(judge.input, item) ? [j] : [])), + ), + pools, + }; +} + +/** + * Whether one judge cannot do both presentations: they are at the same time, + * or the walk between two rooms needs more free slots than lie between them. + * Staying in one room needs no walk. + */ +function clash(context: Context, a: Item, b: Item): boolean { + const apart = Math.abs(a.slot - b.slot); + if (apart === 0) return true; + if (a.roomId === b.roomId) return false; + const gap = + a.buildingId === b.buildingId ? context.sameGap : context.crossGap; + return apart <= gap; +} + +/** Where the search stands: who grades what, and the counts the score needs. */ +class State { + readonly owner: number[]; + /** Per judge, the item they grade in each slot. */ + readonly slots: Map[]; + readonly load: number[]; + readonly rooms: Map[]; + readonly parts: number[][]; + unassigned: number; + memberOnSponsor = 0; + repeats = 0; + covered = 0; + + constructor(private readonly context: Context) { + this.owner = context.items.map(() => -1); + this.slots = context.judges.map(() => new Map()); + this.load = context.judges.map(() => 0); + this.rooms = context.judges.map(() => new Map()); + this.parts = context.judges.map(() => Array(DAY_PARTS).fill(0)); + this.unassigned = context.items.length; + } + + add(i: number, j: number) { + const item = this.context.items[i]!; + this.owner[i] = j; + this.slots[j]!.set(item.slot, i); + this.load[j]! += 1; + this.unassigned -= 1; + if (item.sponsor && this.context.judges[j]!.input.kind === "member") { + this.memberOnSponsor += 1; + } + const rooms = this.rooms[j]!; + const seen = rooms.get(item.roomId) ?? 0; + if (seen > 0) this.repeats += 1; + rooms.set(item.roomId, seen + 1); + if (this.parts[j]![item.part]! === 0) this.covered += 1; + this.parts[j]![item.part]! += 1; + } + + remove(i: number) { + const j = this.owner[i]!; + const item = this.context.items[i]!; + this.owner[i] = -1; + this.slots[j]!.delete(item.slot); + this.load[j]! -= 1; + this.unassigned += 1; + if (item.sponsor && this.context.judges[j]!.input.kind === "member") { + this.memberOnSponsor -= 1; + } + const rooms = this.rooms[j]!; + const left = rooms.get(item.roomId)! - 1; + if (left > 0) { + this.repeats -= 1; + rooms.set(item.roomId, left); + } else { + rooms.delete(item.roomId); + } + this.parts[j]![item.part]! -= 1; + if (this.parts[j]![item.part]! === 0) this.covered -= 1; + } + + /** + * Whether judge `j` could also take `item`: free in its slot, far enough + * from their other rooms, and not past their run limit. `ignore` is an + * item of theirs to leave out, as when it is about to be swapped away. + */ + fits(j: number, item: Item, ignore = -1): boolean { + const mine = this.slots[j]!; + const taken = (slot: number) => { + const other = mine.get(slot); + return other === undefined || other === ignore ? undefined : other; + }; + if (taken(item.slot) !== undefined) return false; + for (let d = 1; d <= this.context.maxGap; d += 1) { + for (const slot of [item.slot - d, item.slot + d]) { + const other = taken(slot); + if ( + other !== undefined && + clash(this.context, this.context.items[other]!, item) + ) { + return false; + } + } + } + let run = 1; + for (let s = item.slot - 1; taken(s) !== undefined; s -= 1) run += 1; + for (let s = item.slot + 1; taken(s) !== undefined; s += 1) run += 1; + return run <= this.context.judges[j]!.max; + } + + /** The score as a vector, compared left to right; lower is better. */ + vector(): number[] { + let maxLoad = 0; + for (const load of this.load) maxLoad = Math.max(maxLoad, load); + return [ + this.unassigned, + this.memberOnSponsor, + maxLoad, + this.spread(), + this.repeats, + -this.covered, + ]; + } + + spread(): number { + let total = 0; + for (const pool of this.context.pools) { + if (pool.length < 2) continue; + let low = Infinity; + let high = 0; + for (const j of pool) { + low = Math.min(low, this.load[j]!); + high = Math.max(high, this.load[j]!); + } + total += high - low; + } + return total; + } + + metrics(): JudgeMetrics { + const [unassigned, memberOnSponsor, maxLoad, loadSpread, repeats] = + this.vector() as [number, number, number, number, number]; + let graders = 0; + let distinct = 0; + const loads: Record = {}; + this.context.judges.forEach((judge, j) => { + loads[judge.input.id] = this.load[j]!; + if (this.load[j]! === 0) return; + graders += 1; + distinct += this.rooms[j]!.size; + }); + return { + unassigned, + memberOnSponsor, + maxLoad, + loadSpread, + repeats, + avgDistinctRooms: graders === 0 ? 0 : distinct / graders, + dayPartsCovered: this.covered, + loads, + }; + } +} + +function compareVectors(a: readonly number[], b: readonly number[]): number { + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return a[i]! - b[i]!; + } + return 0; +} + +/** Lower is better, compared as the fields are listed in JudgeMetrics. */ +export function compareJudgeMetrics(a: JudgeMetrics, b: JudgeMetrics): number { + return compareVectors( + [ + a.unassigned, + a.memberOnSponsor, + a.maxLoad, + a.loadSpread, + a.repeats, + -a.dayPartsCovered, + ], + [ + b.unassigned, + b.memberOnSponsor, + b.maxLoad, + b.loadSpread, + b.repeats, + -b.dayPartsCovered, + ], + ); +} + +/** A state holding `assignment`, skipping rows that name nothing known. */ +function stateOf(context: Context, assignment: readonly JudgeAssignment[]) { + const state = new State(context); + for (const row of assignment) { + const i = context.itemIndex.get(taskKey(row.entryId, row.challengeId)); + const j = context.judgeIndex.get(row.judgeId); + if (i === undefined || j === undefined || state.owner[i]! !== -1) continue; + state.add(i, j); + } + return state; +} + +/** + * Checks an assignment against the hard rules. A presentation with no judge + * is not an error here (see judgeShortfalls); one with the wrong judge, or + * two, is. + */ +export function validateJudgeAssignment( + inputs: PlanInputs, + placements: readonly Placement[], + judges: readonly JudgeInput[], + assignment: readonly JudgeAssignment[], +) { + const problems = new Set(); + const context = contextOf(inputs, placements, judges); + const given = new Set(); + const mine = context.judges.map((): number[] => []); + + for (const row of assignment) { + const key = taskKey(row.entryId, row.challengeId); + const i = context.itemIndex.get(key); + const j = context.judgeIndex.get(row.judgeId); + if (i === undefined) { + problems.add(`No such presentation: ${key}.`); + continue; + } + if (j === undefined) { + problems.add(`No such judge: ${row.judgeId}.`); + continue; + } + if (given.has(key)) problems.add(`${key} has more than one judge.`); + given.add(key); + const judge = context.judges[j]!.input; + const item = context.items[i]!; + if (!judge.challengeIds.includes(item.challengeId)) { + problems.add(`Judge ${judge.id} may not grade ${key}: wrong challenge.`); + } else if (!mayGrade(judge, item)) { + problems.add(`Judge ${judge.id} may not grade ${key}: wrong room.`); + } + mine[j]!.push(i); + } + + context.judges.forEach((judge, j) => { + const items = mine[j]!.map((i) => context.items[i]!).sort( + (a, b) => a.slot - b.slot, + ); + for (let a = 0; a < items.length; a += 1) { + for (let b = a + 1; b < items.length; b += 1) { + if (items[b]!.slot - items[a]!.slot > context.maxGap) break; + if (clash(context, items[a]!, items[b]!)) { + problems.add( + items[a]!.slot === items[b]!.slot + ? `Judge ${judge.input.id} is booked twice in slot ${items[a]!.slot + 1}.` + : `Judge ${judge.input.id} has too little time between ${items[a]!.key} and ${items[b]!.key}.`, + ); + } + } + } + let run = 0; + let previous = -2; + for (const item of items) { + if (item.slot === previous) continue; + run = item.slot === previous + 1 ? run + 1 : 1; + previous = item.slot; + if (run === judge.max + 1) { + problems.add( + `Judge ${judge.input.id} grades more than ${judge.max} slots in a row.`, + ); + } + } + }); + + return { valid: problems.size === 0, problems: [...problems] }; +} + +/** How even and how mixed an assignment is, and how many presentations it leaves out. */ +export function scoreJudgeAssignment( + inputs: PlanInputs, + placements: readonly Placement[], + judges: readonly JudgeInput[], + assignment: readonly JudgeAssignment[], +): JudgeMetrics { + const context = contextOf(inputs, placements, judges); + return stateOf(context, assignment).metrics(); +} + +/** A slot where some presentations of one challenge have no judge. */ +export type JudgeShortfall = { + slot: number; + challengeId: string; + presentations: number; + /** How many of them judges could cover at that time. */ + judgesFree: number; +}; + +const plural = (n: number, one: string) => `${n} ${n === 1 ? one : `${one}s`}`; + +/** The shortfall as a sentence; `challenge` is the challenge's label. */ +export function describeShortfall( + shortfall: JudgeShortfall, + challenge: string, +): string { + return `Slot ${shortfall.slot + 1}: ${plural(shortfall.presentations, "presentation")} in ${challenge}, only ${plural(shortfall.judgesFree, "judge")} free`; +} + +/** + * Each slot and challenge with a presentation that has no judge, earliest + * first. A judge counts as free for a challenge when they may grade it there, + * are not grading another challenge in that slot, and could walk there. + */ +export function judgeShortfalls( + inputs: PlanInputs, + placements: readonly Placement[], + judges: readonly JudgeInput[], + assignment: readonly JudgeAssignment[], +): JudgeShortfall[] { + const context = contextOf(inputs, placements, judges); + const state = stateOf(context, assignment); + + const groups = new Map(); + for (const item of context.items) { + const key = `${item.slot}|${item.challengeId}`; + const list = groups.get(key); + if (list) list.push(item.index); + else groups.set(key, [item.index]); + } + + const found: JudgeShortfall[] = []; + for (const members of groups.values()) { + if (!members.some((i) => state.owner[i]! === -1)) continue; + const first = context.items[members[0]!]!; + // Presentations of this group each need a different free judge: match them. + const options = members.map((i) => + context.eligible[i]!.filter((j) => { + const held = state.slots[j]!.get(first.slot); + const own = held !== undefined && members.includes(held) ? held : -1; + return ( + (held === undefined || own !== -1) && + state.fits(j, context.items[i]!, own) + ); + }), + ); + const taker = new Map(); + const place = (m: number, tried: Set): boolean => { + for (const j of options[m]!) { + if (tried.has(j)) continue; + tried.add(j); + const holder = taker.get(j); + if (holder === undefined || place(holder, tried)) { + taker.set(j, m); + return true; + } + } + return false; + }; + let covered = 0; + for (let m = 0; m < members.length; m += 1) { + if (place(m, new Set())) covered += 1; + } + found.push({ + slot: first.slot, + challengeId: first.challengeId, + presentations: members.length, + judgesFree: covered, + }); + } + return found.sort( + (a, b) => a.slot - b.slot || (a.challengeId < b.challengeId ? -1 : 1), + ); +} + +export type JudgeAssignmentResult = { + /** Every presentation that has a judge, the kept ones included. */ + assignment: JudgeAssignment[]; + metrics: JudgeMetrics; +}; + +/** + * Moves and swaps tried, and blocking items moved out of the way, before the + * search stops, however large the event. + */ +const MAX_EVALUATIONS = 50_000; +const MAX_DISPLACEMENTS = 100_000; +const MAX_PASSES = 12; + +/** + * Assigns a judge to every presentation it can: a greedy pass, each + * presentation to the judge that leaves the score best, from three orders of + * going through them, then a bounded local search on the best of the three + * that moves, swaps and displaces. The same inputs give the same answer, + * whatever order they come in: ties break on a hash of the ids. `keep` is + * assignments that stay as they are, and count towards loads and breaks. + */ +export function assignJudges( + inputs: PlanInputs, + placements: readonly Placement[], + judges: readonly JudgeInput[], + options: { keep?: readonly JudgeAssignment[] } = {}, +): JudgeAssignmentResult { + const context = contextOf(inputs, placements, judges); + const start = stateOf(context, options.keep ?? []); + const locked = new Set( + context.items + .filter((item) => start.owner[item.index]! !== -1) + .map((i) => i.index), + ); + const rank = (j: number) => stableHash(context.judges[j]!.input.id); + const free = context.items.filter((item) => !locked.has(item.index)); + + // Judges to try for an item, in a fixed order that does not favour the + // first by id. + const order = (i: number) => + [...context.eligible[i]!].sort((a, b) => rank(a) - rank(b) || a - b); + + /** + * The judge for `item` that leaves the score best, or -1. With `stay`, one + * who was in this room the slot before comes first: staying costs a judge + * no break, where moving on spends one. + */ + const bestJudge = (state: State, item: Item, skip = -1, stay = false) => { + let best = -1; + let bestVector: number[] = []; + for (const j of order(item.index)) { + if (j === skip || !state.fits(j, item)) continue; + const before = state.slots[j]!.get(item.slot - 1); + const staying = + before !== undefined && context.items[before]!.roomId === item.roomId; + state.add(item.index, j); + const vector = [stay && !staying ? 1 : 0, ...state.vector()]; + state.remove(item.index); + if (best === -1 || compareVectors(vector, bestVector) < 0) { + best = j; + bestVector = vector; + } + } + return best; + }; + + // Three starting points, the best of them is improved below: most + // constrained first, which is what a short supply of judges needs; and + // through the day in order, with or without judges staying where they are, + // which is what a long run of crowded slots needs. + const construct = (sort: (a: Item, b: Item) => number, stay: boolean) => { + const state = stateOf(context, options.keep ?? []); + for (const item of [...free].sort(sort)) { + const j = bestJudge(state, item, -1, stay); + if (j !== -1) state.add(item.index, j); + } + return state; + }; + const tightFirst = (a: Item, b: Item) => + context.eligible[a.index]!.length - context.eligible[b.index]!.length || + a.slot - b.slot || + byHash(a.key, b.key); + const inOrder = (a: Item, b: Item) => a.slot - b.slot || byHash(a.key, b.key); + const state = [ + construct(tightFirst, false), + construct(inOrder, true), + construct(inOrder, false), + ].reduce((best, next) => + compareVectors(next.vector(), best.vector()) < 0 ? next : best, + ); + + let evaluations = 0; + let displacements = 0; + + /** Moves `i` to judge `to` if that scores better than `current`, and says so. */ + const tryMove = (i: number, to: number, current: number[]) => { + const from = state.owner[i]!; + evaluations += 1; + state.remove(i); + state.add(i, to); + if (compareVectors(state.vector(), current) < 0) return true; + state.remove(i); + state.add(i, from); + return false; + }; + + /** Swaps the judges of `i` and `q` if that scores better than `current`, and says so. */ + const trySwap = (i: number, q: number, current: number[]) => { + const a = state.owner[i]!; + const b = state.owner[q]!; + evaluations += 1; + state.remove(i); + state.remove(q); + if (state.fits(b, context.items[i]!) && state.fits(a, context.items[q]!)) { + state.add(i, b); + state.add(q, a); + if (compareVectors(state.vector(), current) < 0) return true; + state.remove(i); + state.remove(q); + } + state.add(i, a); + state.add(q, b); + return false; + }; + + /** + * Gives `item` a judge, moving blocking items of that judge to other + * judges, `depth` moves deep. Changes nothing if it cannot. + */ + const displace = (item: Item, depth: number): boolean => { + const direct = bestJudge(state, item); + if (direct !== -1) { + state.add(item.index, direct); + return true; + } + if (depth === 0) return false; + for (const j of order(item.index)) { + const reach = Math.max(context.maxGap, context.judges[j]!.max); + for (const q of [...state.slots[j]!.values()]) { + if ( + locked.has(q) || + Math.abs(context.items[q]!.slot - item.slot) > reach + ) { + continue; + } + if (displacements >= MAX_DISPLACEMENTS) return false; + displacements += 1; + state.remove(q); + if (state.fits(j, item)) { + state.add(item.index, j); + if (displace(context.items[q]!, depth - 1)) return true; + state.remove(item.index); + } + state.add(q, j); + } + } + return false; + }; + + for ( + let pass = 0; + pass < MAX_PASSES && evaluations < MAX_EVALUATIONS; + pass += 1 + ) { + let improved = false; + + // A presentation with no judge: clear its way by sending an item that + // blocks a judge to somebody else, who may in turn send one on. + for (const item of free) { + if (state.owner[item.index]! !== -1) continue; + if (displacements >= MAX_DISPLACEMENTS) break; + if (displace(item, 4)) improved = true; + } + + // A presentation that scores better with another judge, alone or swapped + // with one of theirs. + for (const item of free) { + const from = state.owner[item.index]!; + if (from === -1) continue; + const current = state.vector(); + for (const to of order(item.index)) { + if (evaluations >= MAX_EVALUATIONS) break; + if (to === from) continue; + if (!state.slots[to]!.has(item.slot) && state.fits(to, item)) { + if (tryMove(item.index, to, current)) { + improved = true; + break; + } + } + let swapped = false; + for (const q of [...state.slots[to]!.values()]) { + if (evaluations >= MAX_EVALUATIONS) break; + if (locked.has(q) || !context.eligible[q]!.includes(from)) continue; + if (trySwap(item.index, q, current)) { + improved = true; + swapped = true; + break; + } + } + if (swapped) break; + } + } + if (!improved) break; + } + + const assignment = context.items + .filter((item) => state.owner[item.index]! !== -1) + .map((item) => ({ + entryId: item.entryId, + challengeId: item.challengeId, + judgeId: context.judges[state.owner[item.index]!]!.input.id, + })); + return { assignment, metrics: state.metrics() }; +} diff --git a/packages/api/src/services/judging/schedule/moves.test.ts b/packages/api/src/services/judging/schedule/moves.test.ts new file mode 100644 index 00000000..79f40317 --- /dev/null +++ b/packages/api/src/services/judging/schedule/moves.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { moveChoices } from "./moves"; +import type { MoveRequest } from "./moves"; + +// General in two ENG rooms and one HEC room; Mongo in HEC. "a" presents to +// Mongo at slot 2 and to General at slot 5; "b" holds g1 at slots 0-1. +const base: MoveRequest = { + slots: 8, + sameGap: 1, + crossGap: 2, + rooms: [ + { id: "g1", challengeId: "general", buildingId: "ENG" }, + { id: "g2", challengeId: "general", buildingId: "ENG" }, + { id: "g3", challengeId: "general", buildingId: "HEC" }, + { id: "m1", challengeId: "mongo", buildingId: "HEC" }, + ], + sponsorChallengeIds: new Set(["mongo"]), + booked: [ + { entryId: "a", challengeId: "mongo", roomId: "m1", slot: 2 }, + { entryId: "b", challengeId: "general", roomId: "g1", slot: 0 }, + { entryId: "c", challengeId: "general", roomId: "g1", slot: 1 }, + ], + task: { entryId: "a", challengeId: "general" }, + current: { roomId: "g1", slot: 5 }, + openRoomIds: ["g1", "g2", "g3", "m1"], + fromSlot: 1, +}; + +const places = (request: MoveRequest) => + moveChoices(request).map((c) => `${c.roomId}@${c.slot}`); + +describe("moveChoices", () => { + it("offers only free, future slots in rooms of the same challenge", () => { + const all = places(base); + expect(all).not.toContain("m1@6"); // another challenge's room + expect(all).not.toContain("g1@1"); // taken by "c" + expect(all).not.toContain("g2@0"); // already started + expect(all).not.toContain("g1@5"); // where it already is + expect(all).toContain("g2@6"); + }); + + it("keeps at least the same-building break from the project's other presentations", () => { + const all = places(base); + // Mongo is at slot 2: slots 1, 2 and 3 are too close in any room. + for (const room of ["g1", "g2", "g3"]) { + for (const slot of [2, 3]) expect(all).not.toContain(`${room}@${slot}`); + } + expect(all).not.toContain("g3@1"); + }); + + it("lists breaks short of the walk between buildings last, and counts them", () => { + const choices = moveChoices(base); + // g2@4 is in ENG, one empty slot after Mongo in HEC: a reduced break. + const reduced = choices.find((c) => c.roomId === "g2" && c.slot === 4)!; + expect(reduced.reducedBreaks).toBe(1); + // g3@4 is in HEC with Mongo: a full break. + expect(choices.find((c) => c.roomId === "g3" && c.slot === 4)!.reducedBreaks).toBe(0); + const firstReduced = choices.findIndex((c) => c.reducedBreaks > 0); + expect(choices.slice(firstReduced).every((c) => c.reducedBreaks > 0)).toBe(true); + expect(choices[0]!.reducedBreaks).toBe(0); + }); + + it("ranks by the whole schedule's score", () => { + const [smart] = moveChoices(base); + // Ties on finish times; staying in HEC with Mongo avoids a building change. + expect(smart).toMatchObject({ roomId: "g3", reducedBreaks: 0 }); + expect(smart!.score[3]).toBe(0); + }); + + it("skips rooms that are not open", () => { + expect(places({ ...base, openRoomIds: ["g2"] }).every((p) => p.startsWith("g2@"))).toBe(true); + }); + + it("places a presentation that has no appointment yet", () => { + const choices = moveChoices({ + ...base, + task: { entryId: "d", challengeId: "general" }, + current: null, + }); + expect(choices.length).toBeGreaterThan(0); + expect(choices.every((c) => c.reducedBreaks === 0)).toBe(true); + // Finishing earliest wins: g1@2 and g2@1 both end the day sooner than later slots. + expect(choices[0]!.score[2]).toBe(3); + }); + + it("treats a room without a building as its own building", () => { + const choices = moveChoices({ + ...base, + rooms: base.rooms.map((r) => (r.id === "g3" ? { ...r, buildingId: null } : r)), + }); + expect(choices.find((c) => c.roomId === "g3" && c.slot === 4)!.reducedBreaks).toBe(1); + }); + + it("offers nothing when the window is full", () => { + expect(moveChoices({ ...base, fromSlot: 8 })).toEqual([]); + }); +}); diff --git a/packages/api/src/services/judging/schedule/moves.ts b/packages/api/src/services/judging/schedule/moves.ts new file mode 100644 index 00000000..cb56cf27 --- /dev/null +++ b/packages/api/src/services/judging/schedule/moves.ts @@ -0,0 +1,131 @@ +import type { Placement } from "./inputs"; +import { compareScores, scoreSchedule } from "./rules"; +import type { PlanScore } from "./rules"; + +// Where one presentation can go once a schedule is saved: a late import that +// needs a slot, or a team that missed theirs. Every other appointment stays +// put. A change of building may get only the same-building break, since on +// the day a short walk beats no slot at all, but each choice says how many +// breaks it shortens and the ones that shorten none come first. + +export type MoveRoom = { + id: string; + challengeId: string; + /** Null once a room loses its building; it then counts as its own. */ + buildingId: string | null; +}; + +export type MoveChoice = { + roomId: string; + slot: number; + /** Breaks next to this presentation that fall short of the walk between buildings. */ + reducedBreaks: number; + score: PlanScore; +}; + +export type MoveRequest = { + slots: number; + sameGap: number; + crossGap: number; + /** Every room the schedule mentions, archived ones included. */ + rooms: readonly MoveRoom[]; + sponsorChallengeIds: ReadonlySet; + /** Every appointment except the one being placed. */ + booked: readonly Placement[]; + task: { entryId: string; challengeId: string }; + /** Where it is now; null for a presentation with no appointment yet. */ + current: { roomId: string; slot: number } | null; + /** Rooms that may take it: in service, for its challenge, with a judge in. */ + openRoomIds: readonly string[]; + /** The first slot that has not started yet. */ + fromSlot: number; +}; + +/** Every place the presentation can go, best first. */ +export function moveChoices(request: MoveRequest): MoveChoice[] { + const buildingOf = new Map( + request.rooms.map((room) => [room.id, room.buildingId ?? `room:${room.id}`]), + ); + const challengeOf = new Map(request.rooms.map((r) => [r.id, r.challengeId])); + const taken = new Set(request.booked.map((p) => `${p.roomId}@${p.slot}`)); + const siblings = request.booked.filter( + (p) => p.entryId === request.task.entryId, + ); + + const scoring = { + rooms: [...buildingOf].map(([id, buildingId]) => ({ id, buildingId })), + tasks: [...request.booked, request.task].map((p) => ({ + entryId: p.entryId, + challengeId: p.challengeId, + sponsor: request.sponsorChallengeIds.has(p.challengeId), + })), + }; + + const choices: MoveChoice[] = []; + for (const roomId of request.openRoomIds) { + if (challengeOf.get(roomId) !== request.task.challengeId) continue; + const building = buildingOf.get(roomId)!; + + for (let slot = request.fromSlot; slot < request.slots; slot += 1) { + if (taken.has(`${roomId}@${slot}`)) continue; + if ( + request.current && + request.current.roomId === roomId && + request.current.slot === slot + ) { + continue; + } + + // Only the presentations just before and after this one matter: the + // rest of the project's day is already settled. + let before: Placement | null = null; + let after: Placement | null = null; + let clash = false; + for (const p of siblings) { + if (p.slot === slot) clash = true; + else if (p.slot < slot && (!before || p.slot > before.slot)) before = p; + else if (p.slot > slot && (!after || p.slot < after.slot)) after = p; + } + if (clash) continue; + + let fits = true; + let reducedBreaks = 0; + for (const [first, second, otherRoom] of [ + [before?.slot, slot, before?.roomId], + [slot, after?.slot, after?.roomId], + ] as const) { + if (first === undefined || second === undefined) continue; + const gap = second - first - 1; + if (gap < request.sameGap) fits = false; + else if ( + buildingOf.get(otherRoom!) !== building && + gap < request.crossGap + ) { + reducedBreaks += 1; + } + } + if (!fits) continue; + + const placed: Placement = { + entryId: request.task.entryId, + challengeId: request.task.challengeId, + roomId, + slot, + }; + choices.push({ + roomId, + slot, + reducedBreaks, + score: scoreSchedule(scoring, [...request.booked, placed]), + }); + } + } + + return choices.sort( + (a, b) => + Number(a.reducedBreaks > 0) - Number(b.reducedBreaks > 0) || + compareScores(a.score, b.score) || + a.slot - b.slot || + (a.roomId < b.roomId ? -1 : a.roomId > b.roomId ? 1 : 0), + ); +} diff --git a/packages/api/src/services/judging/schedule/rules.test.ts b/packages/api/src/services/judging/schedule/rules.test.ts new file mode 100644 index 00000000..ef7395c8 --- /dev/null +++ b/packages/api/src/services/judging/schedule/rules.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { compareScores, scoreSchedule, validateSchedule } from "./rules"; +import type { Placement, PlanInputs } from "./inputs"; + +const inputs: PlanInputs = { + rooms: [ + { id: "g1", challengeId: "general", buildingId: "ENG" }, + { id: "g2", challengeId: "general", buildingId: "ENG" }, + { id: "s1", challengeId: "sponsor", buildingId: "HEC" }, + ], + tasks: [ + { entryId: "a", challengeId: "general", sponsor: false }, + { entryId: "a", challengeId: "sponsor", sponsor: true }, + { entryId: "b", challengeId: "general", sponsor: false }, + ], + slots: 6, + sameGap: 1, + crossGap: 2, +}; + +const good: Placement[] = [ + { entryId: "a", challengeId: "sponsor", roomId: "s1", slot: 0 }, + { entryId: "a", challengeId: "general", roomId: "g1", slot: 3 }, + { entryId: "b", challengeId: "general", roomId: "g1", slot: 0 }, +]; + +const replace = (index: number, change: Partial) => + good.map((p, i) => (i === index ? { ...p, ...change } : p)); + +describe("validateSchedule", () => { + it("accepts a schedule that keeps every rule", () => { + expect(validateSchedule(inputs, good, { relaxed: false })).toEqual({ + valid: true, + errors: [], + reducedBreaks: [], + }); + }); + + it("catches a double-booked room", () => { + const result = validateSchedule(inputs, replace(2, { slot: 3 }), { + relaxed: false, + }); + expect(result.errors).toEqual(["Room g1 is double-booked."]); + }); + + it("catches a room that judges another challenge", () => { + const result = validateSchedule(inputs, replace(1, { roomId: "s1" }), { + relaxed: false, + }); + expect(result.errors.join(" ")).toMatch(/another challenge/); + }); + + it("catches a missing, duplicated or unknown presentation", () => { + expect( + validateSchedule(inputs, good.slice(0, 2), { relaxed: false }).errors, + ).toContain("b:general has no appointment."); + expect( + validateSchedule(inputs, [...good, { ...good[2]!, roomId: "g2" }], { + relaxed: false, + }).errors, + ).toContain("b:general is booked more than once."); + expect( + validateSchedule( + inputs, + [...good, { entryId: "z", challengeId: "general", roomId: "g2", slot: 1 }], + { relaxed: false }, + ).valid, + ).toBe(false); + }); + + it("catches a slot outside the window", () => { + for (const slot of [-1, 6, 1.5]) { + expect( + validateSchedule(inputs, replace(2, { slot }), { relaxed: false }) + .errors, + ).toContain("b:general is outside the judging window."); + } + }); + + it("catches a project in two places at once", () => { + const result = validateSchedule( + inputs, + [ + { entryId: "a", challengeId: "sponsor", roomId: "s1", slot: 2 }, + { entryId: "a", challengeId: "general", roomId: "g1", slot: 2 }, + good[2]!, + ], + { relaxed: true }, + ); + expect(result.errors).toContain("a has too little time between presentations."); + }); + + it("needs the same-building break within a building", () => { + const tight: PlanInputs = { + ...inputs, + rooms: inputs.rooms.map((r) => ({ ...r, buildingId: "ENG" })), + }; + expect( + validateSchedule(tight, replace(1, { slot: 2 }), { relaxed: false }).valid, + ).toBe(true); + expect( + validateSchedule(tight, replace(1, { slot: 1 }), { relaxed: false }).valid, + ).toBe(false); + }); + + it("needs the cross-building break when the building changes", () => { + const short = replace(1, { slot: 2 }); + expect(validateSchedule(inputs, short, { relaxed: false }).valid).toBe(false); + + const relaxed = validateSchedule(inputs, short, { relaxed: true }); + expect(relaxed.valid).toBe(true); + expect(relaxed.reducedBreaks).toEqual([ + { entryId: "a", fromRoomId: "s1", toRoomId: "g1", gap: 1 }, + ]); + // Relaxed still keeps the same-building minimum. + expect( + validateSchedule(inputs, replace(1, { slot: 1 }), { relaxed: true }).valid, + ).toBe(false); + }); +}); + +describe("scoreSchedule", () => { + it("scores finish times, sponsor idle, building changes and breaks", () => { + // Sponsor ends after slot 0; everything after slot 3; "a" changes + // building once with two empty slots between. + expect(scoreSchedule(inputs, good)).toEqual([1, 0, 4, 1, -2]); + }); + + it("counts idle slots inside a sponsor room's busy span", () => { + const sponsorOnly = { + rooms: inputs.rooms, + tasks: [ + { entryId: "a", challengeId: "sponsor", sponsor: true }, + { entryId: "b", challengeId: "sponsor", sponsor: true }, + ], + }; + expect( + scoreSchedule(sponsorOnly, [ + { entryId: "a", challengeId: "sponsor", roomId: "s1", slot: 1 }, + { entryId: "b", challengeId: "sponsor", roomId: "s1", slot: 4 }, + ]), + ).toEqual([5, 2, 5, 0, 0]); + }); + + it("is all zeros for an empty schedule", () => { + expect(scoreSchedule(inputs, [])).toEqual([0, 0, 0, 0, 0]); + }); +}); + +describe("compareScores", () => { + it("compares left to right", () => { + expect(compareScores([1, 9, 9, 9, 9], [2, 0, 0, 0, 0])).toBeLessThan(0); + expect(compareScores([2, 0, 5, 0, 0], [2, 0, 4, 9, 9])).toBeGreaterThan(0); + expect(compareScores([1, 2, 3, 4, -5], [1, 2, 3, 4, -5])).toBe(0); + expect(compareScores([1, 2, 3, 4, -6], [1, 2, 3, 4, -5])).toBeLessThan(0); + }); +}); diff --git a/packages/api/src/services/judging/schedule/rules.ts b/packages/api/src/services/judging/schedule/rules.ts new file mode 100644 index 00000000..3578088d --- /dev/null +++ b/packages/api/src/services/judging/schedule/rules.ts @@ -0,0 +1,177 @@ +import { taskKey } from "./inputs"; +import type { Placement, PlanInputs, PlanTask } from "./inputs"; + +// The schedule's rules and its yardstick, written separately from the solver +// on purpose: every candidate the solver hands back is re-checked here, so a +// bug in its bookkeeping shows up as a refused preview rather than two +// projects booked into one room. + +/** + * Lower is better, compared left to right, all in slots: + * 0 when the last sponsor presentation ends + * 1 empty slots inside sponsor rooms' busy spans, summed + * 2 when the last presentation of all ends + * 3 how often a project changes building between presentations + * 4 minus the empty slots between each project's presentations, so more + * breathing room wins once everything else ties + */ +export type PlanScore = [number, number, number, number, number]; + +export function compareScores(a: PlanScore, b: PlanScore): number { + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return a[i]! - b[i]!; + } + return 0; +} + +/** + * Two back-to-back presentations of one project in different buildings with + * less than the cross-building break between them, allowed only when an + * organiser accepts shorter breaks. + */ +export type ReducedBreak = { + entryId: string; + fromRoomId: string; + toRoomId: string; + /** Empty slots actually left between the two. */ + gap: number; +}; + +type RoomBuilding = { id: string; buildingId: string }; + +/** Each project's placements, earliest first. */ +function itineraries(placements: readonly Placement[]) { + const byEntry = new Map(); + for (const p of placements) { + const list = byEntry.get(p.entryId); + if (list) list.push(p); + else byEntry.set(p.entryId, [p]); + } + for (const list of byEntry.values()) list.sort((a, b) => a.slot - b.slot); + return byEntry; +} + +/** + * Checks a complete schedule against the hard rules. With `relaxed`, a change + * of building needs only the same-building break, and each place that uses + * the allowance is listed in `reducedBreaks`. + */ +export function validateSchedule( + inputs: PlanInputs, + placements: readonly Placement[], + options: { relaxed: boolean }, +) { + const errors = new Set(); + const reducedBreaks: ReducedBreak[] = []; + const rooms = new Map(inputs.rooms.map((room) => [room.id, room])); + const tasks = new Map( + inputs.tasks.map((task) => [taskKey(task.entryId, task.challengeId), task]), + ); + const placed = new Set(); + const cells = new Set(); + const known: Placement[] = []; + + for (const p of placements) { + const key = taskKey(p.entryId, p.challengeId); + const room = rooms.get(p.roomId); + if (!tasks.has(key)) { + errors.add(`No such presentation: ${key}.`); + continue; + } + if (!room) { + errors.add(`No such room: ${p.roomId}.`); + continue; + } + if (placed.has(key)) errors.add(`${key} is booked more than once.`); + placed.add(key); + if (room.challengeId !== p.challengeId) { + errors.add(`${key} is in room ${room.id}, which judges another challenge.`); + } + if (!Number.isInteger(p.slot) || p.slot < 0 || p.slot >= inputs.slots) { + errors.add(`${key} is outside the judging window.`); + } + const cell = `${p.roomId}@${p.slot}`; + if (cells.has(cell)) errors.add(`Room ${p.roomId} is double-booked.`); + cells.add(cell); + known.push(p); + } + + for (const key of tasks.keys()) { + if (!placed.has(key)) errors.add(`${key} has no appointment.`); + } + + for (const [entryId, list] of itineraries(known)) { + for (let i = 1; i < list.length; i += 1) { + const before = list[i - 1]!; + const after = list[i]!; + const gap = after.slot - before.slot - 1; + const cross = + rooms.get(before.roomId)!.buildingId !== + rooms.get(after.roomId)!.buildingId; + const strict = cross ? inputs.crossGap : inputs.sameGap; + const needed = options.relaxed ? inputs.sameGap : strict; + if (gap < needed) { + errors.add(`${entryId} has too little time between presentations.`); + } else if (gap < strict) { + reducedBreaks.push({ + entryId, + fromRoomId: before.roomId, + toRoomId: after.roomId, + gap, + }); + } + } + } + + return { valid: errors.size === 0, errors: [...errors], reducedBreaks }; +} + +/** + * Scores placements without checking them. Rooms need only their building, + * so a saved schedule whose room has since lost one can still be scored. + */ +export function scoreSchedule( + inputs: { rooms: readonly RoomBuilding[]; tasks: readonly PlanTask[] }, + placements: readonly Placement[], +): PlanScore { + const building = new Map(inputs.rooms.map((r) => [r.id, r.buildingId])); + const sponsor = new Set( + inputs.tasks + .filter((t) => t.sponsor) + .map((t) => taskKey(t.entryId, t.challengeId)), + ); + + let sponsorFinish = 0; + let overallFinish = 0; + const sponsorRooms = new Map(); + for (const p of placements) { + overallFinish = Math.max(overallFinish, p.slot + 1); + if (!sponsor.has(taskKey(p.entryId, p.challengeId))) continue; + sponsorFinish = Math.max(sponsorFinish, p.slot + 1); + const slots = sponsorRooms.get(p.roomId); + if (slots) slots.push(p.slot); + else sponsorRooms.set(p.roomId, [p.slot]); + } + + let idle = 0; + for (const slots of sponsorRooms.values()) { + idle += Math.max(...slots) - Math.min(...slots) + 1 - slots.length; + } + + let changes = 0; + // Kept negative as it accumulates, so a schedule with no breaks scores 0 + // rather than -0. + let lessBreaks = 0; + for (const list of itineraries(placements).values()) { + for (let i = 1; i < list.length; i += 1) { + const before = list[i - 1]!; + const after = list[i]!; + if (building.get(before.roomId) !== building.get(after.roomId)) { + changes += 1; + } + lessBreaks -= after.slot - before.slot - 1; + } + } + + return [sponsorFinish, idle, overallFinish, changes, lessBreaks]; +} diff --git a/packages/api/src/services/judging/schedule/slots.test.ts b/packages/api/src/services/judging/schedule/slots.test.ts new file mode 100644 index 00000000..ed4d45f9 --- /dev/null +++ b/packages/api/src/services/judging/schedule/slots.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_SLOTS, + appointmentTimes, + firstSlotFrom, + gridOf, + slotAt, + slotCount, + slotStartsAt, + timingProblems, +} from "./slots"; +import type { ScheduleTiming } from "./slots"; + +const timing: ScheduleTiming = { + windowStart: new Date("2027-02-08T14:00:00Z"), + windowEnd: new Date("2027-02-08T16:05:00Z"), + setupMinutes: 2, + judgingMinutes: 6, + teardownMinutes: 2, + sameBuildingBreakMinutes: 10, + crossBuildingBreakMinutes: 15, +}; + +describe("grid", () => { + it("cuts the window into whole slots", () => { + // 125 minutes of 10-minute slots: the last 5 minutes go unused. + expect(slotCount(timing)).toBe(12); + }); + + it("rounds breaks up to whole slots", () => { + expect(gridOf(timing)).toEqual({ slots: 12, sameGap: 1, crossGap: 2 }); + expect( + gridOf({ ...timing, sameBuildingBreakMinutes: 0, crossBuildingBreakMinutes: 1 }), + ).toEqual({ slots: 12, sameGap: 0, crossGap: 1 }); + }); + + it("is empty for a window shorter than a slot", () => { + expect( + slotCount({ ...timing, windowEnd: new Date("2027-02-08T14:09:00Z") }), + ).toBe(0); + }); +}); + +describe("times", () => { + it("stores setup start, submission deadline and end", () => { + expect(appointmentTimes(timing, 3)).toEqual({ + startsAt: new Date("2027-02-08T14:30:00Z"), + deadlineAt: new Date("2027-02-08T14:38:00Z"), + endsAt: new Date("2027-02-08T14:40:00Z"), + }); + }); + + it("maps a start time back to its slot", () => { + expect(slotAt(timing, slotStartsAt(timing, 7))).toBe(7); + expect(slotAt(timing, new Date("2027-02-08T14:05:00Z"))).toBeNull(); + expect(slotAt(timing, new Date("2027-02-08T13:50:00Z"))).toBeNull(); + // Slot 12 would end after the window. + expect(slotAt(timing, new Date("2027-02-08T16:00:00Z"))).toBeNull(); + }); + + it("finds the first slot that has not started", () => { + expect(firstSlotFrom(timing, new Date("2027-02-08T13:00:00Z"))).toBe(0); + expect(firstSlotFrom(timing, new Date("2027-02-08T14:20:00Z"))).toBe(2); + expect(firstSlotFrom(timing, new Date("2027-02-08T14:20:01Z"))).toBe(3); + }); +}); + +describe("timingProblems", () => { + it("accepts a sensible timing", () => { + expect(timingProblems(timing)).toEqual([]); + }); + + it("refuses a window that ends before it starts", () => { + expect( + timingProblems({ ...timing, windowEnd: timing.windowStart }), + ).toEqual(["End the judging window after it starts."]); + }); + + it("refuses a window shorter than one presentation", () => { + expect( + timingProblems({ + ...timing, + windowEnd: new Date("2027-02-08T14:05:00Z"), + })[0], + ).toMatch(/shorter than one 10-minute presentation/); + }); + + it("refuses more slots than a room can have", () => { + const long = { + ...timing, + setupMinutes: 0, + judgingMinutes: 1, + teardownMinutes: 0, + windowEnd: new Date(timing.windowStart.getTime() + (MAX_SLOTS + 1) * 60_000), + }; + expect(timingProblems(long)[0]).toMatch(/the most is 1440/); + }); + + it("refuses times off the minute and a cross break shorter than the same one", () => { + expect( + timingProblems({ + ...timing, + windowStart: new Date("2027-02-08T14:00:30Z"), + crossBuildingBreakMinutes: 5, + }), + ).toEqual([ + "Start and end the judging window on a whole minute.", + "The break for changing buildings cannot be shorter than the break within one building.", + ]); + }); +}); diff --git a/packages/api/src/services/judging/schedule/slots.ts b/packages/api/src/services/judging/schedule/slots.ts new file mode 100644 index 00000000..b9c774ed --- /dev/null +++ b/packages/api/src/services/judging/schedule/slots.ts @@ -0,0 +1,120 @@ +// Slot arithmetic for the presentation schedule. A judging window is cut into +// equal slots of setup + judging + teardown minutes, and every appointment +// starts on a slot boundary. Breaks between one project's presentations are +// counted in whole slots, rounded up, so a 15-minute break on a 10-minute grid +// leaves two empty slots rather than one and a half. + +/** The columns judging_schedule stores, named the same so a saved row fits. */ +export type ScheduleTiming = { + windowStart: Date; + windowEnd: Date; + setupMinutes: number; + judgingMinutes: number; + teardownMinutes: number; + sameBuildingBreakMinutes: number; + crossBuildingBreakMinutes: number; +}; + +/** + * Most slots one room can have. Keeps move choices and the solver's search + * bounded however long a window an organiser types. + */ +export const MAX_SLOTS = 1440; + +const MINUTE = 60_000; + +export function slotMinutes(timing: ScheduleTiming): number { + return timing.setupMinutes + timing.judgingMinutes + timing.teardownMinutes; +} + +/** Whole slots that fit in the window; a partial slot at the end is unused. */ +export function slotCount(timing: ScheduleTiming): number { + const windowMinutes = + (timing.windowEnd.getTime() - timing.windowStart.getTime()) / MINUTE; + const length = slotMinutes(timing); + if (length <= 0 || windowMinutes <= 0) return 0; + return Math.floor(windowMinutes / length); +} + +/** Empty slots a break needs between two presentations. */ +export function breakSlots(breakMinutes: number, length: number): number { + return Math.ceil(breakMinutes / length); +} + +/** The grid the solver works on: slot count and both breaks in slots. */ +export function gridOf(timing: ScheduleTiming) { + const length = slotMinutes(timing); + return { + slots: slotCount(timing), + sameGap: breakSlots(timing.sameBuildingBreakMinutes, length), + crossGap: breakSlots(timing.crossBuildingBreakMinutes, length), + }; +} + +/** + * What is wrong with a timing, as sentences an organiser can act on. Empty + * when it is usable. + */ +export function timingProblems(timing: ScheduleTiming): string[] { + const problems: string[] = []; + const onMinute = (date: Date) => date.getTime() % MINUTE === 0; + + if (!onMinute(timing.windowStart) || !onMinute(timing.windowEnd)) { + problems.push("Start and end the judging window on a whole minute."); + } + if (timing.windowEnd <= timing.windowStart) { + problems.push("End the judging window after it starts."); + } else if (slotCount(timing) < 1) { + problems.push( + `The window is shorter than one ${slotMinutes(timing)}-minute presentation. Lengthen it or shorten the presentation.`, + ); + } else if (slotCount(timing) > MAX_SLOTS) { + problems.push( + `That window holds ${slotCount(timing)} presentations per room; the most is ${MAX_SLOTS}. Shorten the window or lengthen the presentation.`, + ); + } + if (timing.crossBuildingBreakMinutes < timing.sameBuildingBreakMinutes) { + problems.push( + "The break for changing buildings cannot be shorter than the break within one building.", + ); + } + return problems; +} + +export function slotStartsAt(timing: ScheduleTiming, slot: number): Date { + return new Date( + timing.windowStart.getTime() + slot * slotMinutes(timing) * MINUTE, + ); +} + +/** The slot an appointment starting at `startsAt` is in, or null off the grid. */ +export function slotAt(timing: ScheduleTiming, startsAt: Date): number | null { + const offset = startsAt.getTime() - timing.windowStart.getTime(); + const length = slotMinutes(timing) * MINUTE; + if (offset < 0 || offset % length !== 0) return null; + const slot = offset / length; + return slot < slotCount(timing) ? slot : null; +} + +/** The first slot that starts at or after `now`. */ +export function firstSlotFrom(timing: ScheduleTiming, now: Date): number { + const offset = now.getTime() - timing.windowStart.getTime(); + if (offset <= 0) return 0; + return Math.ceil(offset / (slotMinutes(timing) * MINUTE)); +} + +/** + * The three times an appointment row stores: when the room starts setting + * up, when judges must have submitted, and when the room is free again. + */ +export function appointmentTimes(timing: ScheduleTiming, slot: number) { + const startsAt = slotStartsAt(timing, slot); + return { + startsAt, + deadlineAt: new Date( + startsAt.getTime() + + (timing.setupMinutes + timing.judgingMinutes) * MINUTE, + ), + endsAt: new Date(startsAt.getTime() + slotMinutes(timing) * MINUTE), + }; +} diff --git a/packages/api/src/services/judging/schedule/solve.test.ts b/packages/api/src/services/judging/schedule/solve.test.ts new file mode 100644 index 00000000..b3bcab0f --- /dev/null +++ b/packages/api/src/services/judging/schedule/solve.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it } from "vitest"; +import { capacityProblems, solvePlan } from "./solve"; +import { compareScores, scoreSchedule, validateSchedule } from "./rules"; +import type { PlanScore } from "./rules"; +import type { Placement, PlanInputs, PlanRoom, PlanTask } from "./inputs"; + +const general = (entryId: string): PlanTask => ({ + entryId, + challengeId: "general", + sponsor: false, +}); +const sponsor = (entryId: string, challengeId = "sponsor"): PlanTask => ({ + entryId, + challengeId, + sponsor: true, +}); +const room = (id: string, challengeId: string, buildingId: string): PlanRoom => ({ + id, + challengeId, + buildingId, +}); + +// Two buildings. "a" presents to General in ENG and to the sponsor in HEC. +const small: PlanInputs = { + rooms: [room("g1", "general", "ENG"), room("s1", "sponsor", "HEC")], + tasks: [general("a"), sponsor("a"), general("b")], + slots: 4, + sameGap: 1, + crossGap: 2, +}; + +/** Every assignment of every task to every (room, slot): the true optimum. */ +function bruteForce(inputs: PlanInputs, relaxed: boolean): PlanScore | null { + let best: PlanScore | null = null; + const chosen: Placement[] = []; + const visit = (i: number) => { + if (i === inputs.tasks.length) { + if (!validateSchedule(inputs, chosen, { relaxed }).valid) return; + const score = scoreSchedule(inputs, chosen); + if (!best || compareScores(score, best) < 0) best = score; + return; + } + const task = inputs.tasks[i]!; + for (const r of inputs.rooms) { + if (r.challengeId !== task.challengeId) continue; + for (let slot = 0; slot < inputs.slots; slot += 1) { + chosen.push({ ...task, roomId: r.id, slot }); + visit(i + 1); + chosen.pop(); + } + } + }; + visit(0); + return best; +} + +function solved(inputs: PlanInputs) { + const result = solvePlan(inputs); + if (!result.ok) { + throw new Error(`expected a schedule: ${JSON.stringify(result.diagnostics)}`); + } + return result; +} + +describe("capacityProblems", () => { + it("flags a challenge with more presentations than its rooms hold", () => { + expect( + capacityProblems({ + ...small, + slots: 1, + tasks: [general("a"), general("b")], + }), + ).toEqual([ + { + kind: "capacity", + challengeId: "general", + presentations: 2, + rooms: 1, + capacity: 1, + }, + ]); + }); + + it("flags a challenge with no room at all", () => { + const [problem] = capacityProblems({ + ...small, + tasks: [sponsor("a", "mlh")], + }); + expect(problem).toMatchObject({ kind: "capacity", rooms: 0, capacity: 0 }); + }); + + it("flags a project whose presentations cannot fit with breaks", () => { + expect( + capacityProblems({ + rooms: [room("x", "x", "ENG"), room("y", "y", "ENG")], + tasks: [sponsor("a", "x"), sponsor("a", "y")], + slots: 2, + sameGap: 1, + crossGap: 1, + }), + ).toEqual([ + { + kind: "overload", + entryId: "a", + presentations: 2, + slotsNeeded: 3, + slots: 2, + }, + ]); + }); +}); + +describe("solvePlan", () => { + it("returns an empty schedule when there is nothing to place", () => { + const result = solved({ ...small, tasks: [] }); + expect(result.placements).toEqual([]); + expect(result.score).toEqual([0, 0, 0, 0, 0]); + }); + + it("finishes sponsors first, then keeps the cross-building walk", () => { + const result = solved(small); + const at = (entryId: string, challengeId: string) => + result.placements.find( + (p) => p.entryId === entryId && p.challengeId === challengeId, + )!; + expect(at("a", "sponsor").slot).toBe(0); + // Two empty slots for the walk from HEC to ENG. + expect(at("a", "general").slot).toBeGreaterThanOrEqual(3); + expect(result.relaxed).toBe(false); + expect(result.score).toEqual(bruteForce(small, false)); + }); + + it("matches the exhaustive optimum on small problems", () => { + for (const crossGap of [1, 2, 3]) { + for (const extraRoom of [false, true]) { + const inputs: PlanInputs = { + ...small, + crossGap, + slots: 5, + rooms: extraRoom + ? [...small.rooms, room("s2", "sponsor", "ENG")] + : small.rooms, + tasks: [...small.tasks, sponsor("b")], + }; + const result = solved(inputs); + const best = + bruteForce(inputs, false) ?? bruteForce(inputs, true); + expect(result.score, `crossGap ${crossGap}`).toEqual(best); + } + } + }); + + it("keeps the same-building break between one project's presentations", () => { + const inputs: PlanInputs = { + rooms: [room("x", "x", "ENG"), room("y", "y", "ENG"), room("z", "z", "ENG")], + tasks: [sponsor("a", "x"), sponsor("a", "y"), sponsor("a", "z")], + slots: 5, + sameGap: 1, + crossGap: 3, + }; + const slots = solved(inputs) + .placements.map((p) => p.slot) + .sort((a, b) => a - b); + expect(slots).toEqual([0, 2, 4]); + }); + + it("needs the longer break only when the project changes building", () => { + const inputs: PlanInputs = { + rooms: [room("x", "x", "ENG"), room("y", "y", "HEC")], + tasks: [sponsor("a", "x"), sponsor("a", "y")], + slots: 6, + sameGap: 0, + crossGap: 3, + }; + const result = solved(inputs); + const [first, second] = [...result.placements].sort((a, b) => a.slot - b.slot); + expect(second!.slot - first!.slot - 1).toBeGreaterThanOrEqual(3); + + const sameBuilding = solved({ + ...inputs, + rooms: [room("x", "x", "ENG"), room("y", "y", "ENG")], + }); + // Sponsors finish as early as possible: back to back is allowed. + expect(sameBuilding.score[0]).toBe(2); + }); + + it("falls back to the same-building break and says so", () => { + // Four slots cannot hold two presentations three slots apart. + const inputs: PlanInputs = { + rooms: [room("x", "x", "ENG"), room("y", "y", "HEC")], + tasks: [sponsor("a", "x"), sponsor("a", "y")], + slots: 4, + sameGap: 1, + crossGap: 3, + }; + expect(bruteForce(inputs, false)).toBeNull(); + const result = solved(inputs); + expect(result.relaxed).toBe(true); + expect(result.reducedBreaks).toHaveLength(1); + expect(result.reducedBreaks[0]).toMatchObject({ entryId: "a" }); + expect(validateSchedule(inputs, result.placements, { relaxed: false }).valid).toBe(false); + expect(validateSchedule(inputs, result.placements, { relaxed: true }).valid).toBe(true); + }); + + it("does not flag a relaxed solve that shortened nothing", () => { + expect(solved(small).relaxed).toBe(false); + }); + + it("reports what could not be placed when even short breaks do not fit", () => { + // Capacity is fine (two rooms, two slots each), but "a" and "b" both + // present twice and need a slot between, which two slots cannot give. + const inputs: PlanInputs = { + rooms: [room("x", "x", "ENG"), room("y", "y", "ENG")], + tasks: [ + sponsor("a", "x"), + sponsor("a", "y"), + sponsor("b", "x"), + ], + slots: 2, + sameGap: 1, + crossGap: 1, + }; + const result = solvePlan(inputs); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map((d) => d.kind)).toEqual(["overload"]); + + const tight = solvePlan({ + rooms: [room("x", "x", "ENG"), room("y", "y", "ENG")], + tasks: [ + sponsor("a", "x"), + sponsor("a", "y"), + sponsor("b", "x"), + sponsor("b", "y"), + sponsor("c", "x"), + sponsor("c", "y"), + ], + slots: 3, + sameGap: 1, + crossGap: 1, + }); + // Each challenge has three presentations for three slots, and each + // project needs slots 0 and 2, so only two projects fit. + expect(tight.ok).toBe(false); + if (tight.ok) return; + expect(tight.diagnostics.length).toBeGreaterThan(0); + expect(tight.diagnostics.every((d) => d.kind === "unplaced")).toBe(true); + }); + + it("gives the same schedule for the same inputs in any order", () => { + const inputs = scaleInputs(60, 4); + const first = solved(inputs); + const again = solved(inputs); + const shuffled = solved({ + ...inputs, + rooms: [...inputs.rooms].reverse(), + tasks: [...inputs.tasks].reverse(), + }); + expect(again.placements).toEqual(first.placements); + expect(shuffled.placements).toEqual(first.placements); + }); + + it("prefers keeping a project in one building", () => { + const inputs: PlanInputs = { + rooms: [ + room("ga", "general", "ENG"), + room("gb", "general", "HEC"), + room("s1", "sponsor", "HEC"), + ], + tasks: [general("a"), sponsor("a")], + slots: 6, + sameGap: 1, + crossGap: 2, + }; + const result = solved(inputs); + expect(result.score[3]).toBe(0); + expect( + result.placements.find((p) => p.challengeId === "general")!.roomId, + ).toBe("gb"); + }); + + it("returns only valid schedules, at or above the sponsor lower bound, across varied inputs", () => { + // A fixed-seed generator, so the cases are the same on every run. + let seed = 7; + const next = (n: number) => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed % n; + }; + let solvedCount = 0; + for (let round = 0; round < 40; round += 1) { + const buildings = ["ENG", "HEC", "CUL"].slice(0, 1 + next(3)); + const challenges = ["general", "x", "y", "z"].slice(0, 1 + next(4)); + const rooms: PlanRoom[] = []; + challenges.forEach((c) => { + for (let k = 0, n = 1 + next(3); k < n; k += 1) { + rooms.push(room(`${c}-${k}`, c, buildings[next(buildings.length)]!)); + } + }); + const tasks: PlanTask[] = []; + for (let e = 0, n = 3 + next(25); e < n; e += 1) { + for (const c of challenges) { + if (c === "general" || next(3) === 0) { + tasks.push({ entryId: `e${e}`, challengeId: c, sponsor: c !== "general" }); + } + } + } + const sameGap = next(2); + const inputs: PlanInputs = { + rooms, + tasks, + slots: 4 + next(20), + sameGap, + crossGap: sameGap + next(3), + }; + + const result = solvePlan(inputs); + if (!result.ok) continue; + solvedCount += 1; + const check = validateSchedule(inputs, result.placements, { + relaxed: result.relaxed, + }); + expect(check.valid, `round ${round}`).toBe(true); + expect(result.score).toEqual(scoreSchedule(inputs, result.placements)); + for (const c of challenges.filter((c) => c !== "general")) { + const count = tasks.filter((t) => t.challengeId === c).length; + const roomCount = rooms.filter((r) => r.challengeId === c).length; + expect(result.score[0]).toBeGreaterThanOrEqual(Math.ceil(count / roomCount)); + } + } + expect(solvedCount).toBeGreaterThan(20); + }); + + it("solves 300 projects in 12 rooms quickly and validly", () => { + const inputs = scaleInputs(300, 3); + const started = performance.now(); + const result = solved(inputs); + const elapsed = performance.now() - started; + + expect(validateSchedule(inputs, result.placements, { relaxed: false }).valid).toBe(true); + expect(result.relaxed).toBe(false); + // Each sponsor room has 60 presentations; General needs 300 / 9 slots. + expect(result.score[0]).toBe(60); + expect(result.score[1]).toBe(0); + expect(result.score[2]).toBeLessThanOrEqual(62); + expect(elapsed).toBeLessThan(8000); + }, 20_000); +}); + +/** + * `projects` teams, all in General (nine rooms in ENG); every fifth also in + * each of `sponsors` sponsor challenges, one room each in HEC. + */ +function scaleInputs(projects: number, sponsors: number): PlanInputs { + const rooms: PlanRoom[] = []; + for (let i = 0; i < 9; i += 1) rooms.push(room(`g${i}`, "general", "ENG")); + for (let s = 0; s < sponsors; s += 1) { + rooms.push(room(`s${s}`, `sponsor${s}`, "HEC")); + } + const tasks: PlanTask[] = []; + for (let i = 0; i < projects; i += 1) { + const id = `p${String(i).padStart(3, "0")}`; + tasks.push(general(id)); + for (let s = 0; s < sponsors; s += 1) { + if ((i + s) % 5 === 0) tasks.push(sponsor(id, `sponsor${s}`)); + } + } + return { rooms, tasks, slots: 72, sameGap: 1, crossGap: 2 }; +} diff --git a/packages/api/src/services/judging/schedule/solve.ts b/packages/api/src/services/judging/schedule/solve.ts new file mode 100644 index 00000000..3edae457 --- /dev/null +++ b/packages/api/src/services/judging/schedule/solve.ts @@ -0,0 +1,782 @@ +import type { Placement, PlanInputs, PlanRoom, PlanTask } from "./inputs"; +import { scoreSchedule, validateSchedule } from "./rules"; +import type { PlanScore, ReducedBreak } from "./rules"; + +// A deterministic heuristic scheduler. It builds a schedule greedily, repairs +// what the greedy pass could not place with augmenting paths, then improves +// it by local search under a fixed budget of evaluated moves. Nothing here +// reads the clock or a random source, and every loop runs in id order, so the +// same inputs always give the same schedule: saving re-solves on the server +// and compares the result with what the organiser previewed. + +/** Why a schedule could not be built, for the router to put into words. */ +export type PlanDiagnostic = + | { + kind: "capacity"; + challengeId: string; + presentations: number; + rooms: number; + capacity: number; + } + | { + kind: "overload"; + entryId: string; + presentations: number; + slotsNeeded: number; + slots: number; + } + | { kind: "unplaced"; entryId: string; challengeId: string }; + +export type PlanResult = + | { + ok: true; + /** Some change of building got only the same-building break. */ + relaxed: boolean; + placements: Placement[]; + score: PlanScore; + reducedBreaks: ReducedBreak[]; + } + | { ok: false; diagnostics: PlanDiagnostic[] }; + +// Budgets. Counted in work, not time, so a slow machine gives the same answer +// as a fast one. Sized so 300 projects in 12 rooms solves in about a second. +const BUILD_ROUNDS = 12; +const SEARCH_PASSES = 40; +const SEARCH_EVALUATIONS = 1_500_000; + +/** + * Necessary conditions: a challenge cannot have more presentations than its + * rooms have slots, and a project cannot have more presentations than fit in + * the window with the shortest break between each. Passing these does not + * promise a schedule exists. + */ +export function capacityProblems(inputs: PlanInputs): PlanDiagnostic[] { + const problems: PlanDiagnostic[] = []; + const roomsPer = new Map(); + for (const room of inputs.rooms) { + roomsPer.set(room.challengeId, (roomsPer.get(room.challengeId) ?? 0) + 1); + } + const perChallenge = new Map(); + const perEntry = new Map(); + for (const task of inputs.tasks) { + perChallenge.set( + task.challengeId, + (perChallenge.get(task.challengeId) ?? 0) + 1, + ); + perEntry.set(task.entryId, (perEntry.get(task.entryId) ?? 0) + 1); + } + + for (const [challengeId, presentations] of perChallenge) { + const rooms = roomsPer.get(challengeId) ?? 0; + const capacity = rooms * inputs.slots; + if (presentations > capacity) { + problems.push({ + kind: "capacity", + challengeId, + presentations, + rooms, + capacity, + }); + } + } + for (const [entryId, presentations] of perEntry) { + const slotsNeeded = presentations + (presentations - 1) * inputs.sameGap; + if (slotsNeeded > inputs.slots) { + problems.push({ + kind: "overload", + entryId, + presentations, + slotsNeeded, + slots: inputs.slots, + }); + } + } + return problems; +} + +// Everything below works on array indexes into id-sorted rooms and tasks. +type Model = { + rooms: PlanRoom[]; + tasks: PlanTask[]; + slots: number; + sameGap: number; + crossGap: number; + roomBuilding: Int32Array; + roomSponsor: Uint8Array; + taskEntry: Int32Array; + taskChallenge: Int32Array; + taskSponsor: Uint8Array; + entryTasks: number[][]; + challengeRooms: number[][]; + challengeTasks: number[][]; +}; + +const compareIds = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); + +function buildModel(inputs: PlanInputs, relaxed: boolean): Model { + const rooms = [...inputs.rooms].sort((a, b) => compareIds(a.id, b.id)); + const tasks = [...inputs.tasks].sort( + (a, b) => + compareIds(a.entryId, b.entryId) || + compareIds(a.challengeId, b.challengeId), + ); + + const challengeIndex = new Map(); + const entryIndex = new Map(); + const taskEntry = new Int32Array(tasks.length); + const taskChallenge = new Int32Array(tasks.length); + const taskSponsor = new Uint8Array(tasks.length); + const entryTasks: number[][] = []; + const challengeTasks: number[][] = []; + const challengeSponsor: boolean[] = []; + + tasks.forEach((task, t) => { + let c = challengeIndex.get(task.challengeId); + if (c === undefined) { + c = challengeTasks.length; + challengeIndex.set(task.challengeId, c); + challengeTasks.push([]); + challengeSponsor.push(task.sponsor); + } + let e = entryIndex.get(task.entryId); + if (e === undefined) { + e = entryTasks.length; + entryIndex.set(task.entryId, e); + entryTasks.push([]); + } + taskEntry[t] = e; + taskChallenge[t] = c; + taskSponsor[t] = task.sponsor ? 1 : 0; + entryTasks[e]!.push(t); + challengeTasks[c]!.push(t); + }); + + const buildingIndex = new Map(); + const roomBuilding = new Int32Array(rooms.length); + const roomSponsor = new Uint8Array(rooms.length); + const challengeRooms: number[][] = challengeTasks.map(() => []); + rooms.forEach((room, r) => { + let b = buildingIndex.get(room.buildingId); + if (b === undefined) { + b = buildingIndex.size; + buildingIndex.set(room.buildingId, b); + } + roomBuilding[r] = b; + const c = challengeIndex.get(room.challengeId); + if (c === undefined) return; + challengeRooms[c]!.push(r); + roomSponsor[r] = challengeSponsor[c] ? 1 : 0; + }); + + return { + rooms, + tasks, + slots: inputs.slots, + sameGap: inputs.sameGap, + crossGap: relaxed ? inputs.sameGap : inputs.crossGap, + roomBuilding, + roomSponsor, + taskEntry, + taskChallenge, + taskSponsor, + entryTasks, + challengeRooms, + challengeTasks, + }; +} + +/** Who is where. Shared by the greedy build and the local search. */ +class Layout { + readonly slotOf: Int32Array; + readonly roomOf: Int32Array; + /** room * slots + slot → task, or -1 when free. */ + readonly cells: Int32Array; + + constructor(readonly m: Model) { + this.slotOf = new Int32Array(m.tasks.length).fill(-1); + this.roomOf = new Int32Array(m.tasks.length).fill(-1); + this.cells = new Int32Array(m.rooms.length * m.slots).fill(-1); + } + + occupant(r: number, s: number): number { + return this.cells[r * this.m.slots + s]!; + } + + /** + * Whether task `t` at (r, s) keeps the required break to each of its + * project's other placed presentations. Checking every pair is the same as + * checking the neighbours in time: a farther presentation is separated by + * at least one neighbour's break, and by a cross-building one whenever the + * buildings differ. Does not look at whether the cell is free. + */ + fits(t: number, r: number, s: number): boolean { + const m = this.m; + for (const o of m.entryTasks[m.taskEntry[t]!]!) { + if (o === t) continue; + const os = this.slotOf[o]!; + if (os < 0) continue; + const gap = (os > s ? os - s : s - os) - 1; + if (gap < 0) return false; + const need = + m.roomBuilding[this.roomOf[o]!] === m.roomBuilding[r] + ? m.sameGap + : m.crossGap; + if (gap < need) return false; + } + return true; + } + + /** Places or moves `t`, freeing its old cell. */ + put(t: number, r: number, s: number) { + const slots = this.m.slots; + if (this.slotOf[t]! >= 0) { + this.cells[this.roomOf[t]! * slots + this.slotOf[t]!] = -1; + } + this.cells[r * slots + s] = t; + this.slotOf[t] = s; + this.roomOf[t] = r; + } + + placements(): Placement[] { + return this.m.tasks.map((task, t) => ({ + entryId: task.entryId, + challengeId: task.challengeId, + roomId: this.m.rooms[this.roomOf[t]!]!.id, + slot: this.slotOf[t]!, + })); + } +} + +// --------------------------------------------------------------------------- +// Construction + +/** + * Placing order: tasks that failed earlier rounds first, then sponsor + * presentations, then projects with the most presentations, then the most + * crowded challenges, then id order. + */ +function buildOrder(m: Model, boost: Int32Array): number[] { + const load = m.challengeTasks.map( + (tasks, c) => tasks.length / m.challengeRooms[c]!.length, + ); + const order = m.tasks.map((_, t) => t); + order.sort( + (a, b) => + boost[b]! - boost[a]! || + m.taskSponsor[b]! - m.taskSponsor[a]! || + m.entryTasks[m.taskEntry[b]!]!.length - + m.entryTasks[m.taskEntry[a]!]!.length || + load[m.taskChallenge[b]!]! - load[m.taskChallenge[a]!]! || + a - b, + ); + return order; +} + +/** How many of a project's time-neighbours at slot `s` are in other buildings. */ +function buildingSwitches(layout: Layout, t: number, r: number, s: number) { + const m = layout.m; + let before = -1; + let after = -1; + for (const o of m.entryTasks[m.taskEntry[t]!]!) { + const os = layout.slotOf[o]!; + if (o === t || os < 0) continue; + if (os < s && (before < 0 || os > layout.slotOf[before]!)) before = o; + if (os > s && (after < 0 || os < layout.slotOf[after]!)) after = o; + } + let switches = 0; + for (const o of [before, after]) { + if (o >= 0 && m.roomBuilding[layout.roomOf[o]!] !== m.roomBuilding[r]) { + switches += 1; + } + } + return switches; +} + +/** The earliest free cell `t` fits in, preferring its neighbours' building. */ +function placeEarliest(layout: Layout, t: number, from: number): boolean { + const m = layout.m; + const rooms = m.challengeRooms[m.taskChallenge[t]!]!; + for (let s = from; s < m.slots; s += 1) { + let best = -1; + let bestSwitches = Infinity; + for (const r of rooms) { + if (layout.occupant(r, s) >= 0 || !layout.fits(t, r, s)) continue; + const switches = buildingSwitches(layout, t, r, s); + if (switches < bestSwitches) { + best = r; + bestSwitches = switches; + if (switches === 0) break; + } + } + if (best >= 0) { + layout.put(t, best, s); + return true; + } + } + return false; +} + +/** + * Finds `t` a cell by moving other presentations of the same challenge out of + * the way (an augmenting path). Within one challenge each project has a + * single presentation, so with every other challenge held still this is + * bipartite matching and finds a cell whenever one can be freed. + */ +function augment(layout: Layout, t: number, seen: Uint8Array): boolean { + const m = layout.m; + const rooms = m.challengeRooms[m.taskChallenge[t]!]!; + for (let s = 0; s < m.slots; s += 1) { + for (const r of rooms) { + const cell = r * m.slots + s; + if (seen[cell] || !layout.fits(t, r, s)) continue; + seen[cell] = 1; + const holder = layout.cells[cell]!; + if (holder < 0 || augment(layout, holder, seen)) { + layout.put(t, r, s); + return true; + } + } + } + return false; +} + +/** + * Greedy rounds. A task left over after repair is moved up the order for the + * next round, so the presentations that are hard to place get first pick. + */ +function construct(m: Model): { layout: Layout | null; unplaced: number[] } { + const boost = new Int32Array(m.tasks.length); + let fewest: number[] | null = null; + + for (let round = 0; round < BUILD_ROUNDS; round += 1) { + const layout = new Layout(m); + // The first slot where a room of each challenge may still be free. Rooms + // fill front to back, so the greedy pass need not rescan full slots. + const open = new Int32Array(m.challengeRooms.length); + const failed: number[] = []; + + for (const t of buildOrder(m, boost)) { + const c = m.taskChallenge[t]!; + if (!placeEarliest(layout, t, open[c]!)) failed.push(t); + const rooms = m.challengeRooms[c]!; + while ( + open[c]! < m.slots && + rooms.every((r) => layout.occupant(r, open[c]!) >= 0) + ) { + open[c]! += 1; + } + } + + const unplaced = failed.filter( + (t) => !augment(layout, t, new Uint8Array(layout.cells.length)), + ); + if (unplaced.length === 0) return { layout, unplaced }; + + if (!fewest || unplaced.length < fewest.length) fewest = unplaced; + for (const t of unplaced) boost[t]! += 1; + } + + return { layout: null, unplaced: fewest ?? [] }; +} + +// --------------------------------------------------------------------------- +// Local search + +/** + * The layout plus every objective, kept current as tasks are lifted and + * dropped so a candidate move costs a few array updates, not a full rescore. + */ +class Scored extends Layout { + private readonly sponsorAt: Int32Array; + private readonly allAt: Int32Array; + sponsorEnd = 0; + allEnd = 0; + private readonly roomFirst: Int32Array; + private readonly roomLast: Int32Array; + private readonly roomCount: Int32Array; + private idle = 0; + private readonly entryChanges: Int32Array; + private readonly entryLess: Int32Array; + private changes = 0; + private lessBreaks = 0; + private readonly daySlots: number[] = []; + private readonly dayBuildings: number[] = []; + + constructor(m: Model, from: Layout) { + super(m); + this.sponsorAt = new Int32Array(m.slots); + this.allAt = new Int32Array(m.slots); + this.roomFirst = new Int32Array(m.rooms.length).fill(-1); + this.roomLast = new Int32Array(m.rooms.length).fill(-1); + this.roomCount = new Int32Array(m.rooms.length); + this.entryChanges = new Int32Array(m.entryTasks.length); + this.entryLess = new Int32Array(m.entryTasks.length); + for (let t = 0; t < m.tasks.length; t += 1) { + this.drop(t, from.roomOf[t]!, from.slotOf[t]!); + } + } + + private roomIdle(r: number) { + return this.roomCount[r]! > 0 + ? this.roomLast[r]! - this.roomFirst[r]! + 1 - this.roomCount[r]! + : 0; + } + + /** + * Changes of building and (negated) breaks for one project, optionally as + * if task `moved` sat at (room, slot). + */ + private itinerary(e: number, moved = -1, room = -1, slot = -1) { + const m = this.m; + const slots = this.daySlots; + const buildings = this.dayBuildings; + let n = 0; + for (const o of m.entryTasks[e]!) { + const s = o === moved ? slot : this.slotOf[o]!; + if (s < 0) continue; + const b = m.roomBuilding[o === moved ? room : this.roomOf[o]!]!; + // Insertion sort: a project has a handful of presentations. + let j = n - 1; + while (j >= 0 && slots[j]! > s) { + slots[j + 1] = slots[j]!; + buildings[j + 1] = buildings[j]!; + j -= 1; + } + slots[j + 1] = s; + buildings[j + 1] = b; + n += 1; + } + let changes = 0; + let less = 0; + for (let i = 1; i < n; i += 1) { + if (buildings[i] !== buildings[i - 1]) changes += 1; + less -= slots[i]! - slots[i - 1]! - 1; + } + return { changes, less }; + } + + private refreshEntry(e: number) { + const { changes, less } = this.itinerary(e); + this.changes += changes - this.entryChanges[e]!; + this.lessBreaks += less - this.entryLess[e]!; + this.entryChanges[e] = changes; + this.entryLess[e] = less; + } + + lift(t: number) { + const m = this.m; + const r = this.roomOf[t]!; + const s = this.slotOf[t]!; + this.cells[r * m.slots + s] = -1; + this.slotOf[t] = -1; + this.roomOf[t] = -1; + + this.allAt[s]! -= 1; + if (this.allAt[s] === 0 && s + 1 === this.allEnd) { + let x = s - 1; + while (x >= 0 && this.allAt[x] === 0) x -= 1; + this.allEnd = x + 1; + } + if (m.taskSponsor[t]) { + this.sponsorAt[s]! -= 1; + if (this.sponsorAt[s] === 0 && s + 1 === this.sponsorEnd) { + let x = s - 1; + while (x >= 0 && this.sponsorAt[x] === 0) x -= 1; + this.sponsorEnd = x + 1; + } + } + + if (m.roomSponsor[r]) this.idle -= this.roomIdle(r); + this.roomCount[r]! -= 1; + if (this.roomCount[r] === 0) { + this.roomFirst[r] = -1; + this.roomLast[r] = -1; + } else { + if (s === this.roomFirst[r]) { + let x = s + 1; + while (this.occupant(r, x) < 0) x += 1; + this.roomFirst[r] = x; + } + if (s === this.roomLast[r]) { + let x = s - 1; + while (this.occupant(r, x) < 0) x -= 1; + this.roomLast[r] = x; + } + } + if (m.roomSponsor[r]) this.idle += this.roomIdle(r); + + this.refreshEntry(m.taskEntry[t]!); + } + + drop(t: number, r: number, s: number) { + const m = this.m; + this.cells[r * m.slots + s] = t; + this.slotOf[t] = s; + this.roomOf[t] = r; + + this.allAt[s]! += 1; + if (s + 1 > this.allEnd) this.allEnd = s + 1; + if (m.taskSponsor[t]) { + this.sponsorAt[s]! += 1; + if (s + 1 > this.sponsorEnd) this.sponsorEnd = s + 1; + } + + if (m.roomSponsor[r]) this.idle -= this.roomIdle(r); + this.roomCount[r]! += 1; + if (this.roomCount[r] === 1) { + this.roomFirst[r] = s; + this.roomLast[r] = s; + } else { + if (s < this.roomFirst[r]!) this.roomFirst[r] = s; + if (s > this.roomLast[r]!) this.roomLast[r] = s; + } + if (m.roomSponsor[r]) this.idle += this.roomIdle(r); + + this.refreshEntry(m.taskEntry[t]!); + } + + /** + * The search's own ordering. It refines the public score with how many + * presentations sit in the last sponsor slot and the last slot overall: + * emptying the last slot one move at a time is how a finish time comes + * down, and without the counts each of those moves would look like no + * progress. + */ + read(into: Int32Array) { + into[0] = this.sponsorEnd; + into[1] = this.sponsorEnd > 0 ? this.sponsorAt[this.sponsorEnd - 1]! : 0; + into[2] = this.idle; + into[3] = this.allEnd; + into[4] = this.allEnd > 0 ? this.allAt[this.allEnd - 1]! : 0; + into[5] = this.changes; + into[6] = this.lessBreaks; + } + + publicScore(): PlanScore { + return [ + this.sponsorEnd, + this.idle, + this.allEnd, + this.changes, + this.lessBreaks, + ]; + } + + /** + * The (changes, breaks) totals if `t` and `u` traded cells. Occupancy is + * unchanged by a trade, so nothing else in the score can move. + */ + tradedTotals(t: number, u: number) { + const m = this.m; + const et = m.taskEntry[t]!; + const eu = m.taskEntry[u]!; + const a = this.itinerary(et, t, this.roomOf[u]!, this.slotOf[u]!); + const b = this.itinerary(eu, u, this.roomOf[t]!, this.slotOf[t]!); + return { + changes: + this.changes - + this.entryChanges[et]! - + this.entryChanges[eu]! + + a.changes + + b.changes, + less: + this.lessBreaks - + this.entryLess[et]! - + this.entryLess[eu]! + + a.less + + b.less, + }; + } +} + +function lexLess(a: Int32Array | number[], b: Int32Array | number[]) { + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return a[i]! < b[i]!; + } + return false; +} + +const RELOCATE = 1; +const TRADE = 2; +const EJECT = 3; + +/** + * Hill-climbs from a complete layout. For each task in turn it tries every + * free cell of its challenge, trading cells with each other presentation of + * that challenge, and, for a task holding up a finish time, pushing another + * presentation into a free cell to take its place. The best improving move + * is applied. Returns the layout with the best public score seen. + */ +function improve(m: Model, start: Layout): Layout { + const state = new Scored(m, start); + const best = new Layout(m); + const keepBest = () => { + best.slotOf.set(state.slotOf); + best.roomOf.set(state.roomOf); + }; + keepBest(); + let bestScore = state.publicScore(); + + const current = new Int32Array(7); + const trial = new Int32Array(7); + const winner = new Int32Array(7); + let budget = SEARCH_EVALUATIONS; + + for (let pass = 0; pass < SEARCH_PASSES && budget > 0; pass += 1) { + let moved = false; + + for (let t = 0; t < m.tasks.length && budget > 0; t += 1) { + const c = m.taskChallenge[t]!; + const rooms = m.challengeRooms[c]!; + const peers = m.challengeTasks[c]!; + const r0 = state.roomOf[t]!; + const s0 = state.slotOf[t]!; + // A move to or past the current finish can only make it later. + const limit = m.taskSponsor[t] ? state.sponsorEnd : state.allEnd; + + state.read(current); + winner.set(current); + let kind = 0; + let moveA = -1; + let moveB = -1; + let moveC = -1; + + for (let s = 0; s < limit; s += 1) { + for (const r of rooms) { + if (state.occupant(r, s) >= 0 || !state.fits(t, r, s)) continue; + budget -= 1; + state.lift(t); + state.drop(t, r, s); + state.read(trial); + state.lift(t); + state.drop(t, r0, s0); + if (lexLess(trial, winner)) { + winner.set(trial); + kind = RELOCATE; + moveA = r; + moveB = s; + } + } + } + + for (const u of peers) { + if (u === t) continue; + const ru = state.roomOf[u]!; + const su = state.slotOf[u]!; + if (!state.fits(t, ru, su) || !state.fits(u, r0, s0)) continue; + budget -= 1; + const traded = state.tradedTotals(t, u); + trial.set(current); + trial[5] = traded.changes; + trial[6] = traded.less; + if (lexLess(trial, winner)) { + winner.set(trial); + kind = TRADE; + moveA = u; + } + } + + if (s0 + 1 === limit) { + for (const u of peers) { + if (u === t) continue; + const ru = state.roomOf[u]!; + const su = state.slotOf[u]!; + if (!state.fits(t, ru, su)) continue; + for (let s = 0; s < limit; s += 1) { + for (const r of rooms) { + if (state.occupant(r, s) >= 0 || !state.fits(u, r, s)) continue; + budget -= 1; + state.lift(t); + state.lift(u); + state.drop(t, ru, su); + state.drop(u, r, s); + state.read(trial); + state.lift(u); + state.lift(t); + state.drop(u, ru, su); + state.drop(t, r0, s0); + if (lexLess(trial, winner)) { + winner.set(trial); + kind = EJECT; + moveA = u; + moveB = r; + moveC = s; + } + } + } + } + } + + if (kind === 0) continue; + moved = true; + if (kind === RELOCATE) { + state.lift(t); + state.drop(t, moveA, moveB); + } else { + const u = moveA; + const ru = state.roomOf[u]!; + const su = state.slotOf[u]!; + state.lift(t); + state.lift(u); + state.drop(t, ru, su); + if (kind === TRADE) state.drop(u, r0, s0); + else state.drop(u, moveB, moveC); + } + + const score = state.publicScore(); + if (lexLess(score, bestScore)) { + bestScore = score; + keepBest(); + } + } + + if (!moved) break; + } + + return best; +} + +// --------------------------------------------------------------------------- + +/** + * Builds a schedule. Tries the full cross-building break first; when no + * schedule is found that way, tries again with the same-building break + * everywhere and says so. Every schedule returned has passed + * `validateSchedule` and carries the score `scoreSchedule` gives it. + */ +export function solvePlan(inputs: PlanInputs): PlanResult { + const capacity = capacityProblems(inputs); + if (capacity.length > 0) return { ok: false, diagnostics: capacity }; + + let unplaced: PlanDiagnostic[] = []; + for (const relaxed of [false, true]) { + const m = buildModel(inputs, relaxed); + const built = construct(m); + if (!built.layout) { + unplaced = built.unplaced.map((t) => ({ + kind: "unplaced" as const, + entryId: m.tasks[t]!.entryId, + challengeId: m.tasks[t]!.challengeId, + })); + continue; + } + + const placements = improve(m, built.layout).placements(); + const check = validateSchedule(inputs, placements, { relaxed }); + if (!check.valid) { + throw new Error( + `The scheduler built a schedule that breaks its own rules: ${check.errors.join(" ")}`, + ); + } + return { + ok: true, + // A relaxed solve can still land on a schedule that shortened nothing. + relaxed: check.reducedBreaks.length > 0, + placements, + score: scoreSchedule(inputs, placements), + reducedBreaks: check.reducedBreaks, + }; + } + + return { ok: false, diagnostics: unplaced }; +} diff --git a/packages/api/src/services/judging/scope.test.ts b/packages/api/src/services/judging/scope.test.ts new file mode 100644 index 00000000..4bad80cd --- /dev/null +++ b/packages/api/src/services/judging/scope.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { + descendantsOf, + effectiveMode, + isSponsorChallenge, + normalizeLabel, + rootOf, + sameLabel, +} from "./scope"; +import type { ChallengeNode } from "./scope"; + +// General (scheduled, no children) +// MLH Challenges (unscheduled group) +// ├── Best Use of MongoDB (stored as scheduled, inherits unscheduled) +// │ └── MongoDB Atlas bonus +// └── Best Domain Name +// Online (remote) +const challenges: ChallengeNode[] = [ + { id: "general", parentId: null, mode: "scheduled" }, + { id: "mlh", parentId: null, mode: "unscheduled" }, + { id: "mongo", parentId: "mlh", mode: "scheduled" }, + { id: "domain", parentId: "mlh", mode: "remote" }, + { id: "atlas", parentId: "mongo", mode: "scheduled" }, + { id: "online", parentId: null, mode: "remote" }, +]; + +describe("rootOf", () => { + it("is the challenge itself when it has no parent", () => { + expect(rootOf(challenges, "general")?.id).toBe("general"); + }); + + it("walks up to the top of the tree", () => { + expect(rootOf(challenges, "mongo")?.id).toBe("mlh"); + expect(rootOf(challenges, "atlas")?.id).toBe("mlh"); + }); + + it("is null for an id that is not in the list", () => { + expect(rootOf(challenges, "missing")).toBeNull(); + }); + + it("treats a parent that is not in the list as no parent", () => { + const orphan = [{ id: "a", parentId: "gone", mode: "remote" as const }]; + expect(rootOf(orphan, "a")?.id).toBe("a"); + }); + + it("is null when parent links loop", () => { + const loop: ChallengeNode[] = [ + { id: "a", parentId: "b", mode: "scheduled" }, + { id: "b", parentId: "a", mode: "scheduled" }, + ]; + expect(rootOf(loop, "a")).toBeNull(); + }); +}); + +describe("effectiveMode", () => { + it("is a root's own mode", () => { + expect(effectiveMode(challenges, "general")).toBe("scheduled"); + expect(effectiveMode(challenges, "online")).toBe("remote"); + }); + + it("is inherited from the root, whatever the child stores", () => { + expect(effectiveMode(challenges, "mongo")).toBe("unscheduled"); + expect(effectiveMode(challenges, "domain")).toBe("unscheduled"); + expect(effectiveMode(challenges, "atlas")).toBe("unscheduled"); + }); + + it("is null for an unknown challenge", () => { + expect(effectiveMode(challenges, "missing")).toBeNull(); + }); +}); + +describe("descendantsOf", () => { + it("lists every level below, nearest first, not the challenge itself", () => { + expect(descendantsOf(challenges, "mlh").map((c) => c.id)).toEqual([ + "mongo", + "domain", + "atlas", + ]); + }); + + it("is empty for a leaf or an unknown id", () => { + expect(descendantsOf(challenges, "general")).toEqual([]); + expect(descendantsOf(challenges, "missing")).toEqual([]); + }); + + it("starts below a child, not at its root", () => { + expect(descendantsOf(challenges, "mongo").map((c) => c.id)).toEqual([ + "atlas", + ]); + }); +}); + +describe("labels", () => { + it("trims and collapses whitespace", () => { + expect(normalizeLabel(" Best Use of\tMongoDB \n")).toBe( + "Best Use of MongoDB", + ); + }); + + it("matches ignoring case and spacing", () => { + expect(sameLabel("Best Use of MongoDB", " best use of mongodb")).toBe( + true, + ); + expect(sameLabel("MLH", "mlh")).toBe(true); + }); + + it("does not match different labels", () => { + expect(sameLabel("Best Use of MongoDB", "Best Use of Gemini")).toBe(false); + expect(sameLabel("AI", "A I")).toBe(false); + }); +}); + +describe("isSponsorChallenge", () => { + it("is every challenge but the general ones", () => { + expect(isSponsorChallenge({ isGeneral: false })).toBe(true); + expect(isSponsorChallenge({ isGeneral: true })).toBe(false); + }); +}); diff --git a/packages/api/src/services/judging/scope.ts b/packages/api/src/services/judging/scope.ts new file mode 100644 index 00000000..d8691ed5 --- /dev/null +++ b/packages/api/src/services/judging/scope.ts @@ -0,0 +1,93 @@ +// Pure helpers over one edition's challenge list. Challenges form a shallow +// tree: a group (say "MLH Challenges") holds child challenges, and a child is +// judged the way its root is. Rooms serve a root, and guest judges are locked +// to their room's challenge and everything below it. + +export type ChallengeMode = "scheduled" | "unscheduled" | "remote"; + +/** The columns these helpers need from a judging_challenge row. */ +export type ChallengeNode = { + id: string; + parentId: string | null; + mode: ChallengeMode; +}; + +/** + * The top of the tree that `id` sits in: itself when it has no parent. Null + * when `id` is not in the list, or when the parent links loop. + */ +export function rootOf( + challenges: readonly T[], + id: string, +): T | null { + const byId = new Map(challenges.map((c) => [c.id, c])); + let current = byId.get(id); + const seen = new Set(); + + while (current) { + if (seen.has(current.id)) return null; + seen.add(current.id); + const parent = current.parentId ? byId.get(current.parentId) : undefined; + if (!parent) return current; + current = parent; + } + + return null; +} + +/** + * Whether a challenge is a sponsor's: every challenge but the general ones. + * The schedule and the judge desk both read the flag this way. + */ +export function isSponsorChallenge(challenge: { isGeneral: boolean }): boolean { + return !challenge.isGeneral; +} + +/** How a challenge is judged: always its root's mode. Null when unknown. */ +export function effectiveMode( + challenges: readonly ChallengeNode[], + id: string, +): ChallengeMode | null { + return rootOf(challenges, id)?.mode ?? null; +} + +/** + * Every challenge below `id` (children, their children, …), not `id` itself, + * in list order within each level. + */ +export function descendantsOf( + challenges: readonly T[], + id: string, +): T[] { + const found: T[] = []; + const seen = new Set([id]); + let frontier = [id]; + + while (frontier.length > 0) { + const parents = new Set(frontier); + frontier = []; + for (const challenge of challenges) { + if ( + challenge.parentId && + parents.has(challenge.parentId) && + !seen.has(challenge.id) + ) { + seen.add(challenge.id); + found.push(challenge); + frontier.push(challenge.id); + } + } + } + + return found; +} + +/** A label as stored: trimmed, runs of whitespace collapsed to one space. */ +export function normalizeLabel(label: string): string { + return label.trim().replace(/\s+/g, " "); +} + +/** Whether two labels name the same challenge, ignoring case and spacing. */ +export function sameLabel(a: string, b: string): boolean { + return normalizeLabel(a).toLowerCase() === normalizeLabel(b).toLowerCase(); +} diff --git a/packages/api/src/services/judging/scoring-sql.test.ts b/packages/api/src/services/judging/scoring-sql.test.ts new file mode 100644 index 00000000..94c440b4 --- /dev/null +++ b/packages/api/src/services/judging/scoring-sql.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { judgingEvaluations } from "@query/db"; +import { holdsEveryRating } from "./scoring-sql"; + +const HACK = "11111111-1111-4111-8111-111111111111"; + +// Built with a handle that has no connection: only the SQL text is looked at. +const db = drizzle.mock(); + +/** The SQL with its spacing and parameter numbers evened out. */ +const flat = (text: string) => + text + .replace(/\s+/g, " ") + .replace(/\( /g, "(") + .replace(/ \)/g, ")") + .replace(/\$\d+/g, "$?"); + +// An evaluation holds every rating when its ratings are at least as many as +// the rubric's rating items: scoring.hasEveryRating, in SQL. +const RULE = + 'select count(*) from "judging_evaluation_rating" as r where r."evaluation_id" = "judging_evaluation"."id"'; +const ITEMS = + 'select count(*) from "judging_rubric_item" as i where i."hackathon_id" = $? and i."kind" = \'rating\''; +const HOLDS = `(${RULE}) >= (${ITEMS})`; + +describe("holdsEveryRating", () => { + it("counts the evaluation's ratings against the rubric's rating items", () => { + const query = db + .select({ id: judgingEvaluations.id }) + .from(judgingEvaluations) + .where( + and(eq(judgingEvaluations.hackathonId, HACK), holdsEveryRating(HACK)), + ) + .toSQL(); + + const text = flat(query.sql); + expect(text).toContain(HOLDS); + expect(query.params).toEqual([HACK, HACK]); + // Whether a written response was left empty is not looked at. + expect(text).not.toContain("judging_evaluation_response"); + expect(text).not.toContain("is_complete"); + }); + + it("keeps its table names in a select from the one table, which strips them from columns", () => { + // A sql field over a single table loses the table name from any column in + // it. The names here are not columns, so the subquery still correlates on + // the evaluation's id and not on its own. + const counted = db + .select({ + total: sql`count(*)::int`, + complete: sql`count(*) filter (where ${holdsEveryRating(HACK)})::int`, + holds: holdsEveryRating(HACK), + }) + .from(judgingEvaluations) + .where(eq(judgingEvaluations.hackathonId, HACK)) + .toSQL(); + + const text = flat(counted.sql); + expect(text.split(HOLDS)).toHaveLength(3); + expect(counted.params).toEqual([HACK, HACK, HACK]); + }); + + it("stands beside other conditions on the same evaluations", () => { + const query = db + .select({ id: judgingEvaluations.id }) + .from(judgingEvaluations) + .where( + and( + inArray(judgingEvaluations.entryId, ["a", "b"]), + holdsEveryRating(HACK), + ), + ) + .toSQL(); + expect(query.sql).toContain('"judging_evaluation"."entry_id" in ($1, $2)'); + expect(query.params).toEqual(["a", "b", HACK]); + }); +}); diff --git a/packages/api/src/services/judging/scoring-sql.ts b/packages/api/src/services/judging/scoring-sql.ts new file mode 100644 index 00000000..5b108af4 --- /dev/null +++ b/packages/api/src/services/judging/scoring-sql.ts @@ -0,0 +1,33 @@ +import { getTableName, sql } from "drizzle-orm"; +import { + judgingEvaluationRatings, + judgingEvaluations, + judgingRubricItems, +} from "@query/db"; + +// scoring.hasEveryRating for the queries that count evaluations in the +// database instead of loading them. The same rule, so a count an organiser or +// a hacker reads is the number placings rank on: an evaluation counts once it +// holds a rating for every rating item of the rubric, whether or not a written +// response was left empty. +// +// Names are spelled out rather than passed as columns. A select from one table +// with no joins drops the table name from any column in a sql expression, and +// the subquery would then compare its own columns with each other. + +/** + * True for a judging_evaluation row of the edition that holds every rating. + * The query must read judging_evaluation under its own name, as every query + * that uses this does. + */ +export const holdsEveryRating = (hackathonId: string) => { + const evaluation = sql.identifier(getTableName(judgingEvaluations)); + return sql`( + select count(*) from ${judgingEvaluationRatings} as r + where r.${sql.identifier(judgingEvaluationRatings.evaluationId.name)} = ${evaluation}.${sql.identifier(judgingEvaluations.id.name)} +) >= ( + select count(*) from ${judgingRubricItems} as i + where i.${sql.identifier(judgingRubricItems.hackathonId.name)} = ${hackathonId} + and i.${sql.identifier(judgingRubricItems.kind.name)} = 'rating' +)`; +}; diff --git a/packages/api/src/services/judging/scoring.test.ts b/packages/api/src/services/judging/scoring.test.ts new file mode 100644 index 00000000..0e0162e6 --- /dev/null +++ b/packages/api/src/services/judging/scoring.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "vitest"; +import { + evaluationScore, + hasEveryRating, + meanOrNull, + overallScore, + rankByScore, + scopedScore, +} from "./scoring"; +import type { ScoredEvaluation } from "./scoring"; + +describe("meanOrNull", () => { + it("is null for no values", () => { + expect(meanOrNull([])).toBeNull(); + }); + + it("is the arithmetic mean", () => { + expect(meanOrNull([4])).toBe(4); + expect(meanOrNull([1, 2, 3, 4])).toBe(2.5); + }); +}); + +describe("hasEveryRating", () => { + it("is whether the evaluation rated every rating item", () => { + expect(hasEveryRating([5, 4, 3], 3)).toBe(true); + expect(hasEveryRating([5, 4], 3)).toBe(false); + expect(hasEveryRating([], 3)).toBe(false); + }); + + it("does not need to be told about written responses", () => { + // Four rating items, however many responses the rubric also asks for. + expect(hasEveryRating([1, 2, 3, 4], 4)).toBe(true); + }); + + it("has nothing to miss on a rubric with no rating items", () => { + expect(hasEveryRating([], 0)).toBe(true); + }); +}); + +describe("evaluationScore", () => { + it("is the mean of the ratings", () => { + expect(evaluationScore([5, 4, 3])).toBe(4); + expect(evaluationScore([1, 2])).toBe(1.5); + }); + + it("is null with no ratings", () => { + expect(evaluationScore([])).toBeNull(); + }); +}); + +const evals: ScoredEvaluation[] = [ + { challengeId: "general", ratingsComplete: true, ratings: [5, 5, 5] }, // 5 + { challengeId: "general", ratingsComplete: true, ratings: [3, 3, 3] }, // 3 + { challengeId: "general", ratingsComplete: false, ratings: [1, 1] }, // short of a rating: ignored + { challengeId: "mongo", ratingsComplete: true, ratings: [4, 2] }, // 3 + { challengeId: "mongo", ratingsComplete: true, ratings: [] }, // no score +]; + +describe("scopedScore", () => { + it("averages evaluations with every rating in that challenge only", () => { + expect(scopedScore(evals, "general")).toEqual({ score: 4, count: 2 }); + expect(scopedScore(evals, "mongo")).toEqual({ score: 3, count: 1 }); + }); + + it("is null with nothing to average", () => { + expect(scopedScore(evals, "other")).toEqual({ score: null, count: 0 }); + expect( + scopedScore( + [{ challengeId: "general", ratingsComplete: false, ratings: [5] }], + "general", + ), + ).toEqual({ score: null, count: 0 }); + }); +}); + +describe("overallScore", () => { + it("weighs every counted evaluation the same, across challenges", () => { + // (5 + 3 + 3) / 3 + expect(overallScore(evals)).toEqual({ score: 11 / 3, count: 3 }); + }); + + it("is null with no evaluations", () => { + expect(overallScore([])).toEqual({ score: null, count: 0 }); + }); +}); + +describe("which evaluations count", () => { + const score = (over: Partial) => + overallScore([{ challengeId: "general", ratings: [4, 2], ...over }]); + + it("counts one with every rating, though a written response is missing", () => { + // An auto-submit at the hard end: not complete in the database, but all + // its ratings are there. + expect(score({ ratingsComplete: true, isComplete: false })).toEqual({ + score: 3, + count: 1, + }); + }); + + it("leaves out one short of a rating, though it was submitted whole", () => { + expect(score({ ratingsComplete: false, isComplete: true })).toEqual({ + score: null, + count: 0, + }); + }); + + it("reads isComplete from a caller that gives no ratingsComplete", () => { + expect(score({ isComplete: true })).toEqual({ score: 3, count: 1 }); + expect(score({ isComplete: false })).toEqual({ score: null, count: 0 }); + }); + + it("counts nothing when told nothing", () => { + expect(score({})).toEqual({ score: null, count: 0 }); + }); +}); + +describe("an adjusted score", () => { + const adjusted: ScoredEvaluation[] = [ + { + challengeId: "general", + ratingsComplete: true, + ratings: [5, 5], + adjustedScore: 4, + }, + { + challengeId: "general", + ratingsComplete: true, + ratings: [3, 3], + adjustedScore: 3.5, + }, + // No adjusted score: the mean of its ratings counts. + { challengeId: "general", ratingsComplete: true, ratings: [2, 4] }, + // Short of a rating is still left out, adjusted or not. + { + challengeId: "general", + ratingsComplete: false, + ratings: [1], + adjustedScore: 1, + }, + ]; + + it("counts in place of the mean of the ratings", () => { + // (4 + 3.5 + 3) / 3 + expect(scopedScore(adjusted, "general")).toEqual({ score: 3.5, count: 3 }); + expect(overallScore(adjusted)).toEqual({ score: 3.5, count: 3 }); + }); + + it("leaves evaluations without one on the raw mean", () => { + const raw = adjusted.map(({ challengeId, ratingsComplete, ratings }) => ({ + challengeId, + ratingsComplete, + ratings, + })); + // (5 + 3 + 3) / 3 + expect(overallScore(raw)).toEqual({ score: 11 / 3, count: 3 }); + }); +}); + +describe("rankByScore", () => { + it("ranks highest first", () => { + const ranked = rankByScore([ + { id: "a", score: 3 }, + { id: "b", score: 4.5 }, + { id: "c", score: 1 }, + ]); + expect(ranked.map((r) => [r.id, r.rank])).toEqual([ + ["b", 1], + ["a", 2], + ["c", 3], + ]); + }); + + it("gives ties the same rank and skips the next", () => { + const ranked = rankByScore([ + { id: "a", score: 4 }, + { id: "b", score: 5 }, + { id: "c", score: 4 }, + { id: "d", score: 2 }, + ]); + expect(ranked.map((r) => [r.id, r.rank])).toEqual([ + ["b", 1], + ["a", 2], + ["c", 2], + ["d", 4], + ]); + }); + + it("ties scores that round to the same hundredth", () => { + const ranked = rankByScore([ + { id: "a", score: 10 / 3 }, + { id: "b", score: (3 + 3 + 4) / 3 + 1e-12 }, + ]); + expect(ranked.map((r) => r.rank)).toEqual([1, 1]); + }); + + it("puts rows without a score last, sharing a rank", () => { + const ranked = rankByScore([ + { id: "a", score: null }, + { id: "b", score: 2 }, + { id: "c", score: null }, + { id: "d", score: 3 }, + ]); + expect(ranked.map((r) => [r.id, r.rank])).toEqual([ + ["d", 1], + ["b", 2], + ["a", 3], + ["c", 3], + ]); + }); + + it("keeps the other fields and does not touch the input", () => { + const rows = [ + { id: "a", score: 1, title: "Alpha" }, + { id: "b", score: 2, title: "Beta" }, + ]; + const ranked = rankByScore(rows); + expect(ranked[0]).toEqual({ id: "b", score: 2, title: "Beta", rank: 1 }); + expect(rows.map((r) => r.id)).toEqual(["a", "b"]); + expect(rankByScore([])).toEqual([]); + }); +}); diff --git a/packages/api/src/services/judging/scoring.ts b/packages/api/src/services/judging/scoring.ts new file mode 100644 index 00000000..0cd35bdc --- /dev/null +++ b/packages/api/src/services/judging/scoring.ts @@ -0,0 +1,120 @@ +// Pure scoring for room judging. A judge's evaluation scores the mean of its +// 1–5 ratings; a project's score in a scope is the mean of those evaluation +// scores, counting evaluations that hold every rating. A written response left +// empty does not stop one counting: a draft submitted at the hard end is often +// short of a response and is the project's only grade. Every evaluation weighs +// the same, whoever wrote it. Placements feed in the calibrated score of each +// evaluation (calibration.ts) in place of the raw mean; the judge desk does not. + +/** An evaluation as scoring sees it. */ +export type ScoredEvaluation = { + challengeId: string; + /** + * Holds a rating for every rating item of the rubric (hasEveryRating). It + * counts when this is true, whatever was left unwritten. + */ + ratingsComplete?: boolean; + /** + * For a caller that only knows the evaluation was submitted whole, which + * implies every rating is there. Read only when ratingsComplete is not + * given. + */ + isComplete?: boolean; + /** The 1–5 rating values it holds. */ + ratings: readonly number[]; + /** Counted in place of the mean of the ratings when set: the calibrated score. */ + adjustedScore?: number; +}; + +export type ScoreSummary = { + /** Null when no counted evaluation has a rating. */ + score: number | null; + /** How many evaluations the score is the mean of. */ + count: number; +}; + +export function meanOrNull(values: readonly number[]): number | null { + if (values.length === 0) return null; + let sum = 0; + for (const value of values) sum += value; + return sum / values.length; +} + +/** One evaluation's score: the mean of its ratings, null with none. */ +export function evaluationScore(values: readonly number[]): number | null { + return meanOrNull(values); +} + +/** + * Whether an evaluation rated every rating item. The rubric is fixed once an + * evaluation exists, and a rating is stored once per item, so counting them + * against the rubric's rating items is enough. Responses are not looked at. + */ +export function hasEveryRating( + ratings: readonly number[], + ratingItems: number, +): boolean { + return ratings.length >= ratingItems; +} + +const counts = (evaluation: ScoredEvaluation) => + evaluation.ratingsComplete ?? evaluation.isComplete ?? false; + +function summarize(evals: readonly ScoredEvaluation[]): ScoreSummary { + const scores = evals + .filter(counts) + .map( + (evaluation) => + evaluation.adjustedScore ?? evaluationScore(evaluation.ratings), + ) + .filter((score): score is number => score !== null); + return { score: meanOrNull(scores), count: scores.length }; +} + +/** A project's score in one challenge, from that project's evaluations. */ +export function scopedScore( + evals: readonly ScoredEvaluation[], + challengeId: string, +): ScoreSummary { + return summarize(evals.filter((e) => e.challengeId === challengeId)); +} + +/** A project's overall score: every counted evaluation, in any challenge. */ +export function overallScore(evals: readonly ScoredEvaluation[]): ScoreSummary { + return summarize(evals); +} + +// Placements are stored as numeric(4,2), so scores that print the same must +// tie the same; comparing raw floats split 3.333… from 3.333… by rounding noise. +const toHundredths = (score: number) => Math.round(score * 100); + +/** + * Ranks rows by score, highest first. Equal scores share a rank and the next + * rank skips (1, 1, 3). Rows without a score come last and share the rank + * after the scored ones. Ties keep their input order. + */ +export function rankByScore( + rows: readonly T[], +): (T & { rank: number })[] { + const ordered = rows + .map((row, index) => ({ row, index })) + .sort((a, b) => { + const sa = a.row.score === null ? null : toHundredths(a.row.score); + const sb = b.row.score === null ? null : toHundredths(b.row.score); + if (sa === sb) return a.index - b.index; + if (sa === null) return 1; + if (sb === null) return -1; + return sb - sa; + }); + + const ranked: (T & { rank: number })[] = []; + let previous: number | null | undefined; + let rank = 0; + ordered.forEach(({ row }, position) => { + const key = row.score === null ? null : toHundredths(row.score); + if (position === 0 || key !== previous) rank = position + 1; + previous = key; + ranked.push({ ...row, rank }); + }); + return ranked; +} diff --git a/packages/api/src/services/judging/timing.test.ts b/packages/api/src/services/judging/timing.test.ts new file mode 100644 index 00000000..f3b4276a --- /dev/null +++ b/packages/api/src/services/judging/timing.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it } from "vitest"; +import { + DEADLINE_GRACE_MS, + TAKEOVER_AFTER_MS, + evaluationTiming, + reservedUntil, + takeoverAt, +} from "./timing"; +import type { TimingInput } from "./timing"; + +const startsAt = new Date("2027-02-08T15:00:00Z"); +const deadlineAt = new Date("2027-02-08T15:08:00Z"); +const before = new Date("2027-02-08T14:55:00Z"); +const during = new Date("2027-02-08T15:03:00Z"); +const afterDeadline = new Date("2027-02-08T15:09:00Z"); + +const scheduled = (over: Partial = {}): TimingInput => ({ + judgeId: "judge-a", + hasSchedule: true, + mode: "scheduled", + challengeId: "general", + evaluation: null, + appointment: { id: "appt-1", startsAt, deadlineAt }, + presenceChallengeId: "general", + ...over, +}); + +describe("evaluationTiming", () => { + it("is untimed before any schedule is saved", () => { + expect( + evaluationTiming( + scheduled({ + hasSchedule: false, + appointment: null, + presenceChallengeId: null, + }), + before, + ), + ).toEqual({ canEdit: true, deadlineAt: null, appointmentId: null }); + }); + + it("is untimed for unscheduled and remote challenges", () => { + for (const mode of ["unscheduled", "remote"] as const) { + expect( + evaluationTiming( + scheduled({ mode, appointment: null, presenceChallengeId: null }), + before, + ), + ).toEqual({ canEdit: true, deadlineAt: null, appointmentId: null }); + } + }); + + it("keeps a submitted evaluation editable until the deadline", () => { + for (const now of [before, during]) { + expect( + evaluationTiming( + scheduled({ + evaluation: { appointmentId: "appt-1" }, + presenceChallengeId: null, + }), + now, + ), + ).toEqual({ canEdit: true, deadlineAt, appointmentId: "appt-1" }); + } + }); + + it("gives a write DEADLINE_GRACE_MS past the deadline, and a read none", () => { + const inGrace = new Date(deadlineAt.getTime() + DEADLINE_GRACE_MS - 1); + const pastGrace = new Date(deadlineAt.getTime() + DEADLINE_GRACE_MS); + for (const evaluation of [null, { appointmentId: "appt-1" }]) { + const write = scheduled({ evaluation, graceMs: DEADLINE_GRACE_MS }); + expect(evaluationTiming(write, inGrace)).toMatchObject({ + canEdit: true, + }); + expect(evaluationTiming(write, pastGrace)).toMatchObject({ + canEdit: false, + ended: true, + }); + expect( + evaluationTiming(scheduled({ evaluation }), inGrace), + ).toMatchObject({ canEdit: false, ended: true }); + } + }); + + it("still reports the deadline to a write in the grace window", () => { + // The deadline is already past, but a draft first saved now still has to + // carry it, or nothing submits the draft for the judge. + const inGrace = new Date(deadlineAt.getTime() + DEADLINE_GRACE_MS - 1); + for (const evaluation of [null, { appointmentId: "appt-1" }]) { + const timing = evaluationTiming( + scheduled({ evaluation, graceMs: DEADLINE_GRACE_MS }), + inGrace, + ); + expect(timing).toEqual({ + canEdit: true, + deadlineAt, + appointmentId: "appt-1", + }); + } + }); + + it("locks a submitted evaluation once the deadline passes", () => { + for (const now of [deadlineAt, afterDeadline]) { + const timing = evaluationTiming( + scheduled({ evaluation: { appointmentId: "appt-1" } }), + now, + ); + expect(timing).toMatchObject({ canEdit: false, ended: true }); + if (!timing.canEdit) expect(timing.reason).toMatch(/final/); + } + }); + + it("keeps an untimed evaluation editable after any clock", () => { + expect( + evaluationTiming( + scheduled({ + mode: "remote", + evaluation: { appointmentId: null }, + }), + afterDeadline, + ), + ).toEqual({ canEdit: true, deadlineAt: null, appointmentId: null }); + }); + + it("runs against the deadline during the presentation", () => { + expect(evaluationTiming(scheduled(), during)).toEqual({ + canEdit: true, + deadlineAt, + appointmentId: "appt-1", + }); + expect(evaluationTiming(scheduled(), startsAt)).toMatchObject({ + canEdit: true, + deadlineAt, + }); + }); + + it("refuses a first evaluation once the deadline passes", () => { + for (const now of [deadlineAt, afterDeadline]) { + expect(evaluationTiming(scheduled(), now)).toMatchObject({ + canEdit: false, + opensAt: null, + ended: true, + }); + } + // The deadline wins over the room: there is nothing left to join. + expect( + evaluationTiming(scheduled({ presenceChallengeId: null }), afterDeadline), + ).toMatchObject({ canEdit: false, ended: true }); + }); + + it("waits for the presentation to start", () => { + expect(evaluationTiming(scheduled(), before)).toMatchObject({ + canEdit: false, + opensAt: startsAt, + }); + }); + + it("needs the judge in a room serving the challenge", () => { + for (const presenceChallengeId of [null, "mlh"]) { + expect( + evaluationTiming(scheduled({ presenceChallengeId }), during), + ).toMatchObject({ canEdit: false, opensAt: null, ended: false }); + } + }); + + it("refuses a project the schedule has no slot for", () => { + const timing = evaluationTiming(scheduled({ appointment: null }), during); + expect(timing.canEdit).toBe(false); + if (!timing.canEdit) expect(timing.reason).toMatch(/no presentation slot/); + }); +}); + +describe("TAKEOVER_AFTER_MS", () => { + it("is five minutes", () => { + expect(TAKEOVER_AFTER_MS).toBe(5 * 60_000); + expect(takeoverAt(startsAt)).toEqual(new Date("2027-02-08T15:05:00Z")); + }); +}); + +describe("reservedUntil", () => { + const assigned = { startsAt, assignedJudgeId: "judge-b" }; + const takeover = takeoverAt(startsAt); + + it("is the takeover time while a presentation is kept for another judge", () => { + for (const now of [before, startsAt, during]) { + expect(reservedUntil(assigned, "judge-a", now)).toEqual(takeover); + } + }); + + it("is over from the takeover time on", () => { + expect( + reservedUntil(assigned, "judge-a", new Date(takeover.getTime() - 1)), + ).toEqual(takeover); + expect(reservedUntil(assigned, "judge-a", takeover)).toBeNull(); + expect(reservedUntil(assigned, "judge-a", afterDeadline)).toBeNull(); + }); + + it("keeps nothing from the judge it is assigned to", () => { + expect(reservedUntil(assigned, "judge-b", during)).toBeNull(); + }); + + it("keeps nothing when nobody is assigned", () => { + expect(reservedUntil({ startsAt }, "judge-a", during)).toBeNull(); + expect( + reservedUntil({ startsAt, assignedJudgeId: null }, "judge-a", during), + ).toBeNull(); + expect(reservedUntil(null, "judge-a", during)).toBeNull(); + }); +}); + +describe("evaluationTiming for an assigned presentation", () => { + const appointment = (assignedJudgeId: string | null) => ({ + id: "appt-1", + startsAt, + deadlineAt, + assignedJudgeId, + }); + const mine = scheduled({ appointment: appointment("judge-a") }); + const theirs = scheduled({ appointment: appointment("judge-b") }); + const takeover = takeoverAt(startsAt); + + it("is the assigned judge's from the start", () => { + expect(evaluationTiming(mine, startsAt)).toEqual({ + canEdit: true, + deadlineAt, + appointmentId: "appt-1", + }); + expect(evaluationTiming(mine, before)).toMatchObject({ + canEdit: false, + opensAt: startsAt, + }); + }); + + it("keeps another judge out until the takeover time, and says when it opens", () => { + for (const now of [before, startsAt, during]) { + const timing = evaluationTiming(theirs, now); + expect(timing).toMatchObject({ + canEdit: false, + opensAt: takeover, + ended: false, + }); + } + }); + + it("tells another judge why without saying who", () => { + const timing = evaluationTiming(theirs, during); + if (timing.canEdit) throw new Error("expected a refusal"); + expect(timing.reason).toBe( + "Another judge is assigned to this presentation. If they have not started 5 minutes in, you can take it.", + ); + expect(timing.reason).not.toContain("judge-b"); + }); + + it("lets another judge take it from the takeover time", () => { + expect(evaluationTiming(theirs, takeover)).toEqual({ + canEdit: true, + deadlineAt, + appointmentId: "appt-1", + }); + expect( + evaluationTiming(theirs, new Date(takeover.getTime() - 1)), + ).toMatchObject({ canEdit: false, opensAt: takeover }); + }); + + it("still needs the judge who takes over in a room serving the challenge", () => { + expect( + evaluationTiming({ ...theirs, presenceChallengeId: "mlh" }, takeover), + ).toMatchObject({ canEdit: false, opensAt: null, ended: false }); + }); + + it("names the reservation before the room: joining one does not help yet", () => { + expect( + evaluationTiming({ ...theirs, presenceChallengeId: null }, during), + ).toMatchObject({ canEdit: false, opensAt: takeover }); + }); + + it("is a first-come presentation when nobody is assigned", () => { + const open = scheduled({ appointment: appointment(null) }); + expect(evaluationTiming(open, startsAt)).toMatchObject({ canEdit: true }); + }); + + it("lets the end win over the reservation", () => { + const short = scheduled({ + appointment: { + id: "appt-1", + startsAt, + deadlineAt: new Date("2027-02-08T15:03:00Z"), + assignedJudgeId: "judge-b", + }, + }); + expect( + evaluationTiming(short, new Date("2027-02-08T15:04:00Z")), + ).toMatchObject({ canEdit: false, ended: true }); + }); + + it("leaves a judge's own evaluation theirs, whoever it was assigned to", () => { + expect( + evaluationTiming( + { ...theirs, evaluation: { appointmentId: "appt-1" } }, + during, + ), + ).toEqual({ canEdit: true, deadlineAt, appointmentId: "appt-1" }); + }); + + it("ignores an assignment where nothing is timed", () => { + for (const over of [ + { mode: "remote" as const }, + { mode: "unscheduled" as const }, + { hasSchedule: false }, + ]) { + expect(evaluationTiming({ ...theirs, ...over }, during)).toMatchObject({ + canEdit: true, + deadlineAt: null, + }); + } + }); +}); diff --git a/packages/api/src/services/judging/timing.ts b/packages/api/src/services/judging/timing.ts new file mode 100644 index 00000000..b36a1c75 --- /dev/null +++ b/packages/api/src/services/judging/timing.ts @@ -0,0 +1,186 @@ +import type { ChallengeMode } from "./scope"; + +// When a judge may write an evaluation, and against what clock. Pure: the desk +// loads the rows, this decides. +// +// Nothing is timed until organisers save a schedule, and a challenge whose +// root is unscheduled or remote is never timed: open the project and judge it. +// A scheduled challenge is judged at its presentation. The judge has to be +// sitting in a room that serves the challenge, and the presentation has to +// have started. Until the slot's deadline (setup plus judging minutes in) the +// answers run against that deadline, and a draft still open when it passes is +// submitted for the judge. The deadline is a hard end: after it the judge can +// neither submit a first evaluation nor edit one they already submitted. +// Writes get DEADLINE_GRACE_MS past it, so an answer sent in the last seconds +// is not refused for the time it spent on the network. +// +// Where nothing is timed, a submitted evaluation stays editable while judging +// is open. +// +// A presentation can have one judge assigned to it. From its start until +// TAKEOVER_AFTER_MS later only that judge may start grading it. After that any +// judge who may grade it there can, as long as the assigned judge has not +// started (their draft holds the project, see grading-hold.ts). The assignment +// is never rewritten to hand the project over. + +/** How long after a deadline a write sent before it still lands. */ +export const DEADLINE_GRACE_MS = 15_000; + +// The assigned judge may be walking over from another room, so the first +// minutes are theirs. After that a project is better graded by someone in the +// room than not at all. +/** How long after a presentation starts only its assigned judge may grade it. */ +export const TAKEOVER_AFTER_MS = 5 * 60_000; + +export type TimingInput = { + /** The judge asking. */ + judgeId: string; + /** Whether the edition has a saved schedule. */ + hasSchedule: boolean; + /** The challenge's effective mode (its root's). */ + mode: ChallengeMode; + /** The (root) challenge the evaluation is for. */ + challengeId: string; + /** This judge's submitted evaluation of the project for the challenge. */ + evaluation: { appointmentId: string | null } | null; + /** The project's presentation for the challenge in the saved schedule. */ + appointment: { + id: string; + startsAt: Date; + deadlineAt: Date; + /** The judge the presentation is assigned to; null or absent for none. */ + assignedJudgeId?: string | null; + } | null; + /** The challenge served by the room the judge is sitting in, if any. */ + presenceChallengeId: string | null; + /** DEADLINE_GRACE_MS for a write; reads leave it at 0. */ + graceMs?: number; +}; + +export type EvaluationTiming = + | { + canEdit: true; + /** + * Set while a presentation has not ended: the draft is submitted then. + * A write in the grace window gets a deadline that has just passed. + */ + deadlineAt: Date | null; + appointmentId: string | null; + } + | { + canEdit: false; + reason: string; + /** When the judge can start, if waiting is all it takes. */ + opensAt: Date | null; + /** Shut because the presentation's deadline has passed. */ + ended: boolean; + }; + +const ENDED = + "Judging for this presentation ended at its deadline, so scores for it are final."; + +// Never names the judge. +const RESERVED = `Another judge is assigned to this presentation. If they have not started ${TAKEOVER_AFTER_MS / 60_000} minutes in, you can take it.`; + +/** When a presentation assigned to someone else opens to everyone else. */ +export function takeoverAt(startsAt: Date): Date { + return new Date(startsAt.getTime() + TAKEOVER_AFTER_MS); +} + +/** + * When a presentation opens to `judgeId`, if it is held back for another judge + * until then. Null when nobody is assigned, the judge is the one assigned, or + * the takeover time has passed. Whether the assigned judge has started is not + * decided here: a draft of theirs holds the project (grading-hold.ts). + */ +export function reservedUntil( + appointment: { startsAt: Date; assignedJudgeId?: string | null } | null, + judgeId: string, + now: Date, +): Date | null { + const assigned = appointment?.assignedJudgeId ?? null; + if (!appointment || !assigned || assigned === judgeId) return null; + const opens = takeoverAt(appointment.startsAt); + return now < opens ? opens : null; +} + +export function evaluationTiming( + input: TimingInput, + now: Date, +): EvaluationTiming { + const timed = input.hasSchedule && input.mode === "scheduled"; + const deadline = timed ? (input.appointment?.deadlineAt ?? null) : null; + const ended = + deadline !== null && + now.getTime() >= deadline.getTime() + (input.graceMs ?? 0); + + if (input.evaluation) { + if (ended) { + return { canEdit: false, reason: ENDED, opensAt: null, ended: true }; + } + return { + canEdit: true, + // Counts down the time left to edit; nothing is submitted for the judge. + deadlineAt: deadline, + appointmentId: input.evaluation.appointmentId, + }; + } + + if (!timed) { + return { canEdit: true, deadlineAt: null, appointmentId: null }; + } + + if (!input.appointment) { + return { + canEdit: false, + reason: + "This project has no presentation slot for this challenge yet. Ask an organiser to place it on the schedule.", + opensAt: null, + ended: false, + }; + } + + if (ended) { + return { canEdit: false, reason: ENDED, opensAt: null, ended: true }; + } + + // Before the room: nothing the judge does now helps until the takeover time. + const reserved = reservedUntil(input.appointment, input.judgeId, now); + if (reserved) { + return { + canEdit: false, + reason: RESERVED, + opensAt: reserved, + ended: false, + }; + } + + if (input.presenceChallengeId !== input.challengeId) { + return { + canEdit: false, + reason: + "Join the room or table that judges this challenge to evaluate this project.", + opensAt: null, + ended: false, + }; + } + + if (now < input.appointment.startsAt) { + return { + canEdit: false, + reason: + "This project has not presented yet. You can judge it once its presentation starts.", + opensAt: input.appointment.startsAt, + ended: false, + }; + } + + // Not ended, so the deadline is still ahead or inside the write grace. A + // draft first saved in the grace window must still carry it, or nothing + // submits it for the judge. + return { + canEdit: true, + deadlineAt: input.appointment.deadlineAt, + appointmentId: input.appointment.id, + }; +} diff --git a/packages/api/src/services/metrics.ts b/packages/api/src/services/metrics.ts index f73541b0..ed7814e6 100644 --- a/packages/api/src/services/metrics.ts +++ b/packages/api/src/services/metrics.ts @@ -12,8 +12,33 @@ import { Gauge, collectDefaultMetrics, } from "prom-client"; -import { sql } from "drizzle-orm"; +import { + and, + count, + eq, + gt, + gte, + inArray, + isNotNull, + isNull, + lte, + ne, + sum, +} from "drizzle-orm"; import type { DrizzleDB } from "@query/db"; +import { + hackathonEventAttendees, + hackathonEvents, + hackathonParticipants, + hackathonProjects, + hackathons, + judgeVotes, + judgingProjects, + memberResumes, + members, + stripePayments, +} from "@query/db"; +import { startOfEasternDay } from "./eastern-time"; export const registry = new Registry(); @@ -137,6 +162,215 @@ const gaugeRefreshFailures = new Counter({ registers: [registry], }); +// Live editions only: open (registration), closed (registration shut, event +// not finished), in progress (the weekend). A completed edition drops off on +// the next refresh, so last year does not sit on the dashboard as current. +// Same rule as the other gauges — counted from the database, so which instance +// Prometheus scraped does not matter. + +const PARTICIPANT_STATUSES = [ + "pending", + "approved", + "rejected", + "waitlisted", + "checked_in", +] as const; + +const participantGauge = new Gauge({ + name: "dsgt_hackathon_participants", + help: "Registrations for a live hackathon edition, by status.", + labelNames: ["edition", "phase", "status"] as const, + registers: [registry], +}); + +const projectGauge = new Gauge({ + name: "dsgt_hackathon_projects", + help: "Submitted projects for a live edition, excluding admin withdrawals and drafts.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonCheckinsToday = new Gauge({ + name: "dsgt_hackathon_checkins_today", + help: "Hackathon badge scans since midnight Eastern, for a live edition.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonJudgingProjects = new Gauge({ + name: "dsgt_hackathon_judging_projects", + help: "Projects on the judging floor for a live edition, excluding withdrawals.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonVotes = new Gauge({ + name: "dsgt_hackathon_votes", + help: "Judge scores recorded for a live edition.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonJudgingActive = new Gauge({ + name: "dsgt_hackathon_judging_active", + help: "1 when judging is switched on for a live edition, otherwise 0.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +export type LiveHackathon = { + edition: string; + phase: string; + judgingActive: boolean; + projects: number; + checkinsToday: number; + judgingProjects: number; + votes: number; + participants: Record<(typeof PARTICIPANT_STATUSES)[number], number>; +}; + +const LIVE_PHASES = ["open", "closed", "in_progress"] as const; + +const blankParticipants = (): LiveHackathon["participants"] => ({ + pending: 0, + approved: 0, + rejected: 0, + waitlisted: 0, + checked_in: 0, +}); + +const countByEdition = ( + rows: { hackathonId: string; total: number }[], +) => { + const totals = new Map(); + for (const row of rows) totals.set(row.hackathonId, Number(row.total)); + return totals; +}; + +// Shared with the admin analytics page. Prometheus is optional; the portal +// has to be able to show the same counts when nothing is scraping. +export async function readLiveHackathons( + db: DrizzleDB, + now = new Date(), +): Promise { + const startOfToday = startOfEasternDay(now); + + const editions = await db + .select({ + id: hackathons.id, + edition: hackathons.name, + phase: hackathons.status, + judgingActive: hackathons.judgingActive, + }) + .from(hackathons) + .where(inArray(hackathons.status, [...LIVE_PHASES])); + + if (editions.length === 0) return []; + + const ids = editions.map((edition) => edition.id); + + const [byStatus, projectRows, scanRows, judgingRows, voteRows] = + await Promise.all([ + db + .select({ + hackathonId: hackathonParticipants.hackathonId, + status: hackathonParticipants.registrationStatus, + total: count(), + }) + .from(hackathonParticipants) + .where(inArray(hackathonParticipants.hackathonId, ids)) + .groupBy( + hackathonParticipants.hackathonId, + hackathonParticipants.registrationStatus, + ), + db + .select({ + hackathonId: hackathonProjects.hackathonId, + total: count(), + }) + .from(hackathonProjects) + .where( + and( + inArray(hackathonProjects.hackathonId, ids), + ne(hackathonProjects.status, "draft"), + isNull(hackathonProjects.withdrawnByAdminAt), + ), + ) + .groupBy(hackathonProjects.hackathonId), + db + .select({ + hackathonId: hackathonEvents.hackathonId, + total: count(), + }) + .from(hackathonEventAttendees) + .innerJoin( + hackathonEvents, + eq(hackathonEvents.id, hackathonEventAttendees.eventId), + ) + .where( + and( + inArray(hackathonEvents.hackathonId, ids), + gte(hackathonEventAttendees.checkedInAt, startOfToday), + ), + ) + .groupBy(hackathonEvents.hackathonId), + db + .select({ + hackathonId: judgingProjects.hackathonId, + total: count(), + }) + .from(judgingProjects) + .where( + and( + inArray(judgingProjects.hackathonId, ids), + isNull(judgingProjects.withdrawnAt), + ), + ) + .groupBy(judgingProjects.hackathonId), + db + .select({ + hackathonId: judgingProjects.hackathonId, + total: count(), + }) + .from(judgeVotes) + .innerJoin( + judgingProjects, + eq(judgingProjects.id, judgeVotes.projectId), + ) + .where( + and( + inArray(judgingProjects.hackathonId, ids), + isNull(judgingProjects.withdrawnAt), + ), + ) + .groupBy(judgingProjects.hackathonId), + ]); + + const participantsByEdition = new Map(); + for (const row of byStatus) { + const bucket = + participantsByEdition.get(row.hackathonId) ?? blankParticipants(); + if (row.status in bucket) bucket[row.status] = Number(row.total); + participantsByEdition.set(row.hackathonId, bucket); + } + + const projects = countByEdition(projectRows); + const scans = countByEdition(scanRows); + const judging = countByEdition(judgingRows); + const votes = countByEdition(voteRows); + + return editions.map((edition) => ({ + edition: edition.edition, + phase: edition.phase, + judgingActive: edition.judgingActive, + projects: projects.get(edition.id) ?? 0, + checkinsToday: scans.get(edition.id) ?? 0, + judgingProjects: judging.get(edition.id) ?? 0, + votes: votes.get(edition.id) ?? 0, + participants: participantsByEdition.get(edition.id) ?? blankParticipants(), + })); +} + /** Long enough that a 30s scrape loop cannot turn into a query loop. */ const GAUGE_TTL_MS = 60_000; @@ -171,6 +405,14 @@ const gaugeCache = new GaugeCache(); // Eastern time like currentTerm, or the label flips four hours early at the // May and December boundaries. If the two disagree the gauge is mislabelled, // nothing more. +/** Null metadata is a year. Anything that is not a JSON object is left out. */ +function planFromMetadata(metadata: string | null) { + if (metadata == null) return "annual"; + if (!metadata.startsWith("{")) return null; + const parsed = JSON.parse(metadata) as { plan?: unknown }; + return typeof parsed.plan === "string" && parsed.plan ? parsed.plan : "annual"; +} + const currentTermLabel = (now = new Date()) => { const parts = new Intl.DateTimeFormat("en-US", { timeZone: "America/New_York", @@ -191,65 +433,103 @@ export async function refreshDbGauges(db: DrizzleDB | null | undefined) { await gaugeCache.refresh(async () => { try { const term = currentTermLabel(); - - const totals = await db.execute<{ - active: string; - lapsed: string; - bootcamp: string; - unlinked: string; - }>(sql` - select - count(*) filter ( - where "is_active" and "membership_end_date" > now() - ) as active, - count(*) filter ( - where "membership_end_date" is not null - and "membership_end_date" <= now() - ) as lapsed, - count(*) filter (where "bootcamp_term" = ${term}) as bootcamp, - ( - select count(*) from "stripe_payment" - where "payment_status" = 'paid' and "linked_user_id" is null - ) as unlinked - from "member" - `); - - const counts = totals.rows[0]; - - if (counts) { - membersActive.set(Number(counts.active)); - membersLapsed.set(Number(counts.lapsed)); - bootcampEnrolled.set({ term }, Number(counts.bootcamp)); - paymentsUnlinked.set(Number(counts.unlinked)); + const now = new Date(); + + const [ + [active], + [lapsed], + [bootcamp], + [unlinked], + paidRows, + [resumeTotals], + ] = await Promise.all([ + db + .select({ total: count() }) + .from(members) + .where( + and(eq(members.isActive, true), gt(members.membershipEndDate, now)), + ), + db + .select({ total: count() }) + .from(members) + .where( + and( + isNotNull(members.membershipEndDate), + lte(members.membershipEndDate, now), + ), + ), + db + .select({ total: count() }) + .from(members) + .where(eq(members.bootcampTerm, term)), + db + .select({ total: count() }) + .from(stripePayments) + .where( + and( + eq(stripePayments.paymentStatus, "paid"), + isNull(stripePayments.linkedUserId), + ), + ), + db + .select({ metadata: stripePayments.metadata }) + .from(stripePayments) + .where(eq(stripePayments.paymentStatus, "paid")), + db + .select({ + files: count(), + bytes: sum(memberResumes.sizeBytes), + }) + .from(memberResumes), + ]); + + membersActive.set(Number(active?.total ?? 0)); + membersLapsed.set(Number(lapsed?.total ?? 0)); + bootcampEnrolled.set({ term }, Number(bootcamp?.total ?? 0)); + paymentsUnlinked.set(Number(unlinked?.total ?? 0)); + + // Plan lives in the payment metadata string. Rows that are not JSON are + // skipped; a missing plan is the only product that existed then, a year. + // One unparseable object still fails the refresh, same as a bad cast did. + const plans = new Map(); + for (const row of paidRows) { + const plan = planFromMetadata(row.metadata); + if (!plan) continue; + plans.set(plan, (plans.get(plan) ?? 0) + 1); } - // `like '{%'` before the jsonb cast, deliberately: one row of unparseable - // metadata would error the whole statement, and rows written before the plan - // existed have no `plan` key — those bought the only thing on offer, a year. - const byPlan = await db.execute<{ plan: string; total: string }>(sql` - select - coalesce(("metadata"::jsonb ->> 'plan'), 'annual') as plan, - count(*) as total - from "stripe_payment" - where "payment_status" = 'paid' - and ("metadata" is null or "metadata" like '{%') - group by 1 - `); - paymentsByPlan.reset(); - for (const row of byPlan.rows) { - paymentsByPlan.set({ plan: row.plan }, Number(row.total)); + for (const [plan, total] of plans) { + paymentsByPlan.set({ plan }, total); } - const resumes = await db.execute<{ files: string; bytes: string }>(sql` - select count(*) as files, coalesce(sum("size_bytes"), 0) as bytes - from "member_resume" - `); - - const resumeTotals = resumes.rows[0]; - if (resumeTotals) { - resumesStored.set(Number(resumeTotals.files)); - resumeBytesStored.set(Number(resumeTotals.bytes)); + resumesStored.set(Number(resumeTotals?.files ?? 0)); + resumeBytesStored.set(Number(resumeTotals?.bytes ?? 0)); + + const live = await readLiveHackathons(db); + + // reset before set: an edition that left the live set has to disappear, + // or the dashboard keeps yesterday's registration count. + participantGauge.reset(); + projectGauge.reset(); + hackathonCheckinsToday.reset(); + hackathonJudgingProjects.reset(); + hackathonVotes.reset(); + hackathonJudgingActive.reset(); + + for (const edition of live) { + const labels = { edition: edition.edition, phase: edition.phase }; + projectGauge.set(labels, edition.projects); + hackathonCheckinsToday.set(labels, edition.checkinsToday); + hackathonJudgingProjects.set(labels, edition.judgingProjects); + hackathonVotes.set(labels, edition.votes); + hackathonJudgingActive.set(labels, edition.judgingActive ? 1 : 0); + for (const status of PARTICIPANT_STATUSES) { + participantGauge.set( + { ...labels, status }, + edition.participants[status], + ); + } } } catch { // Stale gauges beat a 500 on the scrape endpoint. diff --git a/packages/api/src/services/points.test.ts b/packages/api/src/services/points.test.ts new file mode 100644 index 00000000..3b7341fa --- /dev/null +++ b/packages/api/src/services/points.test.ts @@ -0,0 +1,793 @@ +import { describe, expect, it } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { + CHECK_IN_CODE_ALPHABET, + CHECK_IN_CODE_LENGTH, + CHECK_IN_GAP_MS, + alreadyCheckedInMessage, + assignCheckInCode, + attendanceConflict, + attendanceConflictMessage, + canSupply, + fullName, + generateCheckInCode, + isSoldOut, + leaderboardName, + ledgerTotals, + normalizeCheckInCode, + personName, + pointsBalance, + pointsShort, + rankLeaderboard, + recordEventAttendance, + selfCheckInWindow, + CheckInRuleBreak, +} from "./points"; +import type { EarlierAttendance, PointsDb } from "./points"; +import type { DrizzleDB } from "@query/db"; + +// Drizzle wraps the pg error, so the SQLSTATE sits on `.cause`. +const uniqueViolation = () => + Object.assign(new Error("duplicate key value"), { + cause: { code: "23505" }, + }); + +describe("pointsBalance", () => { + it("earns from events and adjustments and spends from purchases", () => { + expect(pointsBalance({ awarded: 100, adjusted: 20, spent: 30 })).toEqual({ + earned: 120, + spent: 30, + available: 90, + }); + }); + + it("never offers negative spending power after a deduction", () => { + expect(pointsBalance({ awarded: 50, adjusted: -40, spent: 30 })).toEqual({ + earned: 10, + spent: 30, + available: 0, + }); + }); + + it("keeps a negative earned total as it is", () => { + expect(pointsBalance({ awarded: 0, adjusted: -5, spent: 0 })).toEqual({ + earned: -5, + spent: 0, + available: 0, + }); + }); +}); + +describe("ledgerTotals", () => { + it("counts a null award as zero and skips voided purchases", () => { + const totals = ledgerTotals({ + attendance: [ + { pointsAwarded: 10 }, + { pointsAwarded: null }, + { pointsAwarded: 5 }, + ], + adjustments: [{ delta: 25 }, { delta: -10 }], + purchases: [ + { total: 12, voidedAt: null }, + { total: 100, voidedAt: new Date() }, + ], + }); + expect(totals).toEqual({ awarded: 15, adjusted: 15, spent: 12 }); + expect(pointsBalance(totals)).toEqual({ + earned: 30, + spent: 12, + available: 18, + }); + }); + + it("is all zeros for an empty ledger", () => { + expect( + ledgerTotals({ attendance: [], adjustments: [], purchases: [] }), + ).toEqual({ awarded: 0, adjusted: 0, spent: 0 }); + }); +}); + +describe("store stock", () => { + it("counts tracked stock against the quantity", () => { + expect(canSupply({ stock: 3, soldOut: false }, 3)).toBe(true); + expect(canSupply({ stock: 3, soldOut: false }, 4)).toBe(false); + expect(isSoldOut({ stock: 0, soldOut: false })).toBe(true); + }); + + it("ignores the switch while stock is counted", () => { + expect(canSupply({ stock: 2, soldOut: true }, 1)).toBe(true); + }); + + it("uses the switch when nobody counts", () => { + expect(canSupply({ stock: null, soldOut: false }, 500)).toBe(true); + expect(canSupply({ stock: null, soldOut: true }, 1)).toBe(false); + expect(isSoldOut({ stock: null, soldOut: true })).toBe(true); + }); + + it("says how many more points an item needs", () => { + expect(pointsShort(50, 20)).toBe(30); + expect(pointsShort(50, 50)).toBe(0); + expect(pointsShort(0, 0)).toBe(0); + }); +}); + +describe("names", () => { + it("uses the registration's names when present", () => { + expect( + personName({ + firstName: " Ada ", + lastName: "Lovelace", + accountName: "x y", + }), + ).toEqual({ first: "Ada", last: "Lovelace" }); + }); + + it("splits the account name when the registration has none", () => { + expect( + personName({ + firstName: null, + lastName: null, + accountName: "Grace Brewster Hopper", + }), + ).toEqual({ first: "Grace", last: "Brewster Hopper" }); + expect( + personName({ firstName: "", lastName: null, accountName: null }), + ).toEqual({ first: "", last: "" }); + }); + + it("shows first name and last initial on the leaderboard", () => { + expect(leaderboardName({ first: "Ada", last: "lovelace" })).toBe("Ada L."); + expect(leaderboardName({ first: "Ada", last: "" })).toBe("Ada"); + expect(leaderboardName({ first: "", last: "Lovelace" })).toBe("L."); + expect(leaderboardName({ first: "", last: "" })).toBe("Hacker"); + }); + + it("takes a whole character for the initial", () => { + expect(leaderboardName({ first: "Zoë", last: "Ölund" })).toBe("Zoë Ö."); + }); + + it("falls back when a full name is blank", () => { + expect(fullName({ first: "Ada", last: "Lovelace" }, "a@x.io")).toBe( + "Ada Lovelace", + ); + expect(fullName({ first: "", last: "" }, "a@x.io")).toBe("a@x.io"); + }); +}); + +describe("rankLeaderboard", () => { + const row = (id: string, lastName: string, earned: number) => ({ + id, + lastName, + earned, + }); + + it("orders by points, then last name, then id", () => { + const ranked = rankLeaderboard([ + row("c", "Zed", 10), + row("b", "Adams", 10), + row("a", "Adams", 10), + row("d", "Young", 40), + ]); + expect(ranked.map((r) => r.id)).toEqual(["d", "a", "b", "c"]); + }); + + it("gives ties the same rank and skips the next", () => { + const ranked = rankLeaderboard([ + row("a", "A", 50), + row("b", "B", 30), + row("c", "C", 30), + row("d", "D", 10), + ]); + expect(ranked.map((r) => r.rank)).toEqual([1, 2, 2, 4]); + }); + + it("compares last names without regard to case", () => { + const ranked = rankLeaderboard([row("a", "zed", 0), row("b", "Adams", 0)]); + expect(ranked.map((r) => r.id)).toEqual(["b", "a"]); + }); + + it("does not reorder its input", () => { + const input = [row("a", "A", 1), row("b", "B", 2)]; + rankLeaderboard(input); + expect(input.map((r) => r.id)).toEqual(["a", "b"]); + }); + + it("handles an empty board", () => { + expect(rankLeaderboard([])).toEqual([]); + }); +}); + +describe("check-in codes", () => { + it("leaves out the characters people misread", () => { + for (const ch of ["0", "O", "1", "I", "L"]) { + expect(CHECK_IN_CODE_ALPHABET).not.toContain(ch); + } + expect(new Set(CHECK_IN_CODE_ALPHABET).size).toBe( + CHECK_IN_CODE_ALPHABET.length, + ); + }); + + it("draws six characters from the alphabet", () => { + for (let i = 0; i < 200; i += 1) { + const code = generateCheckInCode(); + expect(code).toHaveLength(CHECK_IN_CODE_LENGTH); + for (const ch of code) expect(CHECK_IN_CODE_ALPHABET).toContain(ch); + } + }); + + it("maps each draw to one character", () => { + const draws = [0, 1, 2, 3, 4, CHECK_IN_CODE_ALPHABET.length - 1]; + const code = generateCheckInCode(() => draws.shift()!); + expect(code).toBe( + `${CHECK_IN_CODE_ALPHABET.slice(0, 5)}${CHECK_IN_CODE_ALPHABET.at(-1)}`, + ); + }); + + it("asks for draws over the whole alphabet", () => { + const bounds: number[] = []; + generateCheckInCode((max) => { + bounds.push(max); + return 0; + }); + expect(bounds).toEqual( + Array(CHECK_IN_CODE_LENGTH).fill(CHECK_IN_CODE_ALPHABET.length), + ); + }); + + it("forgives case, spaces and dashes in what was typed", () => { + expect(normalizeCheckInCode("k7m pq2")).toBe("K7MPQ2"); + expect(normalizeCheckInCode(" K7M-PQ2 ")).toBe("K7MPQ2"); + }); + + it("refuses anything that cannot be a code", () => { + expect(normalizeCheckInCode("")).toBeNull(); + expect(normalizeCheckInCode("K7MPQ")).toBeNull(); + expect(normalizeCheckInCode("K7MPQ22")).toBeNull(); + // O, 0, I, 1 and L are never issued, so a code holding one is a typo. + expect(normalizeCheckInCode("K7MPQO")).toBeNull(); + expect(normalizeCheckInCode("K7MPQ0")).toBeNull(); + expect(normalizeCheckInCode("K7MPQ1")).toBeNull(); + expect(normalizeCheckInCode("K7MPQL")).toBeNull(); + expect(normalizeCheckInCode("K7MPQ!")).toBeNull(); + }); +}); + +describe("assignCheckInCode", () => { + // Each update resolves or throws according to the next scripted outcome. + const fakeDb = (outcomes: ("ok" | "taken" | "boom")[]) => { + const written: string[] = []; + const db = { + update: () => ({ + set: (values: { checkInCode: string }) => ({ + where: async () => { + const outcome = outcomes.shift() ?? "ok"; + if (outcome === "taken") throw uniqueViolation(); + if (outcome === "boom") throw new Error("connection reset"); + written.push(values.checkInCode); + }, + }), + }), + }; + return { db: db as unknown as DrizzleDB, written }; + }; + + const codes = + (...list: string[]) => + () => + list.shift()!; + + it("writes the first free code", async () => { + const { db, written } = fakeDb(["ok"]); + await expect( + assignCheckInCode(db, { id: "e", checkInCode: null }, codes("AAAAAA")), + ).resolves.toBe("AAAAAA"); + expect(written).toEqual(["AAAAAA"]); + }); + + it("draws again when another event already holds the code", async () => { + const { db, written } = fakeDb(["taken", "taken", "ok"]); + await expect( + assignCheckInCode( + db, + { id: "e", checkInCode: null }, + codes("AAAAAA", "BBBBBB", "CCCCCC"), + ), + ).resolves.toBe("CCCCCC"); + expect(written).toEqual(["CCCCCC"]); + }); + + it("never rotates to the code already on the screen", async () => { + const { db, written } = fakeDb(["ok"]); + await expect( + assignCheckInCode( + db, + { id: "e", checkInCode: "AAAAAA" }, + codes("AAAAAA", "BBBBBB"), + ), + ).resolves.toBe("BBBBBB"); + expect(written).toEqual(["BBBBBB"]); + }); + + it("gives up with a sentence after repeated collisions", async () => { + const { db } = fakeDb(["taken", "taken", "taken", "taken", "taken"]); + const error = await assignCheckInCode( + db, + { id: "e", checkInCode: null }, + codes("A", "B", "C", "D", "E"), + ).catch((e: unknown) => e); + expect(error).toBeInstanceOf(TRPCError); + expect((error as TRPCError).code).toBe("CONFLICT"); + }); + + it("does not retry an unrelated failure", async () => { + const { db } = fakeDb(["boom"]); + await expect( + assignCheckInCode(db, { id: "e", checkInCode: null }, codes("AAAAAA")), + ).rejects.toThrow("connection reset"); + }); +}); + +describe("selfCheckInWindow", () => { + const start = new Date("2026-10-10T18:00:00Z"); + const end = new Date("2026-10-10T19:00:00Z"); + const at = (iso: string) => + selfCheckInWindow({ startTime: start, endTime: end }, new Date(iso)); + + it("opens fifteen minutes before the start", () => { + expect(at("2026-10-10T17:44:59.999Z")).toBe("early"); + expect(at("2026-10-10T17:45:00Z")).toBe("open"); + }); + + it("stays open through the end", () => { + expect(at("2026-10-10T18:30:00Z")).toBe("open"); + expect(at("2026-10-10T19:00:00Z")).toBe("open"); + expect(at("2026-10-10T19:00:00.001Z")).toBe("ended"); + }); +}); + +const MIN = 60 * 1000; + +describe("attendanceConflict", () => { + const T0 = new Date("2026-10-10T18:00:00Z").getTime(); + const at = (minutes: number) => new Date(T0 + minutes * MIN); + + const ev = ( + id: string, + startMin: number, + endMin: number, + type = "workshop", + ) => ({ + id, + type, + startTime: at(startMin), + endTime: at(endMin), + }); + + // An earlier check-in. Defaults to long ago, so only the overlap rule can + // fire unless a test says otherwise. + const attended = ( + id: string, + startMin: number, + endMin: number, + opts: { type?: string; checkedInMin?: number; name?: string } = {}, + ): EarlierAttendance => ({ + event: { + ...ev(id, startMin, endMin, opts.type), + name: opts.name ?? `Event ${id}`, + }, + checkedInAt: at(opts.checkedInMin ?? -600), + }); + + it("allows a first check-in", () => { + expect(attendanceConflict(ev("a", 0, 60), [], at(0))).toBeNull(); + }); + + it("refuses an event that runs at the same time as one already attended", () => { + const bny = attended("bny", 0, 60, { name: "BNY Workshop" }); + expect(attendanceConflict(ev("fs", 30, 90), [bny], at(30))).toEqual({ + kind: "overlap", + other: { id: "bny", name: "BNY Workshop" }, + }); + // Contained and containing count too. + expect(attendanceConflict(ev("fs", 10, 20), [bny], at(30))?.kind).toBe( + "overlap", + ); + expect(attendanceConflict(ev("fs", -30, 120), [bny], at(30))?.kind).toBe( + "overlap", + ); + }); + + it("lets back-to-back events through", () => { + const first = attended("a", 0, 60); + expect(attendanceConflict(ev("b", 60, 120), [first], at(60))).toBeNull(); + const second = attended("c", 60, 120); + expect(attendanceConflict(ev("d", 0, 60), [second], at(30))).toBeNull(); + }); + + it("refuses a second check-in 29 minutes after the first and allows it at 30", () => { + const first = attended("a", 0, 60, { checkedInMin: 0, name: "Workshop" }); + // Back-to-back events, so only the gap is in play. + const next = ev("b", 60, 120); + expect(attendanceConflict(next, [first], at(29))).toEqual({ + kind: "too_soon", + other: { id: "a", name: "Workshop" }, + minutesAgo: 29, + }); + expect(attendanceConflict(next, [first], at(30))).toBeNull(); + expect(CHECK_IN_GAP_MS).toBe(30 * MIN); + }); + + it("reports whole minutes, zero for a check-in just now", () => { + const first = attended("a", 0, 60, { checkedInMin: 0 }); + const next = ev("b", 60, 120); + expect( + attendanceConflict(next, [first], new Date(T0 + 59 * 1000))?.minutesAgo, + ).toBe(0); + expect( + attendanceConflict(next, [first], new Date(T0 + 61 * 1000))?.minutesAgo, + ).toBe(1); + }); + + it("does not report a negative wait when the clock is behind the database", () => { + const first = attended("a", 0, 60, { checkedInMin: 1 }); + expect( + attendanceConflict(ev("b", 60, 120), [first], at(0))?.minutesAgo, + ).toBe(0); + }); + + it("names the most recent check-in when several are too recent", () => { + const older = attended("a", 0, 10, { checkedInMin: 0, name: "Older" }); + const newer = attended("b", 20, 30, { checkedInMin: 10, name: "Newer" }); + const expected = { + kind: "too_soon", + other: { id: "b", name: "Newer" }, + minutesAgo: 10, + }; + expect(attendanceConflict(ev("c", 60, 90), [newer, older], at(20))).toEqual( + expected, + ); + expect(attendanceConflict(ev("c", 60, 90), [older, newer], at(20))).toEqual( + expected, + ); + }); + + it("does not block a meal, and a meal does not block anything", () => { + const workshop = attended("w", 0, 60, { checkedInMin: 0 }); + expect( + attendanceConflict(ev("lunch", 10, 50, "meal"), [workshop], at(10)), + ).toBeNull(); + + const lunch = attended("lunch", 0, 60, { type: "meal", checkedInMin: 0 }); + expect(attendanceConflict(ev("w", 10, 50), [lunch], at(10))).toBeNull(); + }); + + it("ignores the same event, which the unique index handles", () => { + const same = attended("a", 0, 60, { checkedInMin: 0 }); + expect(attendanceConflict(ev("a", 0, 60), [same], at(5))).toBeNull(); + // A check-in elsewhere since then does not turn a repeat into "too soon": + // the true answer is still "already checked in". + const since = attended("b", 60, 120, { checkedInMin: 50 }); + expect( + attendanceConflict(ev("a", 0, 60), [same, since], at(55)), + ).toBeNull(); + }); + + it("reports an overlap before a recent check-in", () => { + const overlapping = attended("a", 0, 60, { + checkedInMin: 10, + name: "Overlaps", + }); + const recent = attended("b", -120, -90, { + checkedInMin: 14, + name: "Recent", + }); + expect( + attendanceConflict(ev("c", 30, 90), [recent, overlapping], at(15)), + ).toEqual({ kind: "overlap", other: { id: "a", name: "Overlaps" } }); + }); +}); + +describe("attendanceConflictMessage", () => { + const input = { + event: { name: "Full Sail Fireside" }, + participant: { name: "Ada Lovelace" }, + }; + const overlap = { + kind: "overlap", + other: { id: "bny", name: "BNY Workshop" }, + } as const; + const soon = (minutesAgo: number) => + ({ + kind: "too_soon", + other: { id: "bny", name: "BNY Workshop" }, + minutesAgo, + }) as const; + + it("speaks to the hacker who entered the code about an overlap", () => { + expect( + attendanceConflictMessage({ ...input, method: "code" }, overlap), + ).toBe( + "You're already checked into BNY Workshop, which runs at the same time as Full Sail Fireside. You can check into one event at a time.", + ); + }); + + it("tells staff to remove the participant from the other event", () => { + for (const method of ["scan", "manual"] as const) { + expect(attendanceConflictMessage({ ...input, method }, overlap)).toBe( + "Ada Lovelace is already checked into BNY Workshop, which runs at the same time as Full Sail Fireside. If that was a mistake, remove them from BNY Workshop first.", + ); + } + }); + + it("tells the hacker how long to wait", () => { + expect( + attendanceConflictMessage({ ...input, method: "code" }, soon(12)), + ).toBe( + "You checked into BNY Workshop 12 minutes ago. Event check-ins have to be at least 30 minutes apart, so try again in 18 minutes.", + ); + }); + + it("tells staff the same without a countdown", () => { + for (const method of ["scan", "manual"] as const) { + expect(attendanceConflictMessage({ ...input, method }, soon(12))).toBe( + "Ada Lovelace checked into BNY Workshop 12 minutes ago. Event check-ins have to be at least 30 minutes apart. If that was a mistake, remove them from BNY Workshop first.", + ); + } + }); + + it("uses singular wording at one minute", () => { + expect( + attendanceConflictMessage({ ...input, method: "code" }, soon(1)), + ).toBe( + "You checked into BNY Workshop 1 minute ago. Event check-ins have to be at least 30 minutes apart, so try again in 29 minutes.", + ); + expect( + attendanceConflictMessage({ ...input, method: "code" }, soon(29)), + ).toContain("so try again in 1 minute."); + }); + + it("says less than a minute for zero", () => { + expect( + attendanceConflictMessage({ ...input, method: "code" }, soon(0)), + ).toBe( + "You checked into BNY Workshop less than a minute ago. Event check-ins have to be at least 30 minutes apart, so try again in 30 minutes.", + ); + expect( + attendanceConflictMessage({ ...input, method: "manual" }, soon(0)), + ).toContain("checked into BNY Workshop less than a minute ago."); + }); +}); + +describe("recordEventAttendance", () => { + // Stands in for the pool: a transaction hands back a builder that answers the + // participant lock, the attendance read and the insert, noting the order. + const fakeDb = ( + opts: { fail?: () => Error; earlier?: EarlierAttendance[] } = {}, + ) => { + const inserted: unknown[] = []; + const calls: string[] = []; + const tx = { + select: () => ({ + from: () => ({ + where: () => ({ + for: async (mode: string) => { + calls.push(`lock:${mode}`); + return [{ id: "part-1" }]; + }, + }), + innerJoin: () => ({ + where: async () => { + calls.push("read"); + return opts.earlier ?? []; + }, + }), + }), + }), + insert: () => ({ + values: async (values: unknown) => { + calls.push("insert"); + if (opts.fail) throw opts.fail(); + inserted.push(values); + }, + }), + }; + const db = { + transaction: async (run: (tx: unknown) => unknown) => run(tx), + }; + return { db: db as unknown as PointsDb, inserted, calls }; + }; + + // A meal, so the rules stay out of the way unless a test switches to the + // workshop below. + const input = { + event: { + id: "event-1", + name: "Lunch", + points: 15, + type: "meal", + startTime: new Date(Date.now() - 30 * MIN), + endTime: new Date(Date.now() + 30 * MIN), + }, + participant: { id: "part-1", name: "Ada Lovelace" }, + method: "manual" as const, + actorId: "staff-1", + }; + + const workshop = { + ...input.event, + id: "event-2", + name: "Workshop", + type: "workshop", + }; + + const earlierRow = ( + event: Partial = {}, + checkedInAt = new Date(Date.now() - 300 * MIN), + ): EarlierAttendance => ({ + event: { + id: "event-9", + name: "BNY Workshop", + type: "workshop", + startTime: new Date(Date.now() - 30 * MIN), + endTime: new Date(Date.now() + 30 * MIN), + ...event, + }, + checkedInAt, + }); + + it("copies the event's points and records how and by whom", async () => { + const { db, inserted } = fakeDb(); + await expect(recordEventAttendance(db, input)).resolves.toEqual({ + pointsAwarded: 15, + }); + expect(inserted).toEqual([ + { + eventId: "event-1", + participantId: "part-1", + pointsAwarded: 15, + method: "manual", + checkedInById: "staff-1", + checkedInAt: expect.any(Date), + }, + ]); + }); + + it("locks the participant before it reads their attendance", async () => { + const { db, calls } = fakeDb(); + await recordEventAttendance(db, input); + expect(calls).toEqual(["lock:no key update", "read", "insert"]); + }); + + it("refuses a check-in into an event that overlaps one already attended", async () => { + const { db, inserted } = fakeDb({ earlier: [earlierRow()] }); + const error = await recordEventAttendance(db, { + ...input, + event: workshop, + method: "code", + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(TRPCError); + expect((error as TRPCError).code).toBe("FORBIDDEN"); + expect((error as TRPCError).message).toBe( + "You're already checked into BNY Workshop, which runs at the same time as Workshop. You can check into one event at a time.", + ); + // What a caller needs to record the attempt once this has rolled back. + const cause = (error as TRPCError).cause; + expect(cause).toBeInstanceOf(CheckInRuleBreak); + expect(cause).toMatchObject({ + participantId: "part-1", + eventId: "event-2", + conflictEventId: "event-9", + rule: "overlap", + method: "code", + actorId: "staff-1", + at: expect.any(Date), + }); + expect(inserted).toEqual([]); + }); + + it("refuses a check-in under 30 minutes after the last one", async () => { + const { db, inserted } = fakeDb({ + earlier: [ + earlierRow( + { + startTime: new Date(Date.now() - 300 * MIN), + endTime: new Date(Date.now() - 240 * MIN), + }, + new Date(Date.now() - 10 * MIN), + ), + ], + }); + const error = await recordEventAttendance(db, { + ...input, + event: workshop, + }).catch((e: unknown) => e); + expect((error as TRPCError).code).toBe("FORBIDDEN"); + expect((error as TRPCError).message).toMatch( + /^Ada Lovelace checked into BNY Workshop 10 minutes ago\./, + ); + expect((error as TRPCError).cause).toMatchObject({ + eventId: "event-2", + conflictEventId: "event-9", + rule: "too_soon", + method: "manual", + }); + expect(inserted).toEqual([]); + }); + + it("lets a meal through alongside an overlapping event", async () => { + const { db, inserted } = fakeDb({ earlier: [earlierRow()] }); + await recordEventAttendance(db, input); + expect(inserted).toHaveLength(1); + }); + + it("reads a duplicate as already checked in", async () => { + const { db } = fakeDb({ fail: uniqueViolation }); + const error = await recordEventAttendance(db, input).catch( + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(TRPCError); + expect((error as TRPCError).code).toBe("CONFLICT"); + expect((error as TRPCError).message).toBe( + "Ada Lovelace is already checked into Lunch.", + ); + }); + + it("reads a repeat as already checked in after a later check-in elsewhere", async () => { + const { db } = fakeDb({ + fail: uniqueViolation, + earlier: [ + earlierRow( + { id: workshop.id, name: workshop.name }, + new Date(Date.now() - 40 * MIN), + ), + // Starts as the workshop ends, checked into five minutes ago. + earlierRow( + { + startTime: workshop.endTime, + endTime: new Date(Date.now() + 90 * MIN), + }, + new Date(Date.now() - 5 * MIN), + ), + ], + }); + const error = await recordEventAttendance(db, { + ...input, + event: workshop, + method: "code", + }).catch((e: unknown) => e); + expect((error as TRPCError).code).toBe("CONFLICT"); + expect((error as TRPCError).message).toBe( + "You are already checked into Workshop.", + ); + }); + + it("speaks to the hacker when they entered the code", async () => { + const { db } = fakeDb({ fail: uniqueViolation }); + await expect( + recordEventAttendance(db, { ...input, method: "code" }), + ).rejects.toThrow("You are already checked into Lunch."); + }); + + it("leaves a duplicate without a rule-break cause", async () => { + const { db } = fakeDb({ fail: uniqueViolation }); + const error = await recordEventAttendance(db, input).catch( + (e: unknown) => e, + ); + expect((error as TRPCError).cause).not.toBeInstanceOf(CheckInRuleBreak); + }); + + it("passes other failures through", async () => { + const { db } = fakeDb({ fail: () => new Error("connection reset") }); + await expect(recordEventAttendance(db, input)).rejects.toThrow( + "connection reset", + ); + }); + + it("words the duplicate the same way for every staff path", () => { + for (const method of ["scan", "manual"] as const) { + expect(alreadyCheckedInMessage({ ...input, method })).toBe( + "Ada Lovelace is already checked into Lunch.", + ); + } + }); +}); diff --git a/packages/api/src/services/points.ts b/packages/api/src/services/points.ts new file mode 100644 index 00000000..2004613a --- /dev/null +++ b/packages/api/src/services/points.ts @@ -0,0 +1,806 @@ +import { randomInt } from "node:crypto"; +import { TRPCError } from "@trpc/server"; +import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { + hackathonEvents, + hackathonEventAttendees, + hackathonParticipants, + hackathonPointAdjustments, + hackathonStorePurchases, + users, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { isUniqueViolation } from "../middleware/db-errors"; + +// Hackathon points. No balance is stored anywhere: every number here is a sum +// over the rows that explain it (the comment on packages/db/src/schemas/points.ts +// has the rule), so the arithmetic lives in one place and every reader goes +// through it. + +/** A transaction handle from `db.transaction`. */ +export type PointsTx = Parameters[0]>[0]; + +/** Either the pool or a transaction. */ +export type PointsDb = DrizzleDB | PointsTx; + +export type AttendanceMethod = "scan" | "code" | "manual"; + +/** The three sums a balance is made of. */ +export type PointsTotals = { + /** Event points, as copied onto each attendance row. */ + awarded: number; + /** Staff adjustments, either sign. */ + adjusted: number; + /** Purchases that have not been voided. */ + spent: number; +}; + +export type PointsBalance = { + earned: number; + spent: number; + available: number; +}; + +const NO_TOTALS: PointsTotals = { awarded: 0, adjusted: 0, spent: 0 }; + +export function pointsBalance({ + awarded, + adjusted, + spent, +}: PointsTotals): PointsBalance { + const earned = awarded + adjusted; + // A deduction after a purchase can leave spending above what was earned. + // The purchase stands; there is simply nothing left to spend. + return { earned, spent, available: Math.max(0, earned - spent) }; +} + +/** Totals from ledger rows as read. A null award predates points and is 0. */ +export function ledgerTotals(ledger: { + attendance: readonly { pointsAwarded: number | null }[]; + adjustments: readonly { delta: number }[]; + purchases: readonly { total: number; voidedAt: Date | null }[]; +}): PointsTotals { + let awarded = 0; + for (const row of ledger.attendance) awarded += row.pointsAwarded ?? 0; + let adjusted = 0; + for (const row of ledger.adjustments) adjusted += row.delta; + let spent = 0; + for (const row of ledger.purchases) if (!row.voidedAt) spent += row.total; + return { awarded, adjusted, spent }; +} + +// --------------------------------------------------------------------------- +// Store + +type Stocked = { stock: number | null; soldOut: boolean }; + +/** Null stock means nobody is counting, and then the soldOut switch decides. */ +export function canSupply(item: Stocked, quantity: number): boolean { + return item.stock === null ? !item.soldOut : item.stock >= quantity; +} + +/** Gone, whether counted down to zero or switched off by hand. */ +export function isSoldOut(item: Stocked): boolean { + return !canSupply(item, 1); +} + +/** The "N more points" a hacker needs before they can afford something. */ +export function pointsShort(price: number, available: number): number { + return Math.max(0, price - available); +} + +// --------------------------------------------------------------------------- +// Names and the leaderboard + +export type NameSource = { + firstName: string | null; + lastName: string | null; + /** The account's display name, for registrations without name fields. */ + accountName: string | null; +}; + +/** + * First and last name from the registration, or split from the account name + * when the registration left them blank. + */ +export function personName(source: NameSource): { + first: string; + last: string; +} { + const first = source.firstName?.trim() ?? ""; + if (first) return { first, last: source.lastName?.trim() ?? "" }; + + const words = (source.accountName ?? "").trim().split(/\s+/).filter(Boolean); + return { first: words[0] ?? "", last: words.slice(1).join(" ") }; +} + +/** "Ada L." — what other hackers see on the leaderboard. */ +export function leaderboardName(name: { first: string; last: string }): string { + const initial = Array.from(name.last)[0]?.toUpperCase(); + if (!name.first) return initial ? `${initial}.` : "Hacker"; + return initial ? `${name.first} ${initial}.` : name.first; +} + +/** Full name for staff screens and purchase snapshots. */ +export function fullName( + name: { first: string; last: string }, + fallback: string, +): string { + return `${name.first} ${name.last}`.trim() || fallback; +} + +/** + * Highest earned first, then by last name, then by id so the order never + * shuffles between polls. Equal points share a rank and the next rank skips + * (1, 1, 3). + */ +export function rankLeaderboard< + T extends { id: string; lastName: string; earned: number }, +>(rows: readonly T[]): (T & { rank: number })[] { + const sorted = [...rows].sort( + (a, b) => + b.earned - a.earned || + a.lastName.localeCompare(b.lastName, "en", { sensitivity: "base" }) || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + ); + + let rank = 0; + return sorted.map((row, index) => { + if (index === 0 || sorted[index - 1]!.earned !== row.earned) { + rank = index + 1; + } + return { ...row, rank }; + }); +} + +// --------------------------------------------------------------------------- +// Event codes + +// No 0/O, 1/I/L: a code is read off a projector across a room and typed on a +// phone, and those are the pairs people get wrong. +export const CHECK_IN_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; +export const CHECK_IN_CODE_LENGTH = 6; + +/** A fresh code. `pick` returns an integer in [0, max); crypto by default. */ +export function generateCheckInCode( + pick: (max: number) => number = randomInt, +): string { + let code = ""; + for (let i = 0; i < CHECK_IN_CODE_LENGTH; i += 1) { + code += CHECK_IN_CODE_ALPHABET[pick(CHECK_IN_CODE_ALPHABET.length)]; + } + return code; +} + +/** + * What a hacker typed, as a code to look up, or null when it cannot be one. + * Case, spaces and dashes are forgiven; letters outside the alphabet are not. + */ +export function normalizeCheckInCode(raw: string): string | null { + const code = raw.toUpperCase().replace(/[\s-]/g, ""); + if (code.length !== CHECK_IN_CODE_LENGTH) return null; + for (const ch of code) { + if (!CHECK_IN_CODE_ALPHABET.includes(ch)) return null; + } + return code; +} + +const CODE_ATTEMPTS = 5; + +/** + * Gives an event a new code, replacing any it had. Unique per hackathon by the + * partial index on (hackathon_id, check_in_code): a collision is a unique + * violation, and the answer is to draw again. On the pool, not a transaction, + * because a violation would abort the transaction the retry runs in. + */ +export async function assignCheckInCode( + db: DrizzleDB, + event: { id: string; checkInCode: string | null }, + generate: () => string = generateCheckInCode, +): Promise { + for (let attempt = 0; attempt < CODE_ATTEMPTS; attempt += 1) { + const code = generate(); + // Rotating has to change what is on the screen. + if (code === event.checkInCode) continue; + try { + await db + .update(hackathonEvents) + .set({ checkInCode: code, updatedAt: new Date() }) + .where(eq(hackathonEvents.id, event.id)); + return code; + } catch (error) { + if (!isUniqueViolation(error)) throw error; + } + } + + throw new TRPCError({ + code: "CONFLICT", + message: "Could not find an unused code for this event. Try again.", + }); +} + +// Doors open a little early: people queue before a workshop starts, and the +// code is on the screen as they walk in. +export const SELF_CHECK_IN_LEAD_MS = 15 * 60 * 1000; + +export type SelfCheckInWindow = "early" | "open" | "ended"; + +/** Self check-in runs from 15 minutes before the start to the end, inclusive. */ +export function selfCheckInWindow( + event: { startTime: Date; endTime: Date }, + now: Date, +): SelfCheckInWindow { + if (now.getTime() < event.startTime.getTime() - SELF_CHECK_IN_LEAD_MS) { + return "early"; + } + if (now.getTime() > event.endTime.getTime()) return "ended"; + return "open"; +} + +// --------------------------------------------------------------------------- +// Attendance + +// Two events at once, or two check-ins in quick succession, mean somebody is +// collecting points for events they are not at. Meals are the exception: people +// eat alongside whatever else is on, and a meal scan only counts food. +export const CHECK_IN_GAP_MS = 30 * 60 * 1000; + +type AttendedEvent = { + id: string; + type: string; + startTime: Date; + endTime: Date; +}; + +/** One of the participant's existing check-ins, with the event it was for. */ +export type EarlierAttendance = { + event: AttendedEvent & { name: string }; + checkedInAt: Date; +}; + +export type AttendanceConflict = { + /** overlap: the events run at the same time. too_soon: under the gap. */ + kind: "overlap" | "too_soon"; + /** The earlier check-in that stands in the way. */ + other: { id: string; name: string }; + /** Whole minutes since the earlier check-in. Only on too_soon. */ + minutesAgo?: number; +}; + +/** + * Why `event` cannot be checked into now, given the participant's earlier + * check-ins, or null when it can. Overlap is reported before too_soon because + * it is the firmer answer: waiting does not fix it. Pure, so the rule is + * testable without a database. + */ +export function attendanceConflict( + event: AttendedEvent, + earlier: readonly EarlierAttendance[], + now: Date, +): AttendanceConflict | null { + if (event.type === "meal") return null; + + // Already in this event: the duplicate case, which the unique index answers + // with "already checked in". Checking the others too would answer a repeat + // code with "try again in 20 minutes" after a later check-in elsewhere. + if (earlier.some((row) => row.event.id === event.id)) return null; + + const others = earlier.filter((row) => row.event.type !== "meal"); + + // Strict, so an event that ends at 2:00 does not clash with one that starts + // at 2:00. + const overlapping = others.find( + (row) => + row.event.startTime < event.endTime && + event.startTime < row.event.endTime, + ); + if (overlapping) { + return { + kind: "overlap", + other: { id: overlapping.event.id, name: overlapping.event.name }, + }; + } + + let latest: EarlierAttendance | null = null; + for (const row of others) { + if (now.getTime() - row.checkedInAt.getTime() >= CHECK_IN_GAP_MS) continue; + if (!latest || row.checkedInAt > latest.checkedInAt) latest = row; + } + if (!latest) return null; + + return { + kind: "too_soon", + other: { id: latest.event.id, name: latest.event.name }, + // A clock a little behind the database can put the check-in in the future. + minutesAgo: Math.max( + 0, + Math.floor((now.getTime() - latest.checkedInAt.getTime()) / 60_000), + ), + }; +} + +export type CheckInRuleBreakDetails = { + participantId: string; + /** The event they tried to enter. */ + eventId: string; + /** The event they were already in. */ + conflictEventId: string; + rule: AttendanceConflict["kind"]; + method: AttendanceMethod; + /** The hacker on a code, staff otherwise. */ + actorId: string | null; + /** The clock the rule was judged on. */ + at: Date; +}; + +/** + * Rides on the `cause` of the refusal recordEventAttendance throws, so a caller + * outside the transaction can record the attempt after the refusal has rolled + * back. It never reaches a client: tRPC does not serialise `cause`. + */ +export class CheckInRuleBreak extends Error implements CheckInRuleBreakDetails { + readonly participantId: string; + readonly eventId: string; + readonly conflictEventId: string; + readonly rule: AttendanceConflict["kind"]; + readonly method: AttendanceMethod; + readonly actorId: string | null; + readonly at: Date; + + constructor(details: CheckInRuleBreakDetails) { + super(`Check-in rule broken: ${details.rule}`); + this.name = "CheckInRuleBreak"; + this.participantId = details.participantId; + this.eventId = details.eventId; + this.conflictEventId = details.conflictEventId; + this.rule = details.rule; + this.method = details.method; + this.actorId = details.actorId; + this.at = details.at; + } +} + +/** + * The one writer of attendance rows: pass scans, event codes and manual + * check-in all come through here, so every row carries the event's points at + * that moment and how it was made. Callers check that the event and the + * participant belong to the same hackathon and that the participant may + * attend. The unique index on (event, participant) is the duplicate guard; + * two stations racing both pass any earlier read, and the loser gets the same + * sentence a single duplicate would. + * + * It also holds the bad-actor rules (see attendanceConflict): nobody is in two + * events that run at the same time, or in two within CHECK_IN_GAP_MS of each + * other. Those are read-then-write, so the participant's row is locked first: + * two check-ins for one person at the same moment (two tabs, two stations) + * queue up, and the second one sees the first. A refusal is FORBIDDEN, not + * CONFLICT: the self check-in page reads CONFLICT as "already checked in". + * The refusal rolls this transaction back, so it carries a CheckInRuleBreak as + * its cause for the caller to record (see check-in-flags.ts). + */ +export async function recordEventAttendance( + db: PointsDb, + input: { + event: { + id: string; + name: string; + points: number; + type: string; + startTime: Date; + endTime: Date; + }; + participant: { id: string; name: string }; + method: AttendanceMethod; + actorId: string | null; + }, +): Promise<{ pointsAwarded: number }> { + const { event, participant, method, actorId } = input; + // One clock for the rule and the row it writes. Left to the database's + // now(), checked_in_at (no time zone) follows the session's TimeZone and + // would not compare with this one. + const now = new Date(); + + try { + await db.transaction(async (tx) => { + // No key update: enough to queue this person's check-ins behind each + // other without holding up other writes that reference the row. + await tx + .select({ id: hackathonParticipants.id }) + .from(hackathonParticipants) + .where(eq(hackathonParticipants.id, participant.id)) + .for("no key update"); + + const earlier = await tx + .select({ + event: { + id: hackathonEvents.id, + name: hackathonEvents.name, + type: hackathonEvents.type, + startTime: hackathonEvents.startTime, + endTime: hackathonEvents.endTime, + }, + checkedInAt: hackathonEventAttendees.checkedInAt, + }) + .from(hackathonEventAttendees) + .innerJoin( + hackathonEvents, + eq(hackathonEvents.id, hackathonEventAttendees.eventId), + ) + .where(eq(hackathonEventAttendees.participantId, participant.id)); + + const conflict = attendanceConflict(event, earlier, now); + if (conflict) { + throw new TRPCError({ + code: "FORBIDDEN", + message: attendanceConflictMessage(input, conflict), + cause: new CheckInRuleBreak({ + participantId: participant.id, + eventId: event.id, + conflictEventId: conflict.other.id, + rule: conflict.kind, + method, + actorId, + at: now, + }), + }); + } + + await tx.insert(hackathonEventAttendees).values({ + eventId: event.id, + participantId: participant.id, + pointsAwarded: event.points, + method, + checkedInById: actorId, + checkedInAt: now, + }); + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: alreadyCheckedInMessage(input), + }); + } + throw error; + } + + return { pointsAwarded: event.points }; +} + +/** The duplicate sentence, shared by the pre-check and the index. */ +export function alreadyCheckedInMessage(input: { + event: { name: string }; + participant: { name: string }; + method: AttendanceMethod; +}): string { + // A code is entered by the hacker themselves; everything else by staff. + return input.method === "code" + ? `You are already checked into ${input.event.name}.` + : `${input.participant.name} is already checked into ${input.event.name}.`; +} + +const minutesLabel = (n: number) => `${n} minute${n === 1 ? "" : "s"}`; + +/** The refusal sentence for an attendanceConflict, worded like the one above. */ +export function attendanceConflictMessage( + input: { + event: { name: string }; + participant: { name: string }; + method: AttendanceMethod; + }, + conflict: AttendanceConflict, +): string { + const { event, participant, method } = input; + const other = conflict.other.name; + const self = method === "code"; + + if (conflict.kind === "overlap") { + return self + ? `You're already checked into ${other}, which runs at the same time as ${event.name}. You can check into one event at a time.` + : `${participant.name} is already checked into ${other}, which runs at the same time as ${event.name}. If that was a mistake, remove them from ${other} first.`; + } + + const ago = conflict.minutesAgo ?? 0; + const when = + ago === 0 ? "less than a minute ago" : `${minutesLabel(ago)} ago`; + const rule = `Event check-ins have to be at least ${CHECK_IN_GAP_MS / 60_000} minutes apart`; + if (!self) { + return `${participant.name} checked into ${other} ${when}. ${rule}. If that was a mistake, remove them from ${other} first.`; + } + // minutesAgo is floored, so what is left of the gap rounds up on its own. + const left = Math.max(1, CHECK_IN_GAP_MS / 60_000 - ago); + return `You checked into ${other} ${when}. ${rule}, so try again in ${minutesLabel(left)}.`; +} + +// --------------------------------------------------------------------------- +// Balances + +/** One participant's balance. Inside a purchase, run it in that transaction. */ +export async function readBalance( + db: PointsDb, + participantId: string, +): Promise { + const [[awarded], [adjusted], [spent]] = await Promise.all([ + db + .select({ + total: sql`coalesce(sum(${hackathonEventAttendees.pointsAwarded}), 0)::int`, + }) + .from(hackathonEventAttendees) + .where(eq(hackathonEventAttendees.participantId, participantId)), + db + .select({ + total: sql`coalesce(sum(${hackathonPointAdjustments.delta}), 0)::int`, + }) + .from(hackathonPointAdjustments) + .where(eq(hackathonPointAdjustments.participantId, participantId)), + db + .select({ + total: sql`coalesce(sum(${hackathonStorePurchases.total}), 0)::int`, + }) + .from(hackathonStorePurchases) + .where( + and( + eq(hackathonStorePurchases.participantId, participantId), + isNull(hackathonStorePurchases.voidedAt), + ), + ), + ]); + + return pointsBalance({ + awarded: awarded?.total ?? 0, + adjusted: adjusted?.total ?? 0, + spent: spent?.total ?? 0, + }); +} + +/** + * Balances for many participants of one edition in three grouped reads, + * rather than three per person. Without ids, everyone in the edition. + */ +export async function balancesFor( + db: PointsDb, + hackathonId: string, + participantIds?: readonly string[], +): Promise<(participantId: string) => PointsBalance> { + if (participantIds && participantIds.length === 0) { + return () => pointsBalance(NO_TOTALS); + } + const ids = participantIds ? [...participantIds] : null; + + const [attendance, adjustments, purchases] = await Promise.all([ + db + .select({ + participantId: hackathonEventAttendees.participantId, + total: sql`coalesce(sum(${hackathonEventAttendees.pointsAwarded}), 0)::int`, + }) + .from(hackathonEventAttendees) + .innerJoin( + hackathonEvents, + eq(hackathonEvents.id, hackathonEventAttendees.eventId), + ) + .where( + and( + eq(hackathonEvents.hackathonId, hackathonId), + ids ? inArray(hackathonEventAttendees.participantId, ids) : undefined, + ), + ) + .groupBy(hackathonEventAttendees.participantId), + db + .select({ + participantId: hackathonPointAdjustments.participantId, + total: sql`coalesce(sum(${hackathonPointAdjustments.delta}), 0)::int`, + }) + .from(hackathonPointAdjustments) + .where( + and( + eq(hackathonPointAdjustments.hackathonId, hackathonId), + ids + ? inArray(hackathonPointAdjustments.participantId, ids) + : undefined, + ), + ) + .groupBy(hackathonPointAdjustments.participantId), + db + .select({ + participantId: hackathonStorePurchases.participantId, + total: sql`coalesce(sum(${hackathonStorePurchases.total}), 0)::int`, + }) + .from(hackathonStorePurchases) + .where( + and( + eq(hackathonStorePurchases.hackathonId, hackathonId), + isNull(hackathonStorePurchases.voidedAt), + ids ? inArray(hackathonStorePurchases.participantId, ids) : undefined, + ), + ) + .groupBy(hackathonStorePurchases.participantId), + ]); + + const totals = new Map(); + const totalsOf = (id: string) => { + let entry = totals.get(id); + if (!entry) { + entry = { ...NO_TOTALS }; + totals.set(id, entry); + } + return entry; + }; + for (const row of attendance) totalsOf(row.participantId).awarded = row.total; + for (const row of adjustments) + totalsOf(row.participantId).adjusted = row.total; + for (const row of purchases) { + // A purchase outlives a deleted registration with no participant. + if (row.participantId) totalsOf(row.participantId).spent = row.total; + } + + return (participantId) => + pointsBalance(totals.get(participantId) ?? NO_TOTALS); +} + +/** + * Every checked-in participant of the edition, ranked. Names come back whole; + * each caller decides how much of them to show. + */ +export async function rankedLeaderboard(db: PointsDb, hackathonId: string) { + const [rows, balanceOf] = await Promise.all([ + db + .select({ + id: hackathonParticipants.id, + userId: hackathonParticipants.userId, + firstName: hackathonParticipants.firstName, + lastName: hackathonParticipants.lastName, + accountName: users.name, + email: users.email, + }) + .from(hackathonParticipants) + .innerJoin(users, eq(users.id, hackathonParticipants.userId)) + .where( + and( + eq(hackathonParticipants.hackathonId, hackathonId), + eq(hackathonParticipants.registrationStatus, "checked_in"), + ), + ), + balancesFor(db, hackathonId), + ]); + + return rankLeaderboard( + rows.map((row) => { + const name = personName(row); + const balance = balanceOf(row.id); + return { + id: row.id, + userId: row.userId, + email: row.email, + name, + lastName: name.last, + earned: balance.earned, + balance, + }; + }), + ); +} + +// --------------------------------------------------------------------------- +// Ledger + +/** Every row behind one participant's balance, newest first. */ +export async function readLedger(db: PointsDb, participantId: string) { + const [attendance, adjustments, purchases] = await Promise.all([ + db + .select({ + id: hackathonEventAttendees.id, + eventId: hackathonEventAttendees.eventId, + eventName: hackathonEvents.name, + eventStartTime: hackathonEvents.startTime, + checkedInAt: hackathonEventAttendees.checkedInAt, + pointsAwarded: hackathonEventAttendees.pointsAwarded, + method: hackathonEventAttendees.method, + checkedInById: hackathonEventAttendees.checkedInById, + }) + .from(hackathonEventAttendees) + .innerJoin( + hackathonEvents, + eq(hackathonEvents.id, hackathonEventAttendees.eventId), + ) + .where(eq(hackathonEventAttendees.participantId, participantId)) + .orderBy(desc(hackathonEventAttendees.checkedInAt)), + db + .select({ + id: hackathonPointAdjustments.id, + delta: hackathonPointAdjustments.delta, + reason: hackathonPointAdjustments.reason, + actorId: hackathonPointAdjustments.actorId, + createdAt: hackathonPointAdjustments.createdAt, + }) + .from(hackathonPointAdjustments) + .where(eq(hackathonPointAdjustments.participantId, participantId)) + .orderBy(desc(hackathonPointAdjustments.createdAt)), + db + .select({ + id: hackathonStorePurchases.id, + itemName: hackathonStorePurchases.itemName, + unitPrice: hackathonStorePurchases.unitPrice, + quantity: hackathonStorePurchases.quantity, + total: hackathonStorePurchases.total, + actorId: hackathonStorePurchases.actorId, + createdAt: hackathonStorePurchases.createdAt, + voidedAt: hackathonStorePurchases.voidedAt, + voidedById: hackathonStorePurchases.voidedById, + voidReason: hackathonStorePurchases.voidReason, + restocked: hackathonStorePurchases.restocked, + }) + .from(hackathonStorePurchases) + .where(eq(hackathonStorePurchases.participantId, participantId)) + .orderBy(desc(hackathonStorePurchases.createdAt)), + ]); + + return { + attendance, + adjustments, + purchases, + balance: pointsBalance( + ledgerTotals({ attendance, adjustments, purchases }), + ), + }; +} + +// --------------------------------------------------------------------------- +// Finding people at a station + +const SEARCHABLE_STATUSES = ["approved", "checked_in"] as const; + +/** + * Accepted participants whose name or email contains the query. Pending, + * waitlisted and rejected people cannot attend or spend, so they are not + * offered. + */ +export async function searchParticipants( + db: PointsDb, + input: { hackathonId: string; query: string; limit: number }, +) { + const term = input.query.trim(); + if (!term) return []; + + // Escaped so a literal % or _ searches for itself. + const pattern = `%${term.replace(/[\\%_]/g, (c) => `\\${c}`)}%`; + + const rows = await db + .select({ + id: hackathonParticipants.id, + firstName: hackathonParticipants.firstName, + lastName: hackathonParticipants.lastName, + registrationStatus: hackathonParticipants.registrationStatus, + accountName: users.name, + email: users.email, + }) + .from(hackathonParticipants) + .innerJoin(users, eq(users.id, hackathonParticipants.userId)) + .where( + and( + eq(hackathonParticipants.hackathonId, input.hackathonId), + inArray(hackathonParticipants.registrationStatus, [ + ...SEARCHABLE_STATUSES, + ]), + sql`( + ${hackathonParticipants.firstName} ilike ${pattern} + or ${hackathonParticipants.lastName} ilike ${pattern} + or concat_ws(' ', ${hackathonParticipants.firstName}, ${hackathonParticipants.lastName}) ilike ${pattern} + or ${users.name} ilike ${pattern} + or ${users.email} ilike ${pattern} + )`, + ), + ) + .orderBy( + asc(hackathonParticipants.firstName), + asc(hackathonParticipants.lastName), + asc(hackathonParticipants.id), + ) + .limit(input.limit); + + return rows.map((row) => ({ + id: row.id, + name: fullName(personName(row), row.email), + email: row.email, + registrationStatus: row.registrationStatus, + })); +} diff --git a/packages/api/src/services/portal-context.ts b/packages/api/src/services/portal-context.ts index 70585abe..5e42d931 100644 --- a/packages/api/src/services/portal-context.ts +++ b/packages/api/src/services/portal-context.ts @@ -75,9 +75,9 @@ export async function resolveHackathonId( // twice a year. "" means none, since a miss also reads as null. // // getOrSet, not get/set: this is one key shared by the whole instance, and - // when it expires every request in flight resolves the edition again. The - // judge gate reads it on the fallback path, so at a running event that is - // a burst of identical queries once a minute. + // when it expires every request in flight resolves the edition again. + // judge.isJudge reads it whenever no edition is named, so at a running event + // that is a burst of identical queries once a minute. const resolved = await cache.getOrSet( CURRENT_HACKATHON_KEY, // The rule lives in @query/db so sign-in and the webhook share it; this diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 97f28370..56fde76f 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -350,6 +350,29 @@ const uploadSanitizeInputs = t.middleware( }, ); +// A file an organiser uploads as text, such as a Devpost submissions export. +// Up to 12 MB (a 10 MB file plus JSON escaping), and not run through +// scrubMarkup: one project description that mentions "JavaScript:" or quotes +// an tag would refuse the whole file. The procedure's zod schema still +// shapes the input, and the text is only ever rendered escaped. +const textUploadInputs = t.middleware(async ({ next, ctx, getRawInput }) => { + const rawInput = await getRawInput(); + + if (rawInput && !validateRequestSize(rawInput, 12 * 1024 * 1024)) { + logSecurityEvent({ + type: "validation_error", + identifier: ctx.userId ?? ctx.clientIp, + details: "Text upload too large (max 12MB)", + }); + throw new TRPCError({ + code: "PAYLOAD_TOO_LARGE", + message: "That file is too large to upload. The limit is 10 MB.", + }); + } + + return next(); +}); + // Surgical invalidation: each mutation path maps to the exact key patterns it // should evict. Unmapped routes fall back to namespace-level eviction. const CACHE_INVALIDATION_MAP: Record = { @@ -367,6 +390,13 @@ const CACHE_INVALIDATION_MAP: Record = { // resolver evicts that key by id. Empty rather than absent, so the namespace // fallback cannot wipe every attendee's registrations at every door. "hackathon.scanParticipantPass": [], + // The desk admit is the same write as a scan, so it evicts the same way. + "hackathon.admit": [], + // Both change only the caller's own registration, which the resolver evicts + // by user id. Empty rather than absent, so the namespace fallback cannot wipe + // every attendee's cached registrations. + "hackathon.updateMyRegistration": [], + "hackathon.confirmAttendance": [], // `hackathons:list*`, not `hackathons:list`: deletePattern anchors with `$`, // and every real key carries a viewer/status/limit suffix. With an entry // present the namespace fallback is skipped, so nothing was evicted at all. @@ -380,28 +410,15 @@ const CACHE_INVALIDATION_MAP: Record = { // and the two are read from the same namespace. "hackathon.registerInterest": ["hackathon:*:interest"], "hackathon.withdrawInterest": ["hackathon:*:interest"], - // Judge mutations — only invalidate judging-related keys - "judge.submitVote": ["hackathon:*:rankings", "hackathon:*:judge-analytics"], - "judge.completeAndNext": [ - "hackathon:*:rankings", - "hackathon:*:judge-analytics", - ], - "judge.toggleJudging": ["hackathon:*"], - // Promotion creates judgeable projects and flips submissions to "judging", so - // the public project list and the rankings view both move. - "judge.promoteSubmissions": [ - "hackathon:*:projects", - "hackathon:*:public-projects*", - "hackathon:*:rankings", - ], // Announcements write their own rows and nothing cacheable. Empty rather than // absent, so this cannot sweep the whole hackathon namespace. "hackathon.createAnnouncement": [], "hackathon.sendBatch": [], + // A test goes to the caller's own inbox and writes nothing at all. + "hackathon.sendTestToMe": [], // Same: the marker lives on hackathon_interest, which is not cached, and the // admin list is read fresh. "hackathon.notifyRegistrationOpen": [], - "judge.assignToHackathon": ["judge:*"], // Member mutations "member.update": ["member:*", "user:*:profile"], // Renewal moves the membership the portal reads, so its context goes too. @@ -447,10 +464,17 @@ const CACHE_INVALIDATION_MAP: Record = { // registrations — and a volunteer pressing Undo reaches this. "hackathon.removeEventAttendance": [], "hackathon.sendMassAcceptanceEmails": [], + // A check-in by name evicts its event's count by id in the resolver. Codes + // are never in the public, cached schedule, so rotating one evicts nothing. + "hackathon.manualEventCheckIn": [], + "hackathon.generateEventCheckInCode": [], + "hackathon.clearEventCheckInCode": [], // Publishing and unpublishing change what the public getResults returns. - "judge.computeResults": ["hackathon:*:results"], - "judge.publishResults": ["hackathon:*:results"], - "judge.unpublishResults": ["hackathon:*:results"], + // Every other judging.* and points.* mutation falls back to evicting its own + // namespace, which holds nothing: those reads are polled live, not cached. + "judging.results.compute": ["hackathon:*:results"], + "judging.results.publish": ["hackathon:*:results"], + "judging.results.unpublish": ["hackathon:*:results"], // Stripe — invalidate member status after linking "stripe.attemptAutoLink": ["member:*"], "stripe.linkAccount": ["member:*"], @@ -602,8 +626,19 @@ export const uploadProcedure = t.procedure .use(enforceContentType) .use(cacheInvalidationMiddleware); +// Signed in, large text input (see textUploadInputs). Gate it further with +// isAdminUpload in middleware/procedures.ts; nothing should use it bare. +export const textUploadProcedure = t.procedure + .use(recordDuration) + .use(requiresDb) + .use(isAuthed) + .use(textUploadInputs) + .use(enforceContentType) + .use(cacheInvalidationMiddleware); + // No `adminProcedure` or `judgeProcedure` here. Both existed and were // identical to `protectedProcedure` — no role check — so the obvious call // shipped an admin endpoint open to every signed-in user, and it built // cleanly. The real gates are in middleware/procedures.ts: isAdmin, -// isSuperAdmin, isScanner (volunteers included) and isJudge. +// isSuperAdmin and isScanner (volunteers included). The judge desk resolves +// its own principal in routers/judging. diff --git a/packages/auth/package.json b/packages/auth/package.json index a8db62e8..e87f59e7 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -25,7 +25,7 @@ "devDependencies": { "@query/tsconfig": "workspace:*", "@types/node": "^22.20.4", - "@typescript-eslint/eslint-plugin": "^8.70.1", + "@typescript-eslint/eslint-plugin": "^8.71.0", "@typescript-eslint/parser": "^8.70.1", "@typescript-eslint/utils": "^8.70.1", "eslint": "^10.11.0", diff --git a/packages/auth/src/email.ts b/packages/auth/src/email.ts index 1a58c149..1ecab51e 100644 --- a/packages/auth/src/email.ts +++ b/packages/auth/src/email.ts @@ -218,13 +218,67 @@ export async function sendJudgeApprovedEmail({ heading: "You're confirmed as a judge", paragraphs: [ `Your application to judge ${hackathonName} has been approved.`, - "Your judging queue is ready. On the day, scan the QR card on each table to start, score the project, and move to the next one.", + "Club judges: sign in on your phone before you scan the code at your room or table, so your scores count under your account.", + "On the day, scan the QR code at your judging room or table to take your seat, then score each project as it presents.", ], ctaLabel: "Open the judge portal", ctaUrl: `${host}/judge`, }); } +// An organiser added someone as a judge directly, so there was no application +// to approve. Reusing the approval copy would thank them for one they never +// made. +export async function sendJudgeAddedEmail({ + email, + hackathonName, + host = DEFAULT_HOST, +}: { + email: string; + hackathonName: string; + host?: string; +}) { + await sendTransactionalEmail({ + email, + subject: `You've been added as a judge for ${hackathonName}`, + heading: "You've been added as a judge", + paragraphs: [ + `An organiser added you as a judge for ${hackathonName}. There is nothing to apply for or wait on.`, + "Sign in on your phone before you scan the code at your room or table, so your scores count under your account.", + "On the day, scan the QR code at your judging room or table to take your seat, then score each project as it presents.", + ], + ctaLabel: "Open the judge portal", + ctaUrl: `${host}/judge`, + }); +} + +// The judging itinerary went live. Until it does, the Judging tab hides rooms +// and times, and a team had no other way to learn where to stand. +export async function sendJudgingTimesPostedEmail({ + email, + hackathonName, + hackathonId, + host = DEFAULT_HOST, +}: { + email: string; + hackathonName: string; + hackathonId: string; + host?: string; +}) { + await sendTransactionalEmail({ + email, + subject: `Your judging times are posted for ${hackathonName}`, + heading: "Your judging times are posted", + paragraphs: [ + `Where and when your project presents at ${hackathonName} is now on the Judging tab.`, + "If the tab asks you to claim your place on the project, do that first and your times appear.", + "Check it before you head to your room or table, and be there a few minutes early.", + ], + ctaLabel: "See your judging times", + ctaUrl: `${host}/hackathons/${hackathonId}?tab=JUDGING`, + }); +} + // A decision on an application to join an initiative, or on a proposal to run // one. People applied and heard nothing: the decision was visible only to // whoever made it, so the applicant's only option was to keep checking. @@ -353,6 +407,39 @@ export async function sendResultsPublishedEmail({ }); } +// A link that attaches a judged project to the recipient's portal account. +// Devpost lists the address someone signed up there with, which is often not +// the one they sign in with here, so matching on email alone strands them. +export async function sendJudgingClaimEmail({ + email, + hackathonName, + projectTitle, + claimUrl, + invitedBy, +}: { + email: string; + hackathonName: string; + projectTitle: string; + claimUrl: string; + /** The teammate who added them, when Devpost did not list them. */ + invitedBy?: string | null; +}) { + await sendTransactionalEmail({ + email, + subject: `Claim your place on ${projectTitle}`, + heading: "Claim your project", + paragraphs: [ + invitedBy + ? `${invitedBy} added you to ${projectTitle} for judging at ${hackathonName}.` + : `${projectTitle} is entered for judging at ${hackathonName}, and you are listed on its team.`, + "Open the link while signed in to the portal account you checked in with. Where and when you present, and your feedback, then appear on the Judging tab.", + "The link works once and stops working when the event ends.", + ], + ctaLabel: "Claim your place", + ctaUrl: claimUrl, + }); +} + export async function sendAcceptanceEmail({ email, hackathonName, diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index 140b2ec7..b26aefcb 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -9,8 +9,44 @@ if (!process.env.DATABASE_URL) { throw new Error("DATABASE_URL is not defined in .env file"); } +// Tables the retired panel judging service created in this database with its +// own SQL migrations. Nothing reads them. 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 schema change after it is silently skipped. +// Their enum types are declared in src/schemas/retired.ts for the same reason. +const retiredPanelTables = [ + "panel_migration", + "organization", + "panel_user", + "panel_event", + "event_config", + "rubric", + "criterion", + "track", + "prize", + "zone", + "floor_table", + "project", + "project_track", + "panel_judge", + "visit", + "vote", + "vote_score", + "comparison", + "result_run", + "result", + "event_log", + "outbox", + "webhook", + "api_key", + "membership", + "judge_identity", + "login_code", +]; + export default defineConfig({ schema: "./src/schemas/**/*.ts", + tablesFilter: retiredPanelTables.map((table) => `!${table}`), out: "./drizzle", dialect: "postgresql", dbCredentials: { diff --git a/packages/db/package.json b/packages/db/package.json index 7ccc9e35..a83cbc11 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -30,7 +30,7 @@ "devDependencies": { "@query/tsconfig": "workspace:*", "@types/pg": "^8.23.1", - "dotenv": "^18.0.3", + "dotenv": "^18.0.5", "drizzle-kit": "0.31.11", "tsx": "^4.23.15", "typescript": "^7.0.2", diff --git a/packages/db/scripts/export-clickhouse.mts b/packages/db/scripts/export-clickhouse.mts index b4941d00..77535c6e 100644 --- a/packages/db/scripts/export-clickhouse.mts +++ b/packages/db/scripts/export-clickhouse.mts @@ -1,16 +1,19 @@ /** - * Copies payment and membership history from Postgres into ClickHouse. + * Copies payment, membership, and hackathon history from Postgres into ClickHouse. * * pnpm --filter @query/db export:clickhouse * - * Read-only against Postgres — it runs two SELECTs and writes nothing back. + * Read-only against Postgres — it runs SELECTs and writes nothing back. * That is deliberate: the application database is a 0.5 GB Neon instance, so * history that exists to be aggregated belongs somewhere that is not it. * - * Safe to re-run. Both ClickHouse tables are ReplacingMergeTree keyed on the + * Safe to re-run. The ClickHouse tables are ReplacingMergeTree keyed on the * row id, so a second run replaces rows rather than duplicating them, and the * whole thing can go on a cron without any watermark bookkeeping. * + * Hackathon rows are ids, edition names, and statuses. Names and emails stay + * in Postgres. + * * Env: * DATABASE_URL Postgres to read from. Prod is fine — nothing is written. * CLICKHOUSE_URL default http://localhost:8123 @@ -18,7 +21,22 @@ * CLICKHOUSE_PASSWORD default dsgt * CLICKHOUSE_DB default dsgt */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { eq } from "drizzle-orm"; import { Pool } from "pg"; +import { + hackathonEventAttendees, + hackathonEvents, + hackathonParticipants, + hackathonProjects, + hackathons, + judgeVotes, + judgingProjects, + membershipHistory, + stripePayments, +} from "../src/schemas"; const { DATABASE_URL, @@ -64,6 +82,42 @@ async function insert(table: string, rows: unknown[]) { const stamp = (value: Date | null | undefined) => (value ?? new Date(0)).toISOString().replace("T", " ").replace("Z", ""); +const stampOrNull = (value: Date | null | undefined) => + value ? stamp(value) : null; + +/** Applies monitoring/clickhouse/init/02-hackathon.sql. Init only runs on an empty volume. */ +async function ensureHackathonTables() { + const file = fileURLToPath( + new URL( + "../../../monitoring/clickhouse/init/02-hackathon.sql", + import.meta.url, + ), + ); + const statements = readFileSync(file, "utf-8") + .split("\n") + .filter((line) => !line.trim().startsWith("--")) + .join("\n") + .split(";") + .map((statement) => statement.trim()) + .filter(Boolean); + + for (const statement of statements) { + const url = `${CLICKHOUSE_URL}/?query=${encodeURIComponent(statement)}`; + const res = await fetch(url, { + method: "POST", + headers: { + "X-ClickHouse-User": CLICKHOUSE_USER, + "X-ClickHouse-Key": CLICKHOUSE_PASSWORD, + }, + }); + if (!res.ok) { + throw new Error( + `ClickHouse rejected schema setup: ${res.status} ${await res.text()}`, + ); + } + } +} + const readJson = (raw: string | null) => { if (!raw) return {} as Record; try { @@ -74,76 +128,161 @@ const readJson = (raw: string | null) => { }; const pool = new Pool({ connectionString: DATABASE_URL, max: 2 }); +const db = drizzle(pool); try { - const payments = await pool.query<{ - id: string; - created_at: Date; - updated_at: Date | null; - amount_total: number | null; - currency: string | null; - payment_status: string; - metadata: string | null; - linked_user_id: string | null; - customer_email: string; - }>( - `select id, created_at, updated_at, amount_total, currency, - payment_status, metadata, linked_user_id, customer_email - from "stripe_payment"`, - ); + await ensureHackathonTables(); + const payments = await db + .select({ + id: stripePayments.id, + createdAt: stripePayments.createdAt, + updatedAt: stripePayments.updatedAt, + amountTotal: stripePayments.amountTotal, + currency: stripePayments.currency, + paymentStatus: stripePayments.paymentStatus, + metadata: stripePayments.metadata, + linkedUserId: stripePayments.linkedUserId, + customerEmail: stripePayments.customerEmail, + }) + .from(stripePayments); - const paymentRows = payments.rows.map((row) => { + const paymentRows = payments.map((row) => { const meta = readJson(row.metadata); return { id: row.id, - created_at: stamp(row.created_at), - updated_at: stamp(row.updated_at ?? row.created_at), - amount_cents: row.amount_total ?? 0, + created_at: stamp(row.createdAt), + updated_at: stamp(row.updatedAt ?? row.createdAt), + amount_cents: row.amountTotal ?? 0, currency: row.currency ?? "usd", - payment_status: row.payment_status, + payment_status: row.paymentStatus, // Rows written before the plan existed bought the only thing on offer. plan: meta.plan === "semester" ? "semester" : "annual", bootcamp: meta.bootcamp === "true" ? 1 : 0, addon_only: meta.type === "bootcamp_addon" ? 1 : 0, - linked: row.linked_user_id ? 1 : 0, - customer_email: row.customer_email, + linked: row.linkedUserId ? 1 : 0, + customer_email: row.customerEmail, }; }); - const history = await pool.query<{ - id: string; - member_id: string; - action: string; - start_date: Date | null; - end_date: Date | null; - created_at: Date; - }>( - `select id, member_id, action, start_date, end_date, created_at - from "membership_history"`, - ); + const history = await db + .select({ + id: membershipHistory.id, + memberId: membershipHistory.memberId, + action: membershipHistory.action, + startDate: membershipHistory.startDate, + endDate: membershipHistory.endDate, + createdAt: membershipHistory.createdAt, + }) + .from(membershipHistory); const DAY_MS = 24 * 60 * 60 * 1000; - const historyRows = history.rows.map((row) => ({ + const historyRows = history.map((row) => ({ id: row.id, - member_id: row.member_id, + member_id: row.memberId, action: row.action, - start_date: stamp(row.start_date), - end_date: stamp(row.end_date), - created_at: stamp(row.created_at), + start_date: stamp(row.startDate), + end_date: stamp(row.endDate), + created_at: stamp(row.createdAt), term_days: - row.start_date && row.end_date + row.startDate && row.endDate ? Math.round( - (row.end_date.getTime() - row.start_date.getTime()) / DAY_MS, + (row.endDate.getTime() - row.startDate.getTime()) / DAY_MS, ) : 0, })); + const registrations = await db + .select({ + id: hackathonParticipants.id, + hackathon: hackathons.name, + status: hackathonParticipants.registrationStatus, + registeredAt: hackathonParticipants.registeredAt, + updatedAt: hackathonParticipants.updatedAt, + }) + .from(hackathonParticipants) + .innerJoin( + hackathons, + eq(hackathons.id, hackathonParticipants.hackathonId), + ); + + const registrationRows = registrations.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + status: row.status, + registered_at: stamp(row.registeredAt), + updated_at: stamp(row.updatedAt), + })); + + const projects = await db + .select({ + id: hackathonProjects.id, + hackathon: hackathons.name, + status: hackathonProjects.status, + submittedAt: hackathonProjects.submittedAt, + updatedAt: hackathonProjects.updatedAt, + }) + .from(hackathonProjects) + .innerJoin(hackathons, eq(hackathons.id, hackathonProjects.hackathonId)); + + const projectRows = projects.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + status: row.status, + submitted_at: stampOrNull(row.submittedAt), + updated_at: stamp(row.updatedAt), + })); + + const scans = await db + .select({ + id: hackathonEventAttendees.id, + hackathon: hackathons.name, + eventName: hackathonEvents.name, + checkedInAt: hackathonEventAttendees.checkedInAt, + }) + .from(hackathonEventAttendees) + .innerJoin( + hackathonEvents, + eq(hackathonEvents.id, hackathonEventAttendees.eventId), + ) + .innerJoin(hackathons, eq(hackathons.id, hackathonEvents.hackathonId)); + + const scanRows = scans.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + event_name: row.eventName, + checked_in_at: stamp(row.checkedInAt), + })); + + const votes = await db + .select({ + id: judgeVotes.id, + hackathon: hackathons.name, + score: judgeVotes.score, + votedAt: judgeVotes.votedAt, + updatedAt: judgeVotes.updatedAt, + }) + .from(judgeVotes) + .innerJoin(judgingProjects, eq(judgingProjects.id, judgeVotes.projectId)) + .innerJoin(hackathons, eq(hackathons.id, judgingProjects.hackathonId)); + + const voteRows = votes.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + score: row.score, + voted_at: stamp(row.votedAt), + updated_at: stamp(row.updatedAt), + })); + const written = (await insert("payments", paymentRows)) + - (await insert("membership_events", historyRows)); + (await insert("membership_events", historyRows)) + + (await insert("hackathon_registrations", registrationRows)) + + (await insert("hackathon_projects", projectRows)) + + (await insert("hackathon_scans", scanRows)) + + (await insert("hackathon_votes", voteRows)); console.log( - `Exported ${paymentRows.length} payments and ${historyRows.length} membership events (${written} rows).`, + `Exported ${paymentRows.length} payments, ${historyRows.length} membership events, ${registrationRows.length} registrations, ${projectRows.length} projects, ${scanRows.length} scans, and ${voteRows.length} votes (${written} rows).`, ); } catch (error) { console.error("Export failed:", error); diff --git a/packages/db/src/schemas/hackathons.ts b/packages/db/src/schemas/hackathons.ts index 0ada7f93..c5fc4603 100644 --- a/packages/db/src/schemas/hackathons.ts +++ b/packages/db/src/schemas/hackathons.ts @@ -56,6 +56,19 @@ export const hackathons = pgTable( websiteUrl: text("website_url"), isPublic: boolean("is_public").notNull().default(true), judgingActive: boolean("judging_active").notNull().default(false), + // Retired with the panel judging service; nothing reads it. Still declared + // because a database that already has the column would otherwise see push + // propose dropping it, which stops every deploy at a TTY prompt. + judgingBackend: text("judging_backend", { enum: ["legacy", "panel"] }) + .notNull() + .default("legacy"), + // Point store. Hidden and closed until an organiser opens it; the location + // is where hackers pick up what they bought. + storeCatalogVisible: boolean("store_catalog_visible") + .notNull() + .default(false), + storeOpen: boolean("store_open").notNull().default(false), + storeLocation: text("store_location"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, @@ -170,6 +183,9 @@ export const hackathonParticipants = pgTable( // Participation tracking checkedInAt: timestamp("checked_in_at"), + // Set when an accepted hacker says they are coming. Null means no answer, + // which is what organisers chase to free seats before the event. + attendanceConfirmedAt: timestamp("attendance_confirmed_at"), hasSubmittedProject: boolean("has_submitted_project") .notNull() .default(false), @@ -403,12 +419,24 @@ export const hackathonEvents = pgTable( location: text("location").notNull(), startTime: timestamp("start_time").notNull(), endTime: timestamp("end_time").notNull(), + // What attending is worth. Copied onto each attendance row at check-in, so + // editing it later never changes what anyone already earned. + points: integer("points").notNull().default(0), + // A short code shown at the event (on screen, or as a QR that opens the + // portal with it filled in). Hackers type or scan it themselves, so nobody + // has to hold up a pass. Only accepted while selfCheckIn is on and the + // event is running. + checkInCode: text("check_in_code"), + selfCheckIn: boolean("self_check_in").notNull().default(false), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => [ index("event_hackathon_id_idx").on(table.hackathonId), index("event_type_idx").on(table.type), + uniqueIndex("event_check_in_code_idx") + .on(table.hackathonId, table.checkInCode) + .where(sql`${table.checkInCode} is not null`), ], ); @@ -422,6 +450,14 @@ export const hackathonEventAttendees = pgTable( .references(() => hackathonEvents.id, { onDelete: "cascade" }), participantId: uuid("participant_id").notNull(), checkedInAt: timestamp("checked_in_at").defaultNow().notNull(), + // The event's points at the moment of check-in. Null on rows from before + // points existed, which count as zero. No default on purpose: every writer + // must copy the event's value, or the number is invented. + pointsAwarded: integer("points_awarded"), + // scan: staff scanned the pass. code: the hacker entered the event's code. + // manual: staff found them by name. Null on older rows. + method: text("method", { enum: ["scan", "code", "manual"] }), + checkedInById: text("checked_in_by_id"), }, (table) => [ index("event_attendee_event_id_idx").on(table.eventId), @@ -435,6 +471,11 @@ export const hackathonEventAttendees = pgTable( columns: [table.participantId], foreignColumns: [hackathonParticipants.id], }).onDelete("cascade"), + foreignKey({ + name: "event_attendee_checked_in_by_fk", + columns: [table.checkedInById], + foreignColumns: [users.id], + }).onDelete("set null"), ], ); @@ -463,6 +504,77 @@ export const hackathonEventAttendeesRelations = relations( }), ); +// A check-in the rules refused (two events at the same time, or two under 30 +// minutes apart). The refusal itself rolls back, so this is the only trace: +// organisers read it as a flag on the hacker's team in judging and decide +// whether the team stays in the running for prizes. Never shown to hackers. +// One row per (person, event tried, event already in) with a count, so a hacker +// retrying a rejected code adds an attempt rather than a screen of duplicates. +// It points at the participant, not the project, because a person can change +// teams or have their project promoted later; judging resolves it to an entry +// when it is read. +export const hackathonCheckInFlags = pgTable( + "hackathon_check_in_flag", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + // The participant and event FKs are declared in the table extras with + // explicit names. The generated participant one is 66 characters, past + // Postgres's 63-char limit, and the conflict event one lands on exactly 63; + // the event one is named to match. + participantId: uuid("participant_id").notNull(), + // The event they tried to enter. Cleared if the event is deleted: the flag + // is the only record of the rule break, and prize review still needs it + // after a session is cancelled. Always set when the flag is written. + eventId: uuid("event_id"), + // The event they were already in. Cleared the same way. + conflictEventId: uuid("conflict_event_id"), + rule: text("rule", { enum: ["overlap", "too_soon"] }).notNull(), + method: text("method", { enum: ["scan", "code", "manual"] }).notNull(), + // The hacker on a code, staff otherwise. + attemptedById: text("attempted_by_id").references(() => users.id, { + onDelete: "set null", + }), + attempts: integer("attempts").notNull().default(1), + // Written from the app clock, like checked_in_at: the rule compares against + // it, and the database's now() follows the session's TimeZone. + createdAt: timestamp("created_at").defaultNow().notNull(), + lastAttemptAt: timestamp("last_attempt_at").defaultNow().notNull(), + // An organiser reviewed the flag and cleared it. It stays on record. + dismissedAt: timestamp("dismissed_at"), + dismissedById: text("dismissed_by_id").references(() => users.id, { + onDelete: "set null", + }), + }, + (table) => [ + // Nulls are distinct in a unique index, so clearing a deleted event's id + // never makes two flags collide here. + uniqueIndex("check_in_flag_attempt_idx").on( + table.participantId, + table.eventId, + table.conflictEventId, + ), + index("check_in_flag_hackathon_idx").on(table.hackathonId), + foreignKey({ + name: "check_in_flag_participant_fk", + columns: [table.participantId], + foreignColumns: [hackathonParticipants.id], + }).onDelete("cascade"), + foreignKey({ + name: "check_in_flag_event_fk", + columns: [table.eventId], + foreignColumns: [hackathonEvents.id], + }).onDelete("set null"), + foreignKey({ + name: "check_in_flag_conflict_event_fk", + columns: [table.conflictEventId], + foreignColumns: [hackathonEvents.id], + }).onDelete("set null"), + ], +); + export const hackathonProjectsRelations = relations( hackathonProjects, ({ one }) => ({ @@ -497,6 +609,10 @@ export const hackathonAnnouncements = pgTable( // Rejected and waitlisted applicants, who every other audience deliberately // excludes. Chosen on purpose or not at all. "not_accepted", + // Each half of not_accepted on its own, so a waitlist note never + // reaches someone who was turned down. + "waitlisted", + "rejected", ], }).notNull(), subject: text("subject").notNull(), diff --git a/packages/db/src/schemas/index.ts b/packages/db/src/schemas/index.ts index 2de7c4e2..9bf52fc7 100644 --- a/packages/db/src/schemas/index.ts +++ b/packages/db/src/schemas/index.ts @@ -7,6 +7,9 @@ export * from "./admins"; export * from "./events"; export * from "./bootcamp"; export * from "./judge"; +export * from "./judging"; +export * from "./points"; +export * from "./retired"; export * from "./initiatives"; export * from "./club-projects"; export * from "./subteams"; diff --git a/packages/db/src/schemas/judge.ts b/packages/db/src/schemas/judge.ts index a8286cde..3d63fec8 100644 --- a/packages/db/src/schemas/judge.ts +++ b/packages/db/src/schemas/judge.ts @@ -13,6 +13,14 @@ import { relations, sql } from "drizzle-orm"; import { users } from "./auth"; import { hackathons, hackathonProjects } from "./hackathons"; +// The judge roster is live: `judge` (one row per person per edition) and +// `judge_assignment` (their application, with the track they would like). +// +// judging_project, judge_vote, hackathon_result and judge_queue belong to the +// retired pool-dispatch judging. Nothing writes them any more. They stay +// declared for past editions' data: hackathon.getResults still reads +// hackathon_result. Room judging is in judging.ts. + export const judges = pgTable( "judge", { @@ -71,8 +79,8 @@ export const judgeAssignments = pgTable( (table) => [ index("assignment_judge_id_idx").on(table.judgeId), index("assignment_hackathon_id_idx").on(table.hackathonId), - // assignToHackathon and judge.register both enforce one assignment per judge - // per hackathon with a read before the insert. + // judge.register enforces one assignment per judge per hackathon with a + // read before the insert. uniqueIndex("assignment_judge_hackathon_idx").on( table.judgeId, table.hackathonId, @@ -80,7 +88,8 @@ export const judgeAssignments = pgTable( ], ); -// Projects with table numbers for judging (extends hackathon projects concept) +// Retired (see the top of this file). Projects with table numbers for the +// pool-dispatch judging. export const judgingProjects = pgTable( "judging_project", { @@ -219,8 +228,8 @@ export const hackathonResults = pgTable( ], ); -// One row per visit: written when the judging pool hands a judge a table -// (api routers/judge/dispatch.ts), not as a list built in advance. +// Retired (see the top of this file). One row per visit: written when the +// judging pool handed a judge a table. export const judgeQueue = pgTable( "judge_queue", { diff --git a/packages/db/src/schemas/judging.ts b/packages/db/src/schemas/judging.ts new file mode 100644 index 00000000..0ab2b1a6 --- /dev/null +++ b/packages/db/src/schemas/judging.ts @@ -0,0 +1,1066 @@ +import { + pgTable, + text, + timestamp, + uuid, + boolean, + integer, + jsonb, + numeric, + index, + uniqueIndex, + primaryKey, + foreignKey, +} from "drizzle-orm/pg-core"; +import { relations, sql } from "drizzle-orm"; +import { users } from "./auth"; +import { hackathons, hackathonProjects } from "./hackathons"; + +// Room-based project judging. Projects come in from a Devpost submissions +// export or from portal submissions, are sorted into challenges, and present +// to judges seated in rooms on a generated schedule. Every table hangs off a +// hackathon edition. +// +// Names are prefixed `judging_` because the older pool-dispatch tables +// (judge.ts) and the retired panel's tables share this database: `project`, +// `track`, `vote`, `judging_project` and `judge` are all taken. +// +// No CHECK constraints, matching the rest of the schema: ranges are enforced +// by the API, which is the only writer. + +// One row per edition: the state machine and the switches organisers flip on +// judging day. +export const judgingConfigs = pgTable("judging_config", { + hackathonId: uuid("hackathon_id") + .primaryKey() + .references(() => hackathons.id, { onDelete: "cascade" }), + // draft → open → closed, and closed ↔ open. Back to draft only while no + // evaluation exists. + state: text("state", { enum: ["draft", "open", "closed"] }) + .notNull() + .default("draft"), + openedAt: timestamp("opened_at"), + closedAt: timestamp("closed_at"), + // Member judges see every scoped score, not only projects they judged. + // Guests never do. + showAllScores: boolean("show_all_scores").notNull().default(false), + // Set by the first room link or the first claim. After it, a Devpost import + // only adds projects; replacing the inventory needs typed confirmation. + inventoryLockedAt: timestamp("inventory_locked_at"), + inventoryLockedById: text("inventory_locked_by_id").references( + () => users.id, + { onDelete: "set null" }, + ), + groupsInitializedAt: timestamp("groups_initialized_at"), + // Hackers see their times and rooms only once this is on. + itineraryPublished: boolean("itinerary_published").notNull().default(false), + // Schedule fell over: hackers get project search, roster and feedback hide. + emergencyMode: boolean("emergency_mode").notNull().default(false), + claimsStartedAt: timestamp("claims_started_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + +// A judged project. `source` says where it came from: a Devpost export row +// (keyed by its normalised submission URL) or a portal submission (keyed by +// the hackathon_project it was promoted from). +export const judgingEntries = pgTable( + "judging_entry", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + source: text("source", { enum: ["devpost", "portal"] }).notNull(), + sourceProjectId: uuid("source_project_id"), + title: text("title").notNull(), + submissionUrl: text("submission_url"), + description: text("description").notNull().default(""), + // No array defaults: drizzle-kit re-diffs them on push. Every insert sets them. + demoLinks: text("demo_links").array().notNull(), + videoUrl: text("video_url"), + technologies: text("technologies").array().notNull(), + universities: text("universities").array().notNull(), + prizeCategories: text("prize_categories").array().notNull(), + participantCount: integer("participant_count").notNull().default(1), + projectCreatedAt: timestamp("project_created_at"), + submittedAt: timestamp("submitted_at"), + // Soft delete: evaluations and deliberation notes keep pointing at it. + deletedAt: timestamp("deleted_at"), + deletedById: text("deleted_by_id").references(() => users.id, { + onDelete: "set null", + }), + // An organiser pulled this entry from prizes, usually after a check-in rule + // break. Judges still score it, so nothing about the room schedule changes; + // only placements and results leave it out. + prizeIneligibleAt: timestamp("prize_ineligible_at"), + prizeIneligibleById: text("prize_ineligible_by_id").references( + () => users.id, + { onDelete: "set null" }, + ), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [ + index("judging_entry_hackathon_idx").on(table.hackathonId), + // Re-importing the same export must find the rows it already wrote. + uniqueIndex("judging_entry_submission_url_idx") + .on(table.hackathonId, table.submissionUrl) + .where(sql`${table.submissionUrl} is not null`), + // Promoting portal submissions is re-run as late ones land. + uniqueIndex("judging_entry_source_project_idx") + .on(table.hackathonId, table.sourceProjectId) + .where(sql`${table.sourceProjectId} is not null`), + foreignKey({ + name: "judging_entry_source_project_fk", + columns: [table.sourceProjectId], + foreignColumns: [hackathonProjects.id], + }).onDelete("set null"), + ], +); + +// A person on a project. Devpost rows carry a name and email; claiming links +// the row to a portal user. Portal submissions are linked when promoted. +export const judgingEntryMembers = pgTable( + "judging_entry_member", + { + id: uuid("id").defaultRandom().primaryKey(), + entryId: uuid("entry_id") + .notNull() + .references(() => judgingEntries.id, { onDelete: "cascade" }), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + name: text("name").notNull(), + email: text("email").notNull(), + displayOrder: integer("display_order").notNull().default(0), + userId: text("user_id").references(() => users.id, { + onDelete: "set null", + }), + claimedAt: timestamp("claimed_at"), + // Set when a claimed teammate added this person, rather than Devpost. + invitedById: text("invited_by_id").references(() => users.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + index("judging_entry_member_entry_idx").on(table.entryId), + index("judging_entry_member_email_idx").on(table.hackathonId, table.email), + // One project per person per edition. + uniqueIndex("judging_entry_member_user_idx") + .on(table.hackathonId, table.userId) + .where(sql`${table.userId} is not null`), + ], +); + +// A one-time claim link for a member whose Devpost email is not the address +// they sign in with. The token is cleared when used. +export const judgingClaimLinks = pgTable( + "judging_claim_link", + { + id: uuid("id").defaultRandom().primaryKey(), + memberId: uuid("member_id") + .notNull() + .references(() => judgingEntryMembers.id, { onDelete: "cascade" }), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + token: text("token"), + sentAt: timestamp("sent_at"), + expiresAt: timestamp("expires_at").notNull(), + consumedAt: timestamp("consumed_at"), + consumedById: text("consumed_by_id").references(() => users.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("judging_claim_link_member_idx").on(table.memberId), + uniqueIndex("judging_claim_link_token_idx") + .on(table.token) + .where(sql`${table.token} is not null`), + ], +); + +// A prize a project is judged for. Groups hold children (the "MLH Challenges" +// group collects every imported MLH label); a child inherits its group's mode. +// `isGeneral` means every project is in it whether or not they opted in. +export const judgingChallenges = pgTable( + "judging_challenge", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + label: text("label").notNull(), + parentId: uuid("parent_id"), + isGroup: boolean("is_group").notNull().default(false), + // New imported labels containing this text (case-insensitive) are filed + // under this group. + importLabelMatch: text("import_label_match"), + isGeneral: boolean("is_general").notNull().default(false), + // scheduled: timed appointments in rooms. unscheduled: judged in rooms, + // untimed. remote: untimed and hidden from hackers. + mode: text("mode", { enum: ["scheduled", "unscheduled", "remote"] }) + .notNull() + .default("scheduled"), + displayOrder: integer("display_order").notNull().default(0), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + index("judging_challenge_hackathon_idx").on(table.hackathonId), + // The API trims labels and matches them case-insensitively before insert. + // Plain columns, since an expression index re-diffs on every push. + uniqueIndex("judging_challenge_label_idx").on( + table.hackathonId, + table.label, + table.isGroup, + ), + foreignKey({ + name: "judging_challenge_parent_fk", + columns: [table.parentId], + foreignColumns: [table.id], + }).onDelete("set null"), + ], +); + +// Which challenges a project is judged in. `isOptIn` is false for rows derived +// from a parent group or a general challenge. +export const judgingEntryChallenges = pgTable( + "judging_entry_challenge", + { + entryId: uuid("entry_id") + .notNull() + .references(() => judgingEntries.id, { onDelete: "cascade" }), + challengeId: uuid("challenge_id").notNull(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + isOptIn: boolean("is_opt_in").notNull().default(true), + }, + (table) => [ + primaryKey({ columns: [table.entryId, table.challengeId] }), + index("judging_entry_challenge_challenge_idx").on(table.challengeId), + foreignKey({ + name: "judging_entry_challenge_challenge_fk", + columns: [table.challengeId], + foreignColumns: [judgingChallenges.id], + }).onDelete("cascade"), + ], +); + +export const judgingBuildings = pgTable( + "judging_building", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + name: text("name").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("judging_building_name_idx").on(table.hackathonId, table.name), + ], +); + +// What a judging station is. A room has its own door; a table is one of the +// numbered tables in a hall (a building), where judges sit and teams come to +// them at their slot. Both are judged and scheduled the same way. +export const judgingRoomKinds = ["room", "table"] as const; +export type JudgingRoomKind = (typeof judgingRoomKinds)[number]; + +// A room or table judges sit at. Each serves one root challenge (or group). +export const judgingRooms = pgTable( + "judging_room", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + challengeId: uuid("challenge_id") + .notNull() + .references(() => judgingChallenges.id, { onDelete: "cascade" }), + buildingId: uuid("building_id").references(() => judgingBuildings.id, { + onDelete: "set null", + }), + name: text("name").notNull(), + kind: text("kind", { enum: judgingRoomKinds }).notNull().default("room"), + displayOrder: integer("display_order").notNull().default(0), + archivedAt: timestamp("archived_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + index("judging_room_hackathon_idx").on(table.hackathonId), + uniqueIndex("judging_room_name_idx") + .on(table.hackathonId, table.buildingId, table.name) + .where(sql`${table.archivedAt} is null`), + ], +); + +// The QR code taped to a room's door. Scanning it seats a signed-in judge in +// the room, or starts a guest session for someone with no account. One live +// link per room; revoking it ends every guest session it started. +export const judgingRoomLinks = pgTable( + "judging_room_link", + { + id: uuid("id").defaultRandom().primaryKey(), + roomId: uuid("room_id") + .notNull() + .references(() => judgingRooms.id, { onDelete: "cascade" }), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + createdById: text("created_by_id").references(() => users.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + revokedAt: timestamp("revoked_at"), + revokedById: text("revoked_by_id").references(() => users.id, { + onDelete: "set null", + }), + }, + (table) => [ + uniqueIndex("judging_room_link_active_idx") + .on(table.roomId) + .where(sql`${table.revokedAt} is null`), + ], +); + +// Somebody judging. A member judge is a portal user (an approved `judge` row +// or staff); a guest came in through a room link and typed a name. +export const judgingJudges = pgTable( + "judging_judge", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + kind: text("kind", { enum: ["member", "guest"] }).notNull(), + userId: text("user_id").references(() => users.id, { onDelete: "cascade" }), + displayName: text("display_name").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + index("judging_judge_hackathon_idx").on(table.hackathonId), + uniqueIndex("judging_judge_member_idx") + .on(table.hackathonId, table.userId) + .where(sql`${table.kind} = 'member'`), + ], +); + +// A guest's browser. The cookie holds 32 random bytes; only their sha256 is +// stored. `judgeId` is set once the guest gives a name. +export const judgingGuestSessions = pgTable( + "judging_guest_session", + { + id: uuid("id").defaultRandom().primaryKey(), + tokenHash: text("token_hash").notNull(), + linkId: uuid("link_id") + .notNull() + .references(() => judgingRoomLinks.id, { onDelete: "cascade" }), + roomId: uuid("room_id") + .notNull() + .references(() => judgingRooms.id, { onDelete: "cascade" }), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + judgeId: uuid("judge_id").references(() => judgingJudges.id, { + onDelete: "set null", + }), + expiresAt: timestamp("expires_at").notNull(), + completedAt: timestamp("completed_at"), + revokedAt: timestamp("revoked_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("judging_guest_session_token_idx").on(table.tokenHash), + index("judging_guest_session_link_idx").on(table.linkId), + ], +); + +// Which room a judge is sitting in right now. One open row per judge. +export const judgingPresence = pgTable( + "judging_presence", + { + id: uuid("id").defaultRandom().primaryKey(), + judgeId: uuid("judge_id") + .notNull() + .references(() => judgingJudges.id, { onDelete: "cascade" }), + roomId: uuid("room_id") + .notNull() + .references(() => judgingRooms.id, { onDelete: "cascade" }), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + joinedAt: timestamp("joined_at").defaultNow().notNull(), + lastSeenAt: timestamp("last_seen_at").defaultNow().notNull(), + leftAt: timestamp("left_at"), + }, + (table) => [ + uniqueIndex("judging_presence_open_idx") + .on(table.judgeId) + .where(sql`${table.leftAt} is null`), + index("judging_presence_room_idx").on(table.roomId), + ], +); + +// The saved schedule for an edition: its window and the slot arithmetic. +// One slot = setup + judging + teardown minutes. +export const judgingSchedules = pgTable( + "judging_schedule", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + windowStart: timestamp("window_start").notNull(), + windowEnd: timestamp("window_end").notNull(), + setupMinutes: integer("setup_minutes").notNull().default(2), + judgingMinutes: integer("judging_minutes").notNull().default(6), + teardownMinutes: integer("teardown_minutes").notNull().default(2), + sameBuildingBreakMinutes: integer("same_building_break_minutes") + .notNull() + .default(10), + crossBuildingBreakMinutes: integer("cross_building_break_minutes") + .notNull() + .default(20), + // Saved from a relaxed solve, where cross-building walks got only the + // same-building break. An organiser had to say yes to that. + reducedBreaksAcknowledgedAt: timestamp("reduced_breaks_acknowledged_at"), + // The first submitted evaluation. After it the schedule cannot be dropped. + firstResultAt: timestamp("first_result_at"), + createdById: text("created_by_id").references(() => users.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [uniqueIndex("judging_schedule_hackathon_idx").on(table.hackathonId)], +); + +// One presentation: a project, for one challenge, in one room, at one time. +// deadlineAt = startsAt + setup + judging; endsAt = startsAt + slot length. +export const judgingAppointments = pgTable( + "judging_appointment", + { + id: uuid("id").defaultRandom().primaryKey(), + scheduleId: uuid("schedule_id") + .notNull() + .references(() => judgingSchedules.id, { onDelete: "cascade" }), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + entryId: uuid("entry_id") + .notNull() + .references(() => judgingEntries.id, { onDelete: "cascade" }), + challengeId: uuid("challenge_id").notNull(), + roomId: uuid("room_id") + .notNull() + .references(() => judgingRooms.id, { onDelete: "cascade" }), + startsAt: timestamp("starts_at").notNull(), + deadlineAt: timestamp("deadline_at").notNull(), + endsAt: timestamp("ends_at").notNull(), + // Compare-and-swap for moves made from two organiser screens at once. + revision: integer("revision").notNull().default(0), + // The one judge who grades this presentation. Null until judges are + // assigned, and again if that judge is removed. + assignedJudgeId: uuid("assigned_judge_id").references( + () => judgingJudges.id, + { onDelete: "set null" }, + ), + }, + (table) => [ + uniqueIndex("judging_appointment_task_idx").on( + table.scheduleId, + table.entryId, + table.challengeId, + ), + uniqueIndex("judging_appointment_room_slot_idx").on( + table.scheduleId, + table.roomId, + table.startsAt, + ), + index("judging_appointment_entry_idx").on(table.entryId), + index("judging_appointment_judge_idx").on(table.assignedJudgeId), + foreignKey({ + name: "judging_appointment_challenge_fk", + columns: [table.challengeId], + foreignColumns: [judgingChallenges.id], + }).onDelete("cascade"), + ], +); + +// A message pinned to every judge's screen, or to one room's. One current +// (uncleared) message globally and one per room. +export const judgingAnnouncements = pgTable( + "judging_announcement", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + roomId: uuid("room_id").references(() => judgingRooms.id, { + onDelete: "cascade", + }), + message: text("message").notNull(), + includeGuests: boolean("include_guests").notNull().default(true), + isUrgent: boolean("is_urgent").notNull().default(false), + createdById: text("created_by_id").references(() => users.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + clearedAt: timestamp("cleared_at"), + }, + (table) => [ + uniqueIndex("judging_announcement_global_idx") + .on(table.hackathonId) + .where(sql`${table.roomId} is null and ${table.clearedAt} is null`), + uniqueIndex("judging_announcement_room_idx") + .on(table.roomId) + .where(sql`${table.roomId} is not null and ${table.clearedAt} is null`), + ], +); + +// The rubric. Ratings are 1–5 and always required. A short response has a +// visibility per judge kind: public (shown to the team), optional (the judge +// chooses), private (organisers only). Member responses are always public. +export const judgingRubricItems = pgTable( + "judging_rubric_item", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + kind: text("kind", { enum: ["rating", "response"] }).notNull(), + label: text("label").notNull(), + description: text("description"), + displayOrder: integer("display_order").notNull().default(0), + required: boolean("required").notNull().default(true), + memberVisibility: text("member_visibility", { + enum: ["public", "optional", "private"], + }), + guestVisibility: text("guest_visibility", { + enum: ["public", "optional", "private"], + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [index("judging_rubric_item_hackathon_idx").on(table.hackathonId)], +); + +// A judge's submitted verdict on one project for one challenge. Editable while +// judging is open; every save writes a revision. +export const judgingEvaluations = pgTable( + "judging_evaluation", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + judgeId: uuid("judge_id") + .notNull() + .references(() => judgingJudges.id, { onDelete: "cascade" }), + entryId: uuid("entry_id") + .notNull() + .references(() => judgingEntries.id, { onDelete: "cascade" }), + challengeId: uuid("challenge_id").notNull(), + appointmentId: uuid("appointment_id"), + isComplete: boolean("is_complete").notNull().default(false), + revision: integer("revision").notNull().default(1), + submittedAt: timestamp("submitted_at").defaultNow().notNull(), + // Set when a draft ran past its deadline and was submitted for the judge. + autoSubmittedAt: timestamp("auto_submitted_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("judging_evaluation_task_idx").on( + table.judgeId, + table.entryId, + table.challengeId, + ), + index("judging_evaluation_entry_idx").on(table.entryId, table.challengeId), + index("judging_evaluation_hackathon_idx").on(table.hackathonId), + foreignKey({ + name: "judging_evaluation_challenge_fk", + columns: [table.challengeId], + foreignColumns: [judgingChallenges.id], + }).onDelete("cascade"), + foreignKey({ + name: "judging_evaluation_appointment_fk", + columns: [table.appointmentId], + foreignColumns: [judgingAppointments.id], + }).onDelete("set null"), + ], +); + +export type JudgingDraftRatings = Record; +export type JudgingDraftResponses = Record< + string, + { value: string; isPublic: boolean } +>; + +// Autosaved answers in progress. When `deadlineAt` passes, the next read +// submits it for the judge. +export const judgingEvaluationDrafts = pgTable( + "judging_evaluation_draft", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + judgeId: uuid("judge_id") + .notNull() + .references(() => judgingJudges.id, { onDelete: "cascade" }), + entryId: uuid("entry_id") + .notNull() + .references(() => judgingEntries.id, { onDelete: "cascade" }), + challengeId: uuid("challenge_id").notNull(), + appointmentId: uuid("appointment_id"), + ratings: jsonb("ratings").$type().notNull(), + responses: jsonb("responses").$type().notNull(), + deadlineAt: timestamp("deadline_at"), + revision: integer("revision").notNull().default(0), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("judging_evaluation_draft_task_idx").on( + table.judgeId, + table.entryId, + table.challengeId, + ), + index("judging_evaluation_draft_deadline_idx") + .on(table.hackathonId, table.deadlineAt) + .where(sql`${table.deadlineAt} is not null`), + foreignKey({ + name: "judging_eval_draft_challenge_fk", + columns: [table.challengeId], + foreignColumns: [judgingChallenges.id], + }).onDelete("cascade"), + foreignKey({ + name: "judging_eval_draft_appointment_fk", + columns: [table.appointmentId], + foreignColumns: [judgingAppointments.id], + }).onDelete("set null"), + ], +); + +export const judgingEvaluationRatings = pgTable( + "judging_evaluation_rating", + { + evaluationId: uuid("evaluation_id").notNull(), + itemId: uuid("item_id").notNull(), + // 1–5. + value: integer("value").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.evaluationId, table.itemId] }), + foreignKey({ + name: "judging_eval_rating_evaluation_fk", + columns: [table.evaluationId], + foreignColumns: [judgingEvaluations.id], + }).onDelete("cascade"), + foreignKey({ + name: "judging_eval_rating_item_fk", + columns: [table.itemId], + foreignColumns: [judgingRubricItems.id], + }).onDelete("cascade"), + ], +); + +export const judgingEvaluationResponses = pgTable( + "judging_evaluation_response", + { + evaluationId: uuid("evaluation_id").notNull(), + itemId: uuid("item_id").notNull(), + value: text("value").notNull(), + isPublic: boolean("is_public").notNull().default(false), + }, + (table) => [ + primaryKey({ columns: [table.evaluationId, table.itemId] }), + foreignKey({ + name: "judging_eval_response_evaluation_fk", + columns: [table.evaluationId], + foreignColumns: [judgingEvaluations.id], + }).onDelete("cascade"), + foreignKey({ + name: "judging_eval_response_item_fk", + columns: [table.itemId], + foreignColumns: [judgingRubricItems.id], + }).onDelete("cascade"), + ], +); + +export type JudgingEvaluationSnapshot = { + ratings: JudgingDraftRatings; + responses: JudgingDraftResponses; + isComplete: boolean; + auto: boolean; +}; + +// Every save of an evaluation, for the organisers' audit view. +export const judgingEvaluationRevisions = pgTable( + "judging_evaluation_revision", + { + id: uuid("id").defaultRandom().primaryKey(), + evaluationId: uuid("evaluation_id").notNull(), + revision: integer("revision").notNull(), + snapshot: jsonb("snapshot").$type().notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + index("judging_evaluation_revision_eval_idx").on(table.evaluationId), + foreignKey({ + name: "judging_eval_revision_evaluation_fk", + columns: [table.evaluationId], + foreignColumns: [judgingEvaluations.id], + }).onDelete("cascade"), + ], +); + +// A judge's private shortlist, in sections they name and order. +export const judgingDeliberationSections = pgTable( + "judging_deliberation_section", + { + id: uuid("id").defaultRandom().primaryKey(), + judgeId: uuid("judge_id") + .notNull() + .references(() => judgingJudges.id, { onDelete: "cascade" }), + title: text("title").notNull(), + displayOrder: integer("display_order").notNull().default(0), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [index("judging_deliberation_section_judge_idx").on(table.judgeId)], +); + +export const judgingDeliberationEntries = pgTable( + "judging_deliberation_entry", + { + id: uuid("id").defaultRandom().primaryKey(), + sectionId: uuid("section_id").notNull(), + entryId: uuid("entry_id") + .notNull() + .references(() => judgingEntries.id, { onDelete: "cascade" }), + note: text("note").notNull().default(""), + displayOrder: integer("display_order").notNull().default(0), + }, + (table) => [ + uniqueIndex("judging_deliberation_entry_idx").on( + table.sectionId, + table.entryId, + ), + foreignKey({ + name: "judging_deliberation_entry_section_fk", + columns: [table.sectionId], + foreignColumns: [judgingDeliberationSections.id], + }).onDelete("cascade"), + ], +); + +// Published placings. `scope` is "overall" or a challenge id. A text key +// rather than a nullable challenge column, because Postgres treats NULLs as +// distinct and the unique index would stop holding for the overall ranking. +export const judgingPlacements = pgTable( + "judging_placement", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + entryId: uuid("entry_id") + .notNull() + .references(() => judgingEntries.id, { onDelete: "cascade" }), + scope: text("scope").notNull().default("overall"), + scopeLabel: text("scope_label").notNull().default("Overall"), + placement: integer("placement").notNull(), + // Calibrated for judge bias: what the placing is ranked on. + score: numeric("score", { precision: 4, scale: 2 }), + // The plain mean of the same evaluations. Null on rows computed before + // calibration existed. + rawScore: numeric("raw_score", { precision: 4, scale: 2 }), + evaluationCount: integer("evaluation_count").notNull().default(0), + publishedAt: timestamp("published_at"), + computedAt: timestamp("computed_at").defaultNow().notNull(), + }, + (table) => [ + index("judging_placement_hackathon_idx").on(table.hackathonId), + uniqueIndex("judging_placement_unique_idx").on( + table.hackathonId, + table.scope, + table.entryId, + ), + ], +); + +// Relations + +export const judgingEntriesRelations = relations( + judgingEntries, + ({ one, many }) => ({ + hackathon: one(hackathons, { + fields: [judgingEntries.hackathonId], + references: [hackathons.id], + }), + sourceProject: one(hackathonProjects, { + fields: [judgingEntries.sourceProjectId], + references: [hackathonProjects.id], + }), + members: many(judgingEntryMembers), + challenges: many(judgingEntryChallenges), + appointments: many(judgingAppointments), + evaluations: many(judgingEvaluations), + }), +); + +export const judgingEntryMembersRelations = relations( + judgingEntryMembers, + ({ one }) => ({ + entry: one(judgingEntries, { + fields: [judgingEntryMembers.entryId], + references: [judgingEntries.id], + }), + user: one(users, { + fields: [judgingEntryMembers.userId], + references: [users.id], + }), + claimLink: one(judgingClaimLinks, { + fields: [judgingEntryMembers.id], + references: [judgingClaimLinks.memberId], + }), + }), +); + +export const judgingClaimLinksRelations = relations( + judgingClaimLinks, + ({ one }) => ({ + member: one(judgingEntryMembers, { + fields: [judgingClaimLinks.memberId], + references: [judgingEntryMembers.id], + }), + }), +); + +export const judgingChallengesRelations = relations( + judgingChallenges, + ({ one, many }) => ({ + parent: one(judgingChallenges, { + fields: [judgingChallenges.parentId], + references: [judgingChallenges.id], + relationName: "judging_challenge_parent", + }), + children: many(judgingChallenges, { + relationName: "judging_challenge_parent", + }), + entries: many(judgingEntryChallenges), + rooms: many(judgingRooms), + }), +); + +export const judgingEntryChallengesRelations = relations( + judgingEntryChallenges, + ({ one }) => ({ + entry: one(judgingEntries, { + fields: [judgingEntryChallenges.entryId], + references: [judgingEntries.id], + }), + challenge: one(judgingChallenges, { + fields: [judgingEntryChallenges.challengeId], + references: [judgingChallenges.id], + }), + }), +); + +export const judgingBuildingsRelations = relations( + judgingBuildings, + ({ many }) => ({ rooms: many(judgingRooms) }), +); + +export const judgingRoomsRelations = relations( + judgingRooms, + ({ one, many }) => ({ + challenge: one(judgingChallenges, { + fields: [judgingRooms.challengeId], + references: [judgingChallenges.id], + }), + building: one(judgingBuildings, { + fields: [judgingRooms.buildingId], + references: [judgingBuildings.id], + }), + links: many(judgingRoomLinks), + presence: many(judgingPresence), + appointments: many(judgingAppointments), + }), +); + +export const judgingRoomLinksRelations = relations( + judgingRoomLinks, + ({ one }) => ({ + room: one(judgingRooms, { + fields: [judgingRoomLinks.roomId], + references: [judgingRooms.id], + }), + }), +); + +export const judgingJudgesRelations = relations( + judgingJudges, + ({ one, many }) => ({ + user: one(users, { + fields: [judgingJudges.userId], + references: [users.id], + }), + presence: many(judgingPresence), + evaluations: many(judgingEvaluations), + }), +); + +export const judgingGuestSessionsRelations = relations( + judgingGuestSessions, + ({ one }) => ({ + link: one(judgingRoomLinks, { + fields: [judgingGuestSessions.linkId], + references: [judgingRoomLinks.id], + }), + room: one(judgingRooms, { + fields: [judgingGuestSessions.roomId], + references: [judgingRooms.id], + }), + judge: one(judgingJudges, { + fields: [judgingGuestSessions.judgeId], + references: [judgingJudges.id], + }), + }), +); + +export const judgingPresenceRelations = relations( + judgingPresence, + ({ one }) => ({ + judge: one(judgingJudges, { + fields: [judgingPresence.judgeId], + references: [judgingJudges.id], + }), + room: one(judgingRooms, { + fields: [judgingPresence.roomId], + references: [judgingRooms.id], + }), + }), +); + +export const judgingSchedulesRelations = relations( + judgingSchedules, + ({ many }) => ({ appointments: many(judgingAppointments) }), +); + +export const judgingAppointmentsRelations = relations( + judgingAppointments, + ({ one }) => ({ + schedule: one(judgingSchedules, { + fields: [judgingAppointments.scheduleId], + references: [judgingSchedules.id], + }), + entry: one(judgingEntries, { + fields: [judgingAppointments.entryId], + references: [judgingEntries.id], + }), + challenge: one(judgingChallenges, { + fields: [judgingAppointments.challengeId], + references: [judgingChallenges.id], + }), + room: one(judgingRooms, { + fields: [judgingAppointments.roomId], + references: [judgingRooms.id], + }), + }), +); + +export const judgingEvaluationsRelations = relations( + judgingEvaluations, + ({ one, many }) => ({ + judge: one(judgingJudges, { + fields: [judgingEvaluations.judgeId], + references: [judgingJudges.id], + }), + entry: one(judgingEntries, { + fields: [judgingEvaluations.entryId], + references: [judgingEntries.id], + }), + challenge: one(judgingChallenges, { + fields: [judgingEvaluations.challengeId], + references: [judgingChallenges.id], + }), + appointment: one(judgingAppointments, { + fields: [judgingEvaluations.appointmentId], + references: [judgingAppointments.id], + }), + ratings: many(judgingEvaluationRatings), + responses: many(judgingEvaluationResponses), + revisions: many(judgingEvaluationRevisions), + }), +); + +export const judgingEvaluationRatingsRelations = relations( + judgingEvaluationRatings, + ({ one }) => ({ + evaluation: one(judgingEvaluations, { + fields: [judgingEvaluationRatings.evaluationId], + references: [judgingEvaluations.id], + }), + item: one(judgingRubricItems, { + fields: [judgingEvaluationRatings.itemId], + references: [judgingRubricItems.id], + }), + }), +); + +export const judgingEvaluationResponsesRelations = relations( + judgingEvaluationResponses, + ({ one }) => ({ + evaluation: one(judgingEvaluations, { + fields: [judgingEvaluationResponses.evaluationId], + references: [judgingEvaluations.id], + }), + item: one(judgingRubricItems, { + fields: [judgingEvaluationResponses.itemId], + references: [judgingRubricItems.id], + }), + }), +); + +export const judgingEvaluationRevisionsRelations = relations( + judgingEvaluationRevisions, + ({ one }) => ({ + evaluation: one(judgingEvaluations, { + fields: [judgingEvaluationRevisions.evaluationId], + references: [judgingEvaluations.id], + }), + }), +); + +export const judgingDeliberationSectionsRelations = relations( + judgingDeliberationSections, + ({ many }) => ({ entries: many(judgingDeliberationEntries) }), +); + +export const judgingDeliberationEntriesRelations = relations( + judgingDeliberationEntries, + ({ one }) => ({ + section: one(judgingDeliberationSections, { + fields: [judgingDeliberationEntries.sectionId], + references: [judgingDeliberationSections.id], + }), + entry: one(judgingEntries, { + fields: [judgingDeliberationEntries.entryId], + references: [judgingEntries.id], + }), + }), +); + +export const judgingPlacementsRelations = relations( + judgingPlacements, + ({ one }) => ({ + entry: one(judgingEntries, { + fields: [judgingPlacements.entryId], + references: [judgingEntries.id], + }), + }), +); diff --git a/packages/db/src/schemas/points.ts b/packages/db/src/schemas/points.ts new file mode 100644 index 00000000..f531e56a --- /dev/null +++ b/packages/db/src/schemas/points.ts @@ -0,0 +1,161 @@ +import { + pgTable, + text, + timestamp, + uuid, + boolean, + integer, + index, + foreignKey, +} from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { users } from "./auth"; +import { hackathons, hackathonParticipants } from "./hackathons"; + +// Hackathon points, scoped to one edition. Nothing here is a balance column: +// +// earned = sum(hackathon_event_attendee.points_awarded) +// + sum(hackathon_point_adjustment.delta) +// spent = sum(hackathon_store_purchase.total) where voided_at is null +// available = max(0, earned - spent) +// +// so every point on a hacker's total has a row that explains it, and undoing +// a check-in or voiding a purchase needs no second write to keep a running +// total honest. The API computes these (services/points.ts). + +// An organiser adding or taking away points by hand, with the reason. +export const hackathonPointAdjustments = pgTable( + "hackathon_point_adjustment", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + participantId: uuid("participant_id").notNull(), + // Non-zero, -1000 to 1000. Checked by the API. + delta: integer("delta").notNull(), + reason: text("reason").notNull(), + actorId: text("actor_id").references(() => users.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + index("point_adjustment_participant_idx").on( + table.hackathonId, + table.participantId, + ), + foreignKey({ + name: "point_adjustment_participant_fk", + columns: [table.participantId], + foreignColumns: [hackathonParticipants.id], + }).onDelete("cascade"), + ], +); + +// Something on the store table. `stock` null means nobody is counting; then +// `soldOut` is how an organiser says it is gone. +export const hackathonStoreItems = pgTable( + "hackathon_store_item", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + name: text("name").notNull(), + description: text("description"), + price: integer("price").notNull(), + stock: integer("stock"), + soldOut: boolean("sold_out").notNull().default(false), + archived: boolean("archived").notNull().default(false), + displayOrder: integer("display_order").notNull().default(0), + // Optimistic lock for two organisers editing the same item. + revision: integer("revision").notNull().default(0), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [index("store_item_hackathon_idx").on(table.hackathonId)], +); + +// An organiser handing something over at the store table. The id comes from +// the organiser's browser, so a retried request finds the purchase it already +// made instead of charging twice. Names and prices are copied in, so editing +// or archiving an item never rewrites what somebody paid. +export const hackathonStorePurchases = pgTable( + "hackathon_store_purchase", + { + id: uuid("id").primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + participantId: uuid("participant_id"), + participantName: text("participant_name").notNull(), + itemId: uuid("item_id"), + itemName: text("item_name").notNull(), + unitPrice: integer("unit_price").notNull(), + quantity: integer("quantity").notNull(), + // unitPrice * quantity, written by the API. + total: integer("total").notNull(), + stockTracked: boolean("stock_tracked").notNull(), + actorId: text("actor_id").references(() => users.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + voidedAt: timestamp("voided_at"), + voidedById: text("voided_by_id").references(() => users.id, { + onDelete: "set null", + }), + voidReason: text("void_reason"), + restocked: boolean("restocked").notNull().default(false), + }, + (table) => [ + index("store_purchase_hackathon_idx").on( + table.hackathonId, + table.createdAt, + ), + index("store_purchase_participant_idx").on(table.participantId), + foreignKey({ + name: "store_purchase_participant_fk", + columns: [table.participantId], + foreignColumns: [hackathonParticipants.id], + }).onDelete("set null"), + foreignKey({ + name: "store_purchase_item_fk", + columns: [table.itemId], + foreignColumns: [hackathonStoreItems.id], + }).onDelete("set null"), + ], +); + +export const hackathonPointAdjustmentsRelations = relations( + hackathonPointAdjustments, + ({ one }) => ({ + participant: one(hackathonParticipants, { + fields: [hackathonPointAdjustments.participantId], + references: [hackathonParticipants.id], + }), + actor: one(users, { + fields: [hackathonPointAdjustments.actorId], + references: [users.id], + }), + }), +); + +export const hackathonStoreItemsRelations = relations( + hackathonStoreItems, + ({ many }) => ({ purchases: many(hackathonStorePurchases) }), +); + +export const hackathonStorePurchasesRelations = relations( + hackathonStorePurchases, + ({ one }) => ({ + participant: one(hackathonParticipants, { + fields: [hackathonStorePurchases.participantId], + references: [hackathonParticipants.id], + }), + item: one(hackathonStoreItems, { + fields: [hackathonStorePurchases.itemId], + references: [hackathonStoreItems.id], + }), + }), +); diff --git a/packages/db/src/schemas/retired.ts b/packages/db/src/schemas/retired.ts new file mode 100644 index 00000000..617893fd --- /dev/null +++ b/packages/db/src/schemas/retired.ts @@ -0,0 +1,49 @@ +import { pgEnum } from "drizzle-orm/pg-core"; + +// Types left behind by the retired panel judging service, which created them +// with its own SQL migrations in this database. Nothing uses them. They stay +// declared because tablesFilter in drizzle.config.ts scopes tables, not types: +// undeclared, push proposes DROP TYPE on any database that has them, and that +// prompt stops every deploy. Declared where absent, push only creates them. +// The panel's tables are excluded in drizzle.config.ts for the same reason. + +export const retiredEventPhase = pgEnum("event_phase", [ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", +]); + +export const retiredTrackKind = pgEnum("track_kind", [ + "main", + "sponsor", + "special", +]); + +export const retiredDispatchStrategy = pgEnum("dispatch_strategy", [ + "coverage", + "uncertainty", +]); + +export const retiredJudgeStatus = pgEnum("judge_status", [ + "invited", + "applied", + "approved", + "suspended", +]); + +export const retiredComparisonOutcome = pgEnum("comparison_outcome", [ + "a", + "b", + "tie", +]); + +export const retiredMembershipRole = pgEnum("membership_role", [ + "owner", + "admin", + "organizer", + "volunteer", +]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8907bf4d..710e73af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,27 +19,28 @@ overrides: vite: ^7.3.5 brace-expansion: ^5.0.9 gaxios>uuid: ^11.1.1 + source-map-js: ^1.2.2 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) + specifier: 16.4.0 + version: 16.4.0(@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 devDependencies: turbo: - specifier: ^2.11.3 - version: 2.11.3 + specifier: ^2.11.7 + version: 2.11.7 vite: specifier: ^7.3.5 version: 7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) 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)) + specifier: ^5.0.3 + version: 5.0.3(@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: @@ -57,7 +58,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.4.0(@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) @@ -74,11 +75,11 @@ importers: specifier: 15.1.3 version: 15.1.3 sanitize-html: - specifier: 2.17.7 - version: 2.17.7 + specifier: 2.18.1 + version: 2.18.1 stripe: - specifier: ^22.6.2 - version: 22.6.2(@types/node@22.20.4) + specifier: ^23.0.0 + version: 23.0.0(@types/node@22.20.4) superjson: specifier: 2.2.6 version: 2.2.6 @@ -90,8 +91,8 @@ importers: specifier: workspace:* version: link:../../tooling/typescript '@types/sanitize-html': - specifier: ^2.16.0 - version: 2.16.1 + specifier: ^2.16.2 + version: 2.16.2 typescript: specifier: 7.0.2 version: 7.0.2 @@ -99,8 +100,8 @@ importers: specifier: ^7.3.5 version: 7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) 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)) + specifier: ^5.0.3 + version: 5.0.3(@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/auth: dependencies: @@ -115,7 +116,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.4.0(@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 @@ -127,8 +128,8 @@ importers: specifier: ^22.20.4 version: 22.20.4 '@typescript-eslint/eslint-plugin': - specifier: ^8.70.1 - version: 8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + specifier: ^8.71.0 + version: 8.71.0(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) '@typescript-eslint/parser': specifier: ^8.70.1 version: 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) @@ -155,7 +156,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.4.0(@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 @@ -173,8 +174,8 @@ importers: specifier: ^8.23.1 version: 8.23.1 dotenv: - specifier: ^18.0.3 - version: 18.0.3 + specifier: ^18.0.5 + version: 18.0.5 drizzle-kit: specifier: 0.31.11 version: 0.31.11 @@ -231,8 +232,8 @@ importers: specifier: 1.0.0 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) + specifier: 16.4.0 + version: 16.4.0(@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 @@ -311,7 +312,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.4.0(@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) @@ -332,7 +333,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.4.0(@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) @@ -340,11 +341,11 @@ importers: specifier: 10.2.6 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) + specifier: 16.4.0 + version: 16.4.0(@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)(@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.4.0(@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) @@ -370,11 +371,11 @@ importers: specifier: 1.9.3 version: 1.9.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) sanitize-html: - specifier: ^2.17.7 - version: 2.17.7 + specifier: ^2.18.1 + version: 2.18.1 stripe: - specifier: ^22.6.2 - version: 22.6.2(@types/node@22.20.4) + specifier: ^23.0.0 + version: 23.0.0(@types/node@22.20.4) superjson: specifier: ^2.2.6 version: 2.2.6 @@ -434,8 +435,8 @@ importers: specifier: 4.3.3 version: 4.3.3 turbo: - specifier: ^2.11.3 - version: 2.11.3 + specifier: ^2.11.7 + version: 2.11.7 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -455,8 +456,8 @@ importers: specifier: 16.3.7 version: 16.3.7(eslint@10.11.0(jiti@2.7.0)) '@typescript-eslint/eslint-plugin': - specifier: 8.70.1 - version: 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) + specifier: 8.71.0 + version: 8.71.0(@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) '@typescript-eslint/parser': specifier: 8.70.1 version: 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) @@ -476,11 +477,11 @@ importers: specifier: 7.1.1 version: 7.1.1(eslint@10.11.0(jiti@2.7.0)) eslint-plugin-turbo: - specifier: ^2.11.3 - version: 2.11.3(eslint@10.11.0(jiti@2.7.0))(turbo@2.11.3) + specifier: ^2.11.7 + version: 2.11.7(eslint@10.11.0(jiti@2.7.0))(turbo@2.11.7) globals: - specifier: 17.12.0 - version: 17.12.0 + specifier: 17.13.0 + version: 17.13.0 minimatch: specifier: 10.2.6 version: 10.2.6 @@ -532,8 +533,8 @@ importers: specifier: 4.3.3 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) + specifier: 16.4.0 + version: 16.4.0(@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 @@ -1004,160 +1005,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -1172,9 +1173,6 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} @@ -1209,60 +1207,60 @@ packages: '@next/bundle-analyzer@16.3.7': resolution: {integrity: sha512-c2xuhGaPXa8R6BSOi6qWxPQIAV7Kip4K3iNYt2rm7XB04Adnvx9oMzanmYjZBUfu5BzI8wNz4A/tQBK2O8ELHw==} - '@next/env@16.3.7': - resolution: {integrity: sha512-/HuhBN1CorqNTmewTIh81yXOQri6B8Ye/c9D7+830XqwVaf8qMnJNrgNxDn6dLtihq8YzBbI6NSAuMKvpr+DvQ==} + '@next/env@16.4.0': + resolution: {integrity: sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==} '@next/eslint-plugin-next@16.3.7': resolution: {integrity: sha512-ZQwUKBxqKAasarxLHJkkIv0cuG80pJ7TKvCUcFvlKewkuzYkL+Lsxb++DQlcBnY4uT0kOazDye4j1pyDPlDcDQ==} - '@next/swc-darwin-arm64@16.3.7': - resolution: {integrity: sha512-MDAd3woxfJOVFtfG2VAuaz5zyyVZTMVUPJAknotCNgeEPrPlRBlcV9YSd8S620P6TiSc2MWpfwO/UAHl9EWRug==} + '@next/swc-darwin-arm64@16.4.0': + resolution: {integrity: sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.7': - resolution: {integrity: sha512-Usd86QilBWl2G7JxfWskV9NLAxacVRrDDQ3WQIoPKRB0MXiVuVSdyu3wt5EA8SyjQpaVk/7VOzSkRB4LgJltyA==} + '@next/swc-darwin-x64@16.4.0': + resolution: {integrity: sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.7': - resolution: {integrity: sha512-pVauSs1WomgtBgfJj/Q+846nBix4CdohLUIDafyJGdAvNw5kOusLawoX+rpc2P4U3Kd7tLwqFXsnHeSkLZrqyw==} + '@next/swc-linux-arm64-gnu@16.4.0': + resolution: {integrity: sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.7': - resolution: {integrity: sha512-vY+iamd6cOfk29bGTgxeS/OXlrlfOfyFgyd34cibC7e25bXRMxuqNUfs2Z9Fxb/VHpV5dopffMSWnCeR2tc8rA==} + '@next/swc-linux-arm64-musl@16.4.0': + resolution: {integrity: sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.7': - resolution: {integrity: sha512-NWx0LRZ9IO9rTDXLc+Hi/bNcTKblT86du4OLMRm5Z62C2T5/+mNEhK2XeQxwoSmVFvU2rjMSjJMK4ttZAk88+w==} + '@next/swc-linux-x64-gnu@16.4.0': + resolution: {integrity: sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.7': - resolution: {integrity: sha512-v5Dk/iMB4JyQZwQ+UWOZGGAqA4HjkBewKwaCkXi5DqL1Dy1TjXpRmAh8N7VOuID0hLmouICSXozJrMBBG1Xeyw==} + '@next/swc-linux-x64-musl@16.4.0': + resolution: {integrity: sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.7': - resolution: {integrity: sha512-G4BkB7AhfKJnaoIpIkoA66l5pXPGJXD3EF1EsuYj/sgM4rHA41dsa3CIIqjR4FQFZEjU+zGQRbIEPYZIOi5EoA==} + '@next/swc-win32-arm64-msvc@16.4.0': + resolution: {integrity: sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.7': - resolution: {integrity: sha512-DuvRhf50tGU7leT9Ow0cs/9lo81X5lHdNoxSRk4ckSk2Ihn2dsM+BEe5j7ANpEFhD25p//SmV8ajdRtuaBIg3g==} + '@next/swc-win32-x64-msvc@16.4.0': + resolution: {integrity: sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2101,33 +2099,33 @@ packages: peerDependencies: typescript: '>=5.7.2' - '@turbo/darwin-64@2.11.3': - resolution: {integrity: sha512-D4EIKTgPpxFQL5aZNbNqy57XOiRldM+mwP1ME9ZLM4DJoV3oNK7GKyQWVusz1umjiFNmLvunNGw7Jq4RUyYL3A==} + '@turbo/darwin-64@2.11.7': + resolution: {integrity: sha512-Z2LFOxwbiJueD1miByXJS/BrW21vv6YaQXZustty5jEX4vQYw6zyh101Sz1QO7WG0mqBzKagZ/DoAeeujI0/Sw==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.11.3': - resolution: {integrity: sha512-WOvgWIO86LzBQmL9aRFekROing1CZMVDjmdD7Dk6R65qFAFdrlKkufojenjW1AbUAqytpG6xsXzq8UEScdSdDQ==} + '@turbo/darwin-arm64@2.11.7': + resolution: {integrity: sha512-Vhv8ybgIflosNDSSRD9fNXHlCFA60dsGyTqQmc7l28SEX5jOaweCCD4BBYNd8/p0WGkw3e7tZbfA9w6FsujGvw==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.11.3': - resolution: {integrity: sha512-zIgNl9KkMnsdU54JrnazELatpMJDSktKjD3kjKVliUNMl/5uhFRrqQ+Z1qaACKw/2ybL7xzlsHir9s5q7sYlQg==} + '@turbo/linux-64@2.11.7': + resolution: {integrity: sha512-rAqRp3blQKk1+ylMGlZHAW0eJjVNuuGUm4ZrC6RU1Im8tLryvaO4a2zLQ/FQi1TRlmKqKV9YXBki2YeytlLbXA==} cpu: [x64] os: [android, linux] - '@turbo/linux-arm64@2.11.3': - resolution: {integrity: sha512-aJToWHx5JYvpPwIrAt1DuYrHWcpQGjGf0XVPRMKQBHou//JUYR6oowHeUZp9Oq7e3XDWA75E37C2GnNaBqCVnA==} + '@turbo/linux-arm64@2.11.7': + resolution: {integrity: sha512-thVYGov7S+llXAxrl3kBb/efalav0xhrjl5NHCR6AfKx14JkbqaetvbVwyss4FvZ9rt+Pv3xsTb+8SIYix8DTg==} cpu: [arm64] os: [android, linux] - '@turbo/windows-64@2.11.3': - resolution: {integrity: sha512-hu5biiwVGdMvHB/vvaoPjtudNBn3TqcsKK2A06ap3O0wnSZwAtTXe92tKTckpecDeVs+IC+4r8s2wXpbdSPWoA==} + '@turbo/windows-64@2.11.7': + resolution: {integrity: sha512-ZIoKT7J7KutgYl4bGxmmgeUQjG+7E0nY8BF9dxUOiMH2vdTsM4JQ2uSwTYFkTR2Llbg6ghGjSmuZ8LQtkLEYjg==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.11.3': - resolution: {integrity: sha512-naDEb/31UmOOJOChi+LebLMKWKa1kId0yig1yTRZzDViDg1lBw1Ptu5iQYCvOfOOrx6swUUucf3zPWuPGWz7Jw==} + '@turbo/windows-arm64@2.11.7': + resolution: {integrity: sha512-88Xv2f7SLGVJ68lRRx8o375dKmnZYNdDtvlKcPA0kZpcF93eAQd8l96BKQ0/AMHG0bMJA0Z5QqoqtetxzNsC+g==} cpu: [arm64] os: [win32] @@ -2181,8 +2179,8 @@ packages: '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} - '@types/sanitize-html@2.16.1': - resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} + '@types/sanitize-html@2.16.2': + resolution: {integrity: sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA==} '@types/stats.js@0.17.4': resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} @@ -2201,6 +2199,14 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/eslint-plugin@8.71.0': + resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.71.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.70.1': resolution: {integrity: sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2214,16 +2220,32 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.71.0': + resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.70.1': resolution: {integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.71.0': + resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.70.1': resolution: {integrity: sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/tsconfig-utils@8.71.0': + resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.70.1': resolution: {integrity: sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2231,16 +2253,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.71.0': + resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.70.1': resolution: {integrity: sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.71.0': + resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.70.1': resolution: {integrity: sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.71.0': + resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.70.1': resolution: {integrity: sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2248,10 +2287,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.71.0': + resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.70.1': resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.71.0': + resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -2372,8 +2422,8 @@ packages: cpu: [x64] os: [win32] - '@vitest/mocker@5.0.1': - resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==} + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} peerDependencies: msw: ^2.4.9 vite: ^7.3.5 @@ -2383,8 +2433,8 @@ packages: vite: optional: true - '@vitest/spy@5.0.1': - resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==} + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} '@yudiel/react-qr-scanner@2.6.0': resolution: {integrity: sha512-WjNcSt7qquYUz1LVbwKzSnRQI47sNrh4Vh6nNiGKVxnYcP+KgN5q9rcSzXyZ/7GRV9PPrnzoIuE5YdOxB9yAog==} @@ -2630,8 +2680,11 @@ packages: caniuse-lite@1.0.30001812: resolution: {integrity: sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==} - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + caniuse-lite@1.0.30001815: + resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} chart.js@4.5.1: @@ -2716,8 +2769,8 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} - dayjs@1.11.20: - resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} @@ -2746,10 +2799,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -2777,31 +2826,18 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - dom-serializer@2.0.0: - resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - dom-serializer@3.1.1: resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} engines: {node: '>=20.19.0'} - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - domelementtype@3.0.0: resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} engines: {node: '>=20.19.0'} - domhandler@5.0.3: - resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} - engines: {node: '>= 4'} - domhandler@6.0.1: resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} engines: {node: '>=20.19.0'} - domutils@3.2.2: - resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - domutils@4.0.2: resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} engines: {node: '>=20.19.0'} @@ -2810,8 +2846,8 @@ packages: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} - dotenv@18.0.3: - resolution: {integrity: sha512-/8CJTyL817qPnfbQrJgOEcrU3fDrJkRIoXBE566wHuvIXKOCHUZhPqGPbCPi9SogjdGnreF0TlvnTgK6J/8zpA==} + dotenv@18.0.5: + resolution: {integrity: sha512-aBrGvt6KhjxbEnatqMWOMidftwsrCiCvqh1yNIGB9QyF0J+VcWkKDMsvYOEm7ZS82oiV+BT1sTOtIlAmUlTpwQ==} engines: {node: '>=12'} hasBin: true @@ -2943,14 +2979,6 @@ packages: resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==} engines: {node: '>=10.13.0'} - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} - - entities@7.0.1: - resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} - engines: {node: '>=0.12'} - entities@8.1.0: resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} engines: {node: '>=20.19.0'} @@ -3065,8 +3093,8 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - eslint-plugin-turbo@2.11.3: - resolution: {integrity: sha512-moidquRNi1eEcWdc//TilHc1EPOWm0ADNnEaIOuUyqOa6vxLy0DTf9ByRKYgnNE0dt+UB3V/gtJfilxCVaeZag==} + eslint-plugin-turbo@2.11.7: + resolution: {integrity: sha512-vFjI/0qxA9DFTMAOLhyVo7lFGh5kZ0wrWk+5tsQoafGPWqzTNmIMAjk7hsKLQGHOvLz42y8YIvrevHCbthFuZw==} peerDependencies: eslint: '>6.6.0' turbo: '>2.0.0' @@ -3289,8 +3317,8 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - globals@17.12.0: - resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} + globals@17.13.0: + resolution: {integrity: sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==} engines: {node: '>=18'} globalthis@1.0.4: @@ -3369,9 +3397,6 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - htmlparser2@10.1.0: - resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} - htmlparser2@12.0.0: resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} engines: {node: '>=20.19.0'} @@ -3391,8 +3416,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.10: - resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} + ignore@7.0.12: + resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} engines: {node: '>= 4'} image-size@2.0.4: @@ -3479,10 +3504,6 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-plain-object@5.0.0: - resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} - engines: {node: '>=0.10.0'} - is-plain-object@5.1.0: resolution: {integrity: sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==} engines: {node: '>=0.10.0'} @@ -3600,8 +3621,8 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} - launder@1.7.1: - resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==} + launder@1.7.2: + resolution: {integrity: sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==} lazystream@1.0.1: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} @@ -3711,8 +3732,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magic-string@1.4.2: - resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} @@ -3766,6 +3787,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3791,8 +3817,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.3.7: - resolution: {integrity: sha512-S4AlB0KMYcvVyEVjfD2Ze/3JsX9PWjpD3hJPcEJTVvkAQsMTYVs9DB1NtaBbs41ZZ18NxYwkmM8ljqtrFrmLsQ==} + next@16.4.0: + resolution: {integrity: sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -4007,6 +4033,10 @@ packages: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.30: + resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -4265,8 +4295,8 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} - sanitize-html@2.17.7: - resolution: {integrity: sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==} + sanitize-html@2.18.1: + resolution: {integrity: sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==} engines: {node: '>=22.12.0'} scheduler@0.28.0: @@ -4304,8 +4334,8 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -4337,15 +4367,12 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - sirv@2.0.4: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} source-map-support@0.5.21: @@ -4359,11 +4386,8 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@4.2.0: - resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} @@ -4419,11 +4443,11 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - stripe@22.6.2: - resolution: {integrity: sha512-PwRE2scocvXqDKPiskMa5vRJEAEkq46W69XyUYmAwrNSLr675lQnfqLwnsndAWizwgihOWw/irLmoFRo3ez7ew==} - engines: {node: '>=18'} + stripe@23.0.0: + resolution: {integrity: sha512-MDE2A5ZVVluYURORaZEVghSmnLJc3w6aHAlZSxDQMsOjOb3V0O6ro/CiaeFlJS4N7uIVmsg98ocRsPmUK6YfyA==} + engines: {node: '>=20'} peerDependencies: - '@types/node': '>=18' + '@types/node': '>=20' peerDependenciesMeta: '@types/node': optional: true @@ -4485,12 +4509,12 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} - tinybench@6.1.4: - resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} engines: {node: '>=20.0.0'} - tinyexec@1.3.0: - resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} tinyglobby@0.2.17: @@ -4528,8 +4552,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo@2.11.3: - resolution: {integrity: sha512-Pfg2dWNOUPwJYbR+eYAcjoBbo4Kmoc8uQb80hzKrBgNEQtxvwf2ucXG7C28MyLVhV/zJSKvChrR+2CP/BONpoA==} + turbo@2.11.7: + resolution: {integrity: sha512-RhXHbqfvY6o3xt/zSHCwSNwLrZlXiqHzewi4eAj9PtZ1T1CqgMJ2yIDb7SKPTG9IikTm925mfzL6hldQVdyNWw==} hasBin: true type-check@0.4.0: @@ -4656,20 +4680,20 @@ packages: yaml: optional: true - vitest@5.0.1: - resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==} + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.1 - '@vitest/browser-preview': 5.0.1 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.1 - '@vitest/coverage-v8': 5.0.1 - '@vitest/ui': 5.0.1 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 happy-dom: '*' jsdom: '*' vite: ^7.3.5 @@ -4740,9 +4764,9 @@ packages: engines: {node: '>= 8'} hasBin: true - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} hasBin: true word-wrap@1.2.5: @@ -4769,7 +4793,7 @@ packages: optional: true xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: - resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} + resolution: {tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} version: 0.20.3 engines: {node: '>=0.8'} hasBin: true @@ -5222,108 +5246,108 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-ia32@0.35.4': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.35.5': optional: true '@jridgewell/gen-mapping@0.3.13': @@ -5338,8 +5362,6 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': @@ -5388,7 +5410,7 @@ snapshots: - bufferutil - utf-8-validate - '@next/env@16.3.7': {} + '@next/env@16.4.0': {} '@next/eslint-plugin-next@16.3.7(eslint@10.11.0(jiti@2.7.0))': dependencies: @@ -5397,28 +5419,28 @@ snapshots: transitivePeerDependencies: - eslint - '@next/swc-darwin-arm64@16.3.7': + '@next/swc-darwin-arm64@16.4.0': optional: true - '@next/swc-darwin-x64@16.3.7': + '@next/swc-darwin-x64@16.4.0': optional: true - '@next/swc-linux-arm64-gnu@16.3.7': + '@next/swc-linux-arm64-gnu@16.4.0': optional: true - '@next/swc-linux-arm64-musl@16.3.7': + '@next/swc-linux-arm64-musl@16.4.0': optional: true - '@next/swc-linux-x64-gnu@16.3.7': + '@next/swc-linux-x64-gnu@16.4.0': optional: true - '@next/swc-linux-x64-musl@16.3.7': + '@next/swc-linux-x64-musl@16.4.0': optional: true - '@next/swc-win32-arm64-msvc@16.3.7': + '@next/swc-win32-arm64-msvc@16.4.0': optional: true - '@next/swc-win32-x64-msvc@16.3.7': + '@next/swc-win32-x64-msvc@16.4.0': optional: true '@nodable/entities@3.0.0': {} @@ -6028,7 +6050,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 - source-map-js: 1.2.1 + source-map-js: 1.2.2 tailwindcss: 4.3.3 '@tailwindcss/oxide-android-arm64@4.3.3': @@ -6102,11 +6124,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.4.0(@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)(@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.4.0(@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 @@ -6126,22 +6148,22 @@ snapshots: dependencies: typescript: 7.0.2 - '@turbo/darwin-64@2.11.3': + '@turbo/darwin-64@2.11.7': optional: true - '@turbo/darwin-arm64@2.11.3': + '@turbo/darwin-arm64@2.11.7': optional: true - '@turbo/linux-64@2.11.3': + '@turbo/linux-64@2.11.7': optional: true - '@turbo/linux-arm64@2.11.3': + '@turbo/linux-arm64@2.11.7': optional: true - '@turbo/windows-64@2.11.3': + '@turbo/windows-64@2.11.7': optional: true - '@turbo/windows-arm64@2.11.3': + '@turbo/windows-arm64@2.11.7': optional: true '@tweenjs/tween.js@23.1.3': {} @@ -6198,9 +6220,9 @@ snapshots: dependencies: '@types/node': 22.20.4 - '@types/sanitize-html@2.16.1': + '@types/sanitize-html@2.16.2': dependencies: - htmlparser2: 10.1.0 + htmlparser2: 12.0.0 '@types/stats.js@0.17.4': {} @@ -6224,7 +6246,7 @@ snapshots: '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) '@typescript-eslint/visitor-keys': 8.70.1 eslint: 10.11.0(jiti@2.7.0) - ignore: 7.0.10 + ignore: 7.0.12 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 @@ -6240,7 +6262,39 @@ snapshots: '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) '@typescript-eslint/visitor-keys': 8.70.1 eslint: 10.11.0(jiti@2.7.0) - ignore: 7.0.10 + ignore: 7.0.12 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@7.0.2) + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.71.0(@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 + '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/type-utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.71.0 + eslint: 10.11.0(jiti@2.7.0) + ignore: 7.0.12 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/type-utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + '@typescript-eslint/visitor-keys': 8.71.0 + eslint: 10.11.0(jiti@2.7.0) + ignore: 7.0.12 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@7.0.2) typescript: 7.0.2 @@ -6274,7 +6328,7 @@ snapshots: '@typescript-eslint/project-service@8.70.1(typescript@6.0.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@6.0.2) - '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/types': 8.71.0 debug: 4.4.3 typescript: 6.0.2 transitivePeerDependencies: @@ -6283,7 +6337,25 @@ snapshots: '@typescript-eslint/project-service@8.70.1(typescript@7.0.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@7.0.2) - '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/types': 8.71.0 + debug: 4.4.3 + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.71.0(typescript@6.0.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.2) + '@typescript-eslint/types': 8.71.0 + debug: 4.4.3 + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.71.0(typescript@7.0.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@7.0.2) + '@typescript-eslint/types': 8.71.0 debug: 4.4.3 typescript: 7.0.2 transitivePeerDependencies: @@ -6294,6 +6366,11 @@ snapshots: '@typescript-eslint/types': 8.70.1 '@typescript-eslint/visitor-keys': 8.70.1 + '@typescript-eslint/scope-manager@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/tsconfig-utils@8.70.1(typescript@6.0.2)': dependencies: typescript: 6.0.2 @@ -6302,6 +6379,14 @@ snapshots: dependencies: typescript: 7.0.2 + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@6.0.2)': + dependencies: + typescript: 6.0.2 + + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@7.0.2)': + dependencies: + typescript: 7.0.2 + '@typescript-eslint/type-utils@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': dependencies: '@typescript-eslint/types': 8.70.1 @@ -6326,8 +6411,34 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@6.0.2) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) + debug: 4.4.3 + eslint: 10.11.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2)': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@7.0.2) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + debug: 4.4.3 + eslint: 10.11.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@7.0.2) + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.70.1': {} + '@typescript-eslint/types@8.71.0': {} + '@typescript-eslint/typescript-estree@8.70.1(typescript@6.0.2)': dependencies: '@typescript-eslint/project-service': 8.70.1(typescript@6.0.2) @@ -6358,6 +6469,36 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.71.0(typescript@6.0.2)': + dependencies: + '@typescript-eslint/project-service': 8.71.0(typescript@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.2) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.71.0(typescript@7.0.2)': + dependencies: + '@typescript-eslint/project-service': 8.71.0(typescript@7.0.2) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@7.0.2) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@7.0.2) + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) @@ -6380,11 +6521,38 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@6.0.2) + eslint: 10.11.0(jiti@2.7.0) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@7.0.2) + eslint: 10.11.0(jiti@2.7.0) + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.70.1': dependencies: '@typescript-eslint/types': 8.70.1 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + eslint-visitor-keys: 5.0.1 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -6445,16 +6613,16 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/mocker@5.0.1(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15))': + '@vitest/mocker@5.0.3(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15))': dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.1 + '@vitest/spy': 5.0.3 estree-walker: 3.0.3 - magic-string: 1.4.2 + magic-string: 1.4.3 optionalDependencies: vite: 7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) - '@vitest/spy@5.0.1': {} + '@vitest/spy@5.0.3': {} '@yudiel/react-qr-scanner@2.6.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: @@ -6683,7 +6851,7 @@ snapshots: browserslist@4.29.1: dependencies: baseline-browser-mapping: 2.11.26 - caniuse-lite: 1.0.30001812 + caniuse-lite: 1.0.30001815 electron-to-chromium: 1.5.438 node-releases: 2.0.57 update-browserslist-db: 1.3.3(browserslist@4.29.1) @@ -6728,7 +6896,9 @@ snapshots: caniuse-lite@1.0.30001812: {} - chai@6.2.2: {} + caniuse-lite@1.0.30001815: {} + + chai@6.3.0: {} chart.js@4.5.1: dependencies: @@ -6812,7 +6982,7 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - dayjs@1.11.20: {} + dayjs@1.11.23: {} debounce@1.2.1: {} @@ -6828,8 +6998,6 @@ snapshots: deep-is@0.1.4: {} - deepmerge@4.3.1: {} - define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -6854,36 +7022,18 @@ snapshots: dependencies: esutils: 2.0.3 - dom-serializer@2.0.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - entities: 4.5.0 - dom-serializer@3.1.1: dependencies: domelementtype: 3.0.0 domhandler: 6.0.1 entities: 8.1.0 - domelementtype@2.3.0: {} - domelementtype@3.0.0: {} - domhandler@5.0.3: - dependencies: - domelementtype: 2.3.0 - domhandler@6.0.1: dependencies: domelementtype: 3.0.0 - domutils@3.2.2: - dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils@4.0.2: dependencies: dom-serializer: 3.1.1 @@ -6892,7 +7042,7 @@ snapshots: dotenv@16.0.3: {} - dotenv@18.0.3: {} + dotenv@18.0.5: {} drizzle-kit@0.31.11: dependencies: @@ -6942,10 +7092,6 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 - entities@4.5.0: {} - - entities@7.0.1: {} - entities@8.1.0: {} es-abstract@1.24.2: @@ -7191,11 +7337,11 @@ snapshots: string.prototype.matchall: 4.1.0 string.prototype.repeat: 1.0.0 - eslint-plugin-turbo@2.11.3(eslint@10.11.0(jiti@2.7.0))(turbo@2.11.3): + eslint-plugin-turbo@2.11.7(eslint@10.11.0(jiti@2.7.0))(turbo@2.11.7): dependencies: dotenv: 16.0.3 eslint: 10.11.0(jiti@2.7.0) - turbo: 2.11.3 + turbo: 2.11.7 eslint-scope@9.1.2: dependencies: @@ -7407,9 +7553,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.4.0(@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)(@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.4.0(@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: {} @@ -7455,7 +7601,7 @@ snapshots: dependencies: is-glob: 4.0.3 - globals@17.12.0: {} + globals@17.13.0: {} globalthis@1.0.4: dependencies: @@ -7534,13 +7680,6 @@ snapshots: html-escaper@2.0.2: {} - htmlparser2@10.1.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - entities: 7.0.1 - htmlparser2@12.0.0: dependencies: domelementtype: 3.0.0 @@ -7566,7 +7705,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.10: {} + ignore@7.0.12: {} image-size@2.0.4: {} @@ -7651,8 +7790,6 @@ snapshots: is-number@7.0.0: {} - is-plain-object@5.0.0: {} - is-plain-object@5.1.0: {} is-regex@1.2.1: @@ -7765,9 +7902,9 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 - launder@1.7.1: + launder@1.7.2: dependencies: - dayjs: 1.11.20 + dayjs: 1.11.23 lazystream@1.0.1: dependencies: @@ -7851,9 +7988,9 @@ snapshots: magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - magic-string@1.4.2: + magic-string@1.4.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -7894,12 +8031,14 @@ snapshots: nanoid@3.3.19: {} + nanoid@3.3.20: {} + 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.4.0(@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)(@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.4.0(@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 @@ -7909,28 +8048,28 @@ 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.4.0(@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 + '@next/env': 16.4.0 '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.26 - caniuse-lite: 1.0.30001812 + caniuse-lite: 1.0.30001815 postcss: 8.5.28 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) styled-jsx: 5.1.6(react@19.3.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.7 - '@next/swc-darwin-x64': 16.3.7 - '@next/swc-linux-arm64-gnu': 16.3.7 - '@next/swc-linux-arm64-musl': 16.3.7 - '@next/swc-linux-x64-gnu': 16.3.7 - '@next/swc-linux-x64-musl': 16.3.7 - '@next/swc-win32-arm64-msvc': 16.3.7 - '@next/swc-win32-x64-msvc': 16.3.7 + '@next/swc-darwin-arm64': 16.4.0 + '@next/swc-darwin-x64': 16.4.0 + '@next/swc-linux-arm64-gnu': 16.4.0 + '@next/swc-linux-arm64-musl': 16.4.0 + '@next/swc-linux-x64-gnu': 16.4.0 + '@next/swc-linux-x64-musl': 16.4.0 + '@next/swc-win32-arm64-msvc': 16.4.0 + '@next/swc-win32-x64-msvc': 16.4.0 '@opentelemetry/api': 1.9.1 babel-plugin-react-compiler: 1.0.0 - sharp: 0.35.4(@types/node@22.20.4) + sharp: 0.35.5(@types/node@22.20.4) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -8118,7 +8257,13 @@ snapshots: dependencies: nanoid: 3.3.19 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 + + postcss@8.5.30: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 postgres-array@2.0.0: {} @@ -8362,15 +8507,14 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 - sanitize-html@2.17.7: + sanitize-html@2.18.1: dependencies: - deepmerge: 4.3.1 escape-string-regexp: 4.0.0 htmlparser2: 12.0.0 - is-plain-object: 5.0.0 - launder: 1.7.1 + is-plain-object: 5.1.0 + launder: 1.7.2 parse-srcset: 1.0.2 - postcss: 8.5.28 + postcss: 8.5.30 scheduler@0.28.0: {} @@ -8406,37 +8550,37 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 - sharp@0.35.4(@types/node@22.20.4): + sharp@0.35.5(@types/node@22.20.4): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 '@types/node': 22.20.4 optional: true @@ -8474,15 +8618,13 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - siginfo@2.0.0: {} - sirv@2.0.4: dependencies: '@polka/url': 1.0.0-next.29 mrmime: 2.0.1 totalist: 3.0.1 - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} source-map-support@0.5.21: dependencies: @@ -8493,9 +8635,7 @@ snapshots: split2@4.2.0: {} - stackback@0.0.2: {} - - std-env@4.2.0: {} + std-env@4.3.0: {} stop-iteration-iterator@1.1.0: dependencies: @@ -8587,7 +8727,7 @@ snapshots: strip-bom@3.0.0: {} - stripe@22.6.2(@types/node@22.20.4): + stripe@23.0.0(@types/node@22.20.4): optionalDependencies: '@types/node': 22.20.4 @@ -8653,9 +8793,9 @@ snapshots: transitivePeerDependencies: - react-native-b4a - tinybench@6.1.4: {} + tinybench@6.2.0: {} - tinyexec@1.3.0: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: dependencies: @@ -8695,14 +8835,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo@2.11.3: + turbo@2.11.7: optionalDependencies: - '@turbo/darwin-64': 2.11.3 - '@turbo/darwin-arm64': 2.11.3 - '@turbo/linux-64': 2.11.3 - '@turbo/linux-arm64': 2.11.3 - '@turbo/windows-64': 2.11.3 - '@turbo/windows-arm64': 2.11.3 + '@turbo/darwin-64': 2.11.7 + '@turbo/darwin-arm64': 2.11.7 + '@turbo/linux-64': 2.11.7 + '@turbo/linux-arm64': 2.11.7 + '@turbo/windows-64': 2.11.7 + '@turbo/windows-arm64': 2.11.7 type-check@0.4.0: dependencies: @@ -8845,22 +8985,22 @@ snapshots: lightningcss: 1.32.0 tsx: 4.23.15 - vitest@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)): + vitest@5.0.3(@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)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.1(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) - chai: 6.2.2 + '@vitest/mocker': 5.0.3(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 - magic-string: 1.4.2 + magic-string: 1.4.3 obug: 2.2.1 picomatch: 4.0.7 - std-env: 4.2.0 - tinybench: 6.1.4 - tinyexec: 1.3.0 + std-env: 4.3.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 tinyglobby: 0.2.17 vite: 7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) - why-is-node-running: 2.3.0 + why-is-node-running: 3.2.1 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 22.20.4 @@ -8946,10 +9086,7 @@ snapshots: dependencies: isexe: 2.0.0 - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 + why-is-node-running@3.2.1: {} word-wrap@1.2.5: {} diff --git a/sites/hacklytics2027/app/apple-icon.png b/sites/hacklytics2027/app/apple-icon.png new file mode 100644 index 00000000..dafb963d Binary files /dev/null and b/sites/hacklytics2027/app/apple-icon.png differ diff --git a/sites/hacklytics2027/app/favicon.ico b/sites/hacklytics2027/app/favicon.ico index be92100c..039f2586 100644 Binary files a/sites/hacklytics2027/app/favicon.ico and b/sites/hacklytics2027/app/favicon.ico differ diff --git a/sites/hacklytics2027/app/globals.css b/sites/hacklytics2027/app/globals.css index 8de0c3e9..dc07d30d 100644 --- a/sites/hacklytics2027/app/globals.css +++ b/sites/hacklytics2027/app/globals.css @@ -10,6 +10,7 @@ --color-ink-2: var(--ink-2); --color-ink-3: var(--ink-3); --color-rule: var(--rule); + --color-line: var(--line-border); --color-bloom: var(--bloom); --color-bloom-deep: var(--bloom-deep); --color-on-bloom: var(--on-bloom); @@ -32,7 +33,7 @@ --bloom-hover: #ff6a9d; --bloom-deep: #b8285a; --on-bloom: #16060c; - --line-border: #39413c; + --line-border: #626a64; /* ≥3:1 on ground */ /* Neon halo on blooming flowers. */ --glow-1: #ff4f8b; @@ -59,7 +60,7 @@ --bloom-hover: #a8134d; --bloom-deep: #8e0f41; --on-bloom: #ffffff; - --line-border: #bfc7c0; + --line-border: #838b85; /* ≥3:1 on ground */ --glow-1: rgba(194, 24, 91, 0.55); --glow-2: rgba(194, 24, 91, 0.35); @@ -80,7 +81,7 @@ --bloom-hover: #a8134d; --bloom-deep: #8e0f41; --on-bloom: #ffffff; - --line-border: #bfc7c0; + --line-border: #838b85; --glow-1: rgba(194, 24, 91, 0.55); --glow-2: rgba(194, 24, 91, 0.35); @@ -125,22 +126,26 @@ body { .wrap { padding-inline: 3rem; } } - /* Display type: the Silkscreen pixel face, bold. Pixel glyphs need their - own spacing, so no negative tracking. */ + /* Display type: the Silkscreen pixel face at its one loaded weight (400). + Pixel glyphs need their own spacing, so no negative tracking. */ .display { font-family: var(--font-display); - font-weight: 700; + font-weight: 400; letter-spacing: 0; line-height: 1; } + /* 32px, 48px from lg: multiples of 8, so a pixel cell is a whole 4 or 6px. */ .section-title { font-family: var(--font-display); - font-weight: 700; - font-size: clamp(2rem, 1.4rem + 2.4vw, 3.25rem); /* 32–52px */ + font-weight: 400; + font-size: 2rem; letter-spacing: 0; line-height: 1.05; color: var(--ink); } + @media (min-width: 1024px) { + .section-title { font-size: 3rem; } + } /* Silkscreen kicker: small, as written, one per section at most. */ .kicker { @@ -148,7 +153,7 @@ body { font-size: 0.8125rem; line-height: 1.4; letter-spacing: 0.02em; - color: var(--bloom); + color: var(--ink-2); } /* Buttons: pills, verbs. Pink only for the action. */ @@ -171,7 +176,9 @@ body { color: var(--on-bloom); } .btn-bloom:hover { background: var(--bloom-hover); } - .btn-bloom:active { background: var(--bloom-deep); color: var(--ink); } + /* --bloom-deep is a dark pink in both themes, so the pressed label is white: + --ink is near-black in light (2:1) and --on-bloom is near-black in dark (3.3:1). */ + .btn-bloom:active { background: var(--bloom-deep); color: #ffffff; } .btn-line { border: 1px solid var(--line-border); color: var(--ink); @@ -249,7 +256,9 @@ body { flower-breathe 4s ease-in-out var(--t3) infinite; } /* Offscreen beds stop breathing (data-anim is kept by useBloomInView). */ -[data-anim="paused"] .flower-neon { animation-play-state: paused; } +/* Only the breathing: the list matches flower-flash, flower-breathe, so a flash + cut off by scrolling away still finishes instead of freezing at 1.6x. */ +[data-anim="paused"] .flower-neon { animation-play-state: running, paused; } @keyframes flower-stem { from { clip-path: inset(100% 0 0 0); } diff --git a/sites/hacklytics2027/app/layout.tsx b/sites/hacklytics2027/app/layout.tsx index ffa061cc..b263f510 100644 --- a/sites/hacklytics2027/app/layout.tsx +++ b/sites/hacklytics2027/app/layout.tsx @@ -6,31 +6,36 @@ import ServiceWorkerRegistrar from "../components/ServiceWorkerRegistrar"; import Footer from "../components/Footer"; import { INTEREST_URL } from "../lib/links"; -// Display face: hero and section titles, set at 800. Variable, with the -// optical-size axis so the big sizes get the display cut. // Text and UI. const instrumentSans = Instrument_Sans({ subsets: ["latin"], variable: "--font-instrument", }); -// Pixel face, for the small kickers only. +// Pixel face: the display type (hero, section titles, track names, countdown) +// and the kickers. Loaded at 400 only, so nothing sets it bold: a synthesized +// bold smears the pixel cells. const silkscreen = Silkscreen({ subsets: ["latin"], weight: "400", variable: "--font-silkscreen", }); +// --ground in globals.css, per theme. The browser bar follows the device +// setting by default; ThemeToggle repeats these when a choice is made. +const ground = { light: "#eef1ec", dark: "#131715" }; + export const viewport: Viewport = { themeColor: [ - { media: "(prefers-color-scheme: light)", color: "#eef1ec" }, - { media: "(prefers-color-scheme: dark)", color: "#131715" }, + { media: "(prefers-color-scheme: light)", color: ground.light }, + { media: "(prefers-color-scheme: dark)", color: ground.dark }, ], }; // Runs before first paint so a saved light/dark choice never flashes the -// other theme. Kept tiny and dependency-free; the key matches ThemeToggle. -const themeScript = `try{var t=localStorage.getItem("hl-theme");if(t==="light"||t==="dark")document.documentElement.dataset.theme=t}catch(e){}`; +// other theme, and points the theme-color metas at it so the bar matches the +// page. Kept tiny and dependency-free; the key matches ThemeToggle. +const themeScript = `try{var t=localStorage.getItem("hl-theme");if(t==="light"||t==="dark"){document.documentElement.dataset.theme=t;document.querySelectorAll('meta[name="theme-color"]').forEach(function(m){m.content=t==="light"?"${ground.light}":"${ground.dark}"})}}catch(e){}`; export const metadata: Metadata = { title: "Hacklytics 2027: Digital Bloom · Data Science Hackathon at Georgia Tech", @@ -61,7 +66,7 @@ export const metadata: Metadata = { images: ["/og-image.jpg"], creator: "@datasciencegt", }, - icons: { icon: "/favicon.ico" }, + icons: { icon: "/favicon.ico", apple: "/apple-icon.png" }, metadataBase: new URL("https://hacklytics.io"), }; diff --git a/sites/hacklytics2027/app/page.tsx b/sites/hacklytics2027/app/page.tsx index 680b4301..53833ee6 100644 --- a/sites/hacklytics2027/app/page.tsx +++ b/sites/hacklytics2027/app/page.tsx @@ -61,7 +61,7 @@ const Countdown: React.FC<{ targetDate: Date }> = ({ targetDate }) => { {units.map(({ label, value }) => (
@@ -95,7 +95,7 @@ export default function HomePage() { rel="noopener noreferrer" className="absolute top-0 right-5 md:right-12 z-50 w-[9%] max-w-[90px] min-w-[56px] hover:scale-105 hover:brightness-110 transition-all duration-300" > - Major League Hacking 2027 + Major League Hacking 2027 {/* Top padding clears the navbar and the MLH badge, which hangs to @@ -139,7 +139,7 @@ export default function HomePage() { {/* The flower bed the hero stands in. Its share in bloom tracks the season; visitors can plant their own. */}
-

+

Plant a flower

-
+

Applications open soon.

@@ -31,24 +36,41 @@ export default function Footer() {
{/* Side by side only once both rows fit: at tablet width the contact - links wrapped "MLH Code of Conduct" onto three lines. */} -
-