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..5f6fa53b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,7 +54,9 @@ 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`) - Announcements and acceptance waves Hackathon participation is **open to non-members**. Membership is not a registration requirement. @@ -68,7 +70,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..3eadf40f 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`). Unset means CP-SAT is download, run on a laptop, upload | +| `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..63c81425 100644 --- a/docs/operations/testing.md +++ b/docs/operations/testing.md @@ -31,7 +31,7 @@ Config: `packages/api/vitest.config.ts`. Includes `src/**/*.test.ts` and `src/.i | 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..8143aedf 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,11 @@ 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. ## Club procedures (selected) diff --git a/docs/packages/db.md b/docs/packages/db.md index 12e5c566..d928531e 100644 --- a/docs/packages/db.md +++ b/docs/packages/db.md @@ -28,7 +28,10 @@ Files in `src/schemas/`, re-exported from `schemas/index.ts`. `drizzle.config.ts | `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` | +| `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/package.json b/package.json index c88627b1..bab4f90d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint": "turbo run lint", "format": "prettier --write .", "typecheck": "turbo run typecheck", - "test": "vitest run packages/api packages/auth packages/db sites/mainweb/lib" + "test": "vitest run packages/api packages/auth packages/db sites/mainweb/lib sites/mainweb/components" }, "dependencies": { "next": "16.3.7", diff --git a/packages/api/package.json b/packages/api/package.json index 9f6b350f..127e143f 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", diff --git a/packages/api/scripts/judging-schedule/cpsat.py b/packages/api/scripts/judging-schedule/cpsat.py new file mode 100644 index 00000000..f81afaad --- /dev/null +++ b/packages/api/scripts/judging-schedule/cpsat.py @@ -0,0 +1,603 @@ +# /// 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. + +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 + +PROBLEM_FORMAT = "query-judging-schedule-problem" +SOLUTION_FORMAT = "query-judging-schedule-solution" +VERSION = 1 + + +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 +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]] + + +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"]) + + 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, + ) + + +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 + return { + "sponsorFinish": sponsor_finish, + "sponsorIdle": sponsor_idle, + "finish": finish, + "buildingChanges": changes, + "breakSlots": breaks, + } + + +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) + + goals = { + "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)] + + 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) + + +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() + attempts = [(False, problem.cross_gap)] + if problem.cross_gap > problem.same_gap: + attempts.append((True, problem.same_gap)) + + status, placement, relaxed = "infeasible", None, False + 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 + + solution = { + "format": SOLUTION_FORMAT, + "version": VERSION, + "fingerprint": problem.fingerprint, + "status": status, + "relaxed": relaxed if placement is not None else False, + "objective": score(problem, placement) if placement is not None 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 placement is not None else [], + "reasons": reasons, + "solver": { + "name": "cp-sat", + "ortools": ortools.__version__, + "seconds": round(time.monotonic() - began, 2), + }, + } + 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 placement is not None: + log(f"{status}{', relaxed' if solution['relaxed'] else ''}: " + + ", ".join(f"{k} {v}" for k, v in solution["objective"].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..84ca5ae2 --- /dev/null +++ b/packages/api/scripts/judging-schedule/test_cpsat.py @@ -0,0 +1,263 @@ +"""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 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) + + +@unittest.skipUnless(HAVE_ORTOOLS, "OR-Tools is not installed") +class CommandLineTest(unittest.TestCase): + def run_script(self, 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, + ) + + 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"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/api/src/.internal-tests/announcements.test.ts b/packages/api/src/.internal-tests/announcements.test.ts index bf41e4d0..0f2b2d0f 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", }, @@ -140,12 +153,25 @@ vi.mock("@query/db", () => { }); 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 +204,7 @@ describe("Announcements", () => { mockInsert.mockReset().mockReturnValue([{ id: ANNOUNCEMENT }]); mockUpdate.mockReset().mockReturnValue([]); mockSelectRows.mockReset().mockReturnValue([]); + mockSelectConditions.mockReset(); mockSendAnnouncement.mockReset().mockResolvedValue(undefined); cache.clear(); }); @@ -231,6 +258,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 +318,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 +467,272 @@ 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 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/hackathon-admin-edge.test.ts b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts index 50f9a94d..3ab02749 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,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { appRouter } from "../root"; import { cache } from "../middleware/cache"; -import { db, hackathons, hackathonParticipants } from "@query/db"; +import { + db, + hackathons, + hackathonParticipants, + hackathonEventAttendees, +} 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. @@ -151,6 +156,7 @@ vi.mock("@query/db", () => { userId: "user_id", registrationStatus: "registration_status", checkedInAt: "checked_in_at", + attendanceConfirmedAt: "attendance_confirmed_at", }, hackathonTeams: { _t: "hackathonTeams", @@ -498,12 +504,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 +541,9 @@ 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); }); it("lets everyone else through the ban gate", async () => { @@ -1115,6 +1124,191 @@ 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/, + ); + }); + }); + }); + + // ===================================================================== + 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 +1364,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,6 +1625,176 @@ 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(); + }); + }); }); // ===================================================================== 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..d3e9af21 100644 --- a/packages/api/src/.internal-tests/qr-checkin.test.ts +++ b/packages/api/src/.internal-tests/qr-checkin.test.ts @@ -100,7 +100,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 +172,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", @@ -834,10 +833,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]; @@ -1053,6 +1057,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/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/middleware/procedures.ts b/packages/api/src/middleware/procedures.ts index 99539cfe..67392057 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,86 +189,6 @@ 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()}`; 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 c180ea82..9fa73f56 100644 --- a/packages/api/src/routers/admin.ts +++ b/packages/api/src/routers/admin.ts @@ -13,13 +13,39 @@ 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 }) => { if (!ctx.userId) { @@ -300,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()), @@ -319,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), @@ -341,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({ @@ -386,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(), @@ -406,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), }); @@ -413,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", @@ -472,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({ @@ -485,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", @@ -500,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..a8b951d7 100644 --- a/packages/api/src/routers/hackathon/admin.ts +++ b/packages/api/src/routers/hackathon/admin.ts @@ -6,8 +6,14 @@ 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 { CacheKeys } from "../../middleware/cache"; import { MASS_EMAIL_BATCH } from "../../services/email-limits"; import { @@ -15,9 +21,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 +139,7 @@ const buildAttendeeWhere = (input: { hackathonId: string; search?: string; status?: z.infer; + unconfirmedOnly?: boolean; }) => { const clauses = [eq(hackathonParticipants.hackathonId, input.hackathonId)]; @@ -139,6 +147,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 +180,151 @@ 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. + // 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.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 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 +334,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 +367,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 +405,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 +429,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 +1142,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 +1164,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 +1175,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 +1203,161 @@ 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, + }); - // 2. Verify event exists and belongs to this hackathon - const event = await (ctx.db as DrizzleDB).query.hackathonEvents.findFirst( - { + 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), + })); + }), + + // 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, points: 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 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..a837e3bf 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) @@ -165,14 +210,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 +233,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 +274,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 +295,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..6511a785 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,76 @@ 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, + }, + 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. + ...placements + .filter((row) => row.entry && !row.entry.deletedAt) + .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..241d4637 --- /dev/null +++ b/packages/api/src/routers/judging/admin.ts @@ -0,0 +1,2481 @@ +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 { + hackathonProjects, + judgingAnnouncements, + judgingAppointments, + judgingBuildings, + judgingChallenges, + judgingConfigs, + judgingEntries, + judgingEntryChallenges, + judgingEntryMembers, + judgingEvaluationDrafts, + judgingEvaluations, + judgingGuestSessions, + 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 { 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"; + +// 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`); + +// 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], + ] = 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} = ${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} = ${judgingSchedules.id})`, + }) + .from(judgingSchedules) + .where(eq(judgingSchedules.hackathonId, id)) + .limit(1), + db + .select({ + total: count(), + complete: sql`count(*) filter (where ${judgingEvaluations.isComplete})::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), + ), + ), + ]); + + 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, + }, + 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(), + 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; + + 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, + 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, + 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] = + ids.length === 0 + ? [[], [], [], []] + : 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), + eq(judgingEvaluations.isComplete, true), + ), + ) + .groupBy(judgingEvaluations.entryId), + ]); + + 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), + })), + }; + }), + + /** + * 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 }; + }), + + // --------------------------------------------------------------------- + // 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 }; + }), + + // --------------------------------------------------------------------- + // 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..849147db --- /dev/null +++ b/packages/api/src/routers/judging/desk.ts @@ -0,0 +1,1661 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { and, asc, desc, eq, 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 { 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, + evaluatedChallenge, + evaluationAccess, + guestScope, + judgingSwitches, + loadChallenges, + loadEntryInChallenge, + loadRubric, + reconcileDueDrafts, + seatOf, + writeEvaluation, +} from "../../services/judging/evaluation-store"; +import type { ChallengeRow } from "../../services/judging/evaluation-store"; +import { + checkAnswers, + responseVisibility, +} from "../../services/judging/evaluation"; +import { + evaluationScore, + overallScore, + scopedScore, +} from "../../services/judging/scoring"; +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; +} + +/** 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. + 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), + eq(judgingEvaluations.isComplete, true), + 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); + const visible = + principal.kind === "guest" + ? guestScope(challenges, principal.roomChallengeId) + : null; + + 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 || 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. + 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, + }) + .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), + ), + ) + : []; + const mine = await db + .select({ + entryId: judgingEvaluations.entryId, + revision: judgingEvaluations.revision, + isComplete: judgingEvaluations.isComplete, + }) + .from(judgingEvaluations) + .where( + and( + eq(judgingEvaluations.judgeId, judge.judgeId), + eq(judgingEvaluations.challengeId, challenge.id), + inArray(judgingEvaluations.entryId, entryIds), + ), + ); + const drafts = await db + .select({ entryId: judgingEvaluationDrafts.entryId }) + .from(judgingEvaluationDrafts) + .where( + and( + eq(judgingEvaluationDrafts.judgeId, judge.judgeId), + eq(judgingEvaluationDrafts.challengeId, challenge.id), + inArray(judgingEvaluationDrafts.entryId, entryIds), + ), + ); + const tags = await tagsOf(db, judge, challenges, entryIds); + + const presentationOf = new Map(presentations.map((p) => [p.entryId, p])); + const evaluationOf = new Map(mine.map((e) => [e.entryId, e])); + const drafted = new Set(drafts.map((d) => d.entryId)); + + const inView = entries.filter( + (entry) => + !( + roomFilter === "applied" && + presentationOf.get(entry.id)?.roomId !== seat?.roomId + ), + ); + // An incomplete evaluation (time ran out on it) still has work to do, so + // only finished ones are hidden. + const judgedCount = inView.filter( + (entry) => evaluationOf.get(entry.id)?.isComplete, + ).length; + + const projects = inView + .filter( + (entry) => + !(input.hideJudged && evaluationOf.get(entry.id)?.isComplete), + ) + .map((entry) => { + const presentation = presentationOf.get(entry.id); + const evaluation = evaluationOf.get(entry.id); + return { + ...entry, + challenges: tags.get(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, + isComplete: evaluation.isComplete, + } + : null, + hasDraft: drafted.has(entry.id), + }; + }); + + const byTitle = (a: { title: string }, b: { title: string }) => + a.title.localeCompare(b.title); + projects.sort((a, b) => { + if (input.sort === "scheduled") { + 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 }; + }), + + // 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]); + 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); + + const { timing, presentation } = await evaluationAccess(db, { + hackathonId: input.hackathonId, + judgeId: judge.judgeId, + entryId: entry.id, + challenge, + challenges, + evaluation: evaluation ?? null, + }); + const presentationRoom = presentation + ? await roomSummary(db, presentation.roomId, challenges) + : null; + + // 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 complete = await db + .select({ + id: judgingEvaluations.id, + challengeId: judgingEvaluations.challengeId, + }) + .from(judgingEvaluations) + .where( + and( + eq(judgingEvaluations.entryId, entry.id), + eq(judgingEvaluations.isComplete, true), + ), + ); + const answers = await answersOf( + db, + complete.map((e) => e.id), + ); + const scored = complete.map((e) => ({ + challengeId: e.challengeId, + isComplete: true, + ratings: Object.values(answers.get(e.id)?.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) ?? [], + }, + 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: evaluation.isComplete + ? evaluationScore(Object.values(mine?.ratings ?? {})) + : null, + } + : null, + draft: draft ?? null, + access: { + judgingState: switches.state, + canEdit: open && timing.canEdit, + reason: !open + ? switches.state === "closed" + ? CLOSED + : NOT_OPEN_YET + : timing.canEdit + ? null + : timing.reason, + deadlineAt: timing.canEdit ? timing.deadlineAt : null, + opensAt: timing.canEdit ? null : timing.opensAt, + // 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 && !timing.canEdit && !timing.opensAt && 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(), + }; + }), + + // 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, + null, + ); + 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); + + if ((draft?.revision ?? 0) !== input.revision) { + throw new TRPCError({ + code: "CONFLICT", + message: + evaluation && !draft + ? "Time ran out, so your answers were submitted for you. Reload to see them; you can still edit." + : "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, + }); + 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, + null, + ); + 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. + 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); + + if ((existing?.revision ?? 0) !== input.expectedRevision) { + throw new TRPCError({ + code: "CONFLICT", + message: + existing?.autoSubmittedAt && input.expectedRevision === 0 + ? "Time ran out, so your answers were submitted for you. Reload to see them; you can still edit." + : "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, + }); + 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), + ); + return rows.map(({ deletedAt, ...row }) => { + const mine = answers.get(row.id) ?? { ratings: {}, responses: {} }; + return { + ...row, + withdrawn: deletedAt !== null, + ratings: mine.ratings, + responses: mine.responses, + score: row.isComplete + ? evaluationScore(Object.values(mine.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..8c0f2d54 --- /dev/null +++ b/packages/api/src/routers/judging/hacker.ts @@ -0,0 +1,1128 @@ +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"; + +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, + isComplete: judgingEvaluations.isComplete, + }) + .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 from member + // judges' complete evaluations. 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), + eq(judgingEvaluations.isComplete, true), + 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..82f60d6c --- /dev/null +++ b/packages/api/src/routers/judging/results.ts @@ -0,0 +1,748 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { + and, + asc, + count, + desc, + eq, + gt, + inArray, + isNotNull, + isNull, + 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 { 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 { + notifyResultsPublished, + resultsPublishedBefore, +} from "../../services/judging/notify"; +import { evaluationScore, 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)), + ); +} + +// 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 complete + // evaluation. 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.", + }); + } + + const entries = await tx + .select({ id: judgingEntries.id }) + .from(judgingEntries) + .where( + and( + eq(judgingEntries.hackathonId, input.hackathonId), + isNull(judgingEntries.deletedAt), + ), + ); + 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 tx + .select({ + id: judgingEvaluations.id, + entryId: judgingEvaluations.entryId, + challengeId: judgingEvaluations.challengeId, + isComplete: judgingEvaluations.isComplete, + }) + .from(judgingEvaluations) + .where( + and( + eq(judgingEvaluations.hackathonId, input.hackathonId), + eq(judgingEvaluations.isComplete, true), + ), + ); + // Joined rather than listed by id: an edition's evaluations run to + // thousands, and the filter is the same one as above. + const ratingRows = await tx + .select({ + evaluationId: judgingEvaluationRatings.evaluationId, + value: judgingEvaluationRatings.value, + }) + .from(judgingEvaluationRatings) + .innerJoin( + judgingEvaluations, + eq(judgingEvaluations.id, judgingEvaluationRatings.evaluationId), + ) + .where( + and( + eq(judgingEvaluations.hackathonId, input.hackathonId), + eq(judgingEvaluations.isComplete, true), + ), + ); + const ratings = groupRatings(ratingRows); + + const placements = computePlacements({ + entryIds: entries.map((entry) => entry.id), + memberships, + challenges, + evaluations: evaluations.map((evaluation) => ({ + ...evaluation, + ratings: ratings.get(evaluation.id) ?? [], + })), + }); + + 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), + evaluationCount: placement.evaluationCount, + computedAt: now, + })), + ); + } + + return { + placements: placements.length, + scopes: new Set(placements.map((placement) => placement.scope)).size, + evaluations: evaluations.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.", + }); + } + 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, + evaluationCount: judgingPlacements.evaluationCount, + publishedAt: judgingPlacements.publishedAt, + computedAt: judgingPlacements.computedAt, + entryId: judgingEntries.id, + title: judgingEntries.title, + deletedAt: judgingEntries.deletedAt, + }) + .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; + } + + const scopes = new Map< + string, + { + scope: string; + scopeLabel: string; + placements: { + id: string; + entryId: string; + title: string; + placement: number; + score: number | null; + evaluationCount: number; + /** Pulled from judging since; getResults already hides it. */ + withdrawn: 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), + evaluationCount: row.evaluationCount, + withdrawn: !!row.deletedAt, + }); + scopes.set(row.scope, scope); + } + + return { + computedAt, + publishedAt, + evaluationsSinceCompute, + 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 complete, 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), + ), + ), + // Complete 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), + eq(judgingEvaluations.isComplete, true), + or( + isNull(judgingEvaluations.appointmentId), + ne(judgingEvaluations.appointmentId, appointment.id), + ), + ), + ) + .limit(1), + ]); + + const completeIds = evaluations + .filter((evaluation) => evaluation.isComplete) + .map((evaluation) => evaluation.id); + const [ratings, responses] = completeIds.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, completeIds), + ) + .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, completeIds), + ) + .orderBy(asc(judgingRubricItems.displayOrder)), + ]) + : [[], []]; + + const judged = new Set( + evaluations.map((evaluation) => evaluation.judgeId), + ); + const judges = [ + ...evaluations.map((evaluation) => { + const own = evaluation.isComplete + ? 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, + updatedAt: evaluation.updatedAt, + score: evaluationScore(own.map((rating) => rating.value)), + ratings: own.map(({ itemId, label, value }) => ({ + itemId, + label, + value, + })), + responses: evaluation.isComplete + ? 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, + updatedAt: draft.updatedAt, + score: null, + ratings: [], + responses: [], + })), + ].sort((a, b) => a.judgeName.localeCompare(b.judgeName)); + + const complete = judges.filter((judge) => judge.status === "complete"); + return { + appointment, + average: meanOrNull( + complete + .map((judge) => judge.score) + .filter((score): score is number => score !== null), + ), + completeCount: complete.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), + ); + + return rows.map(({ autoSubmittedAt, ...row }) => ({ + ...row, + autoSubmitted: !!autoSubmittedAt, + autoSubmittedAt, + score: evaluationScore(ratings.get(row.id) ?? []), + })); + }), + + // 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..1b34a501 --- /dev/null +++ b/packages/api/src/routers/judging/schedule.ts @@ -0,0 +1,1217 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { and, asc, eq, isNull, sql } from "drizzle-orm"; +import { + judgingAppointments, + judgingBuildings, + judgingChallenges, + judgingConfigs, + judgingEntries, + judgingEntryChallenges, + judgingEvaluations, + 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, + cpsatProblem, + readCpsatSolution, +} from "../../services/judging/schedule/cpsat-bridge"; +import { + CPSAT_SECONDS, + CPSAT_UNAVAILABLE, + cpsatAvailable, + runCpsat, +} from "../../services/judging/schedule/cpsat-runner"; +import { reconcileDueDrafts } from "../../services/judging/evaluation-store"; +import { moveChoices } from "../../services/judging/schedule/moves"; +import { + staffedRoomIds, + withStatus, +} from "../../services/judging/schedule/board"; +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, + }) + .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>; + +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 }, +) { + 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 }); + + const rows = schedule.placements.map((p) => ({ + scheduleId: row!.id, + hackathonId: input.hackathonId, + entryId: p.entryId, + challengeId: p.challengeId, + roomId: p.roomId, + ...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 }; + }); +} + +/** The uploaded CP-SAT solution, checked against the edition as it stands. */ +function readUpload(inventory: Inventory, timing: ScheduleTiming, solution: string) { + const { inputs, fingerprint } = planOf(inventory, timing); + const read = readCpsatSolution(inputs, fingerprint, solution); + if (!read.ok) { + throw new TRPCError({ code: "BAD_REQUEST", message: read.reason }); + } + return { + inputs, + fingerprint, + read, + key: candidateKey(fingerprint, read.relaxed, read.placements), + }; +} + +/** 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, + 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, + isComplete: judgingEvaluations.isComplete, + }) + .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, + ]), + ); + return { + schedule, + inventory, + plan: selectPlan(inventory), + names: namesOf(inventory), + appointments: withStatus( + booked, + evaluations, + presence, + (id) => roots.get(id) ?? null, + now, + ), + staffed: staffedRoomIds(presence, now), + }; +} + +type Board = Awaited>; +type SavedSchedule = NonNullable; + +/** + * 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 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 { 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.`, + }); + } + + const written = await writeSchedule(db, ctx.userId, input, { + placements: result.placements, + relaxed: result.relaxed, + fingerprint, + }); + 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 problem = cpsatProblem( + inputs, + fingerprint, + result.ok ? result.placements : undefined, + ); + return { + fileName: "problem.json", + contents: JSON.stringify(problem, null, 2), + command: CPSAT_COMMAND, + presentations: inputs.tasks.length, + hinted: result.ok, + }; + }), + + /** 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 { inputs, read, key } = readUpload(inventory, input.timing, input.solution); + 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, + }; + }), + + /** 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 { read, key, fingerprint } = readUpload( + inventory, + input.timing, + input.solution, + ); + 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, + }); + const saved = { + ...written, + relaxed: read.relaxed, + reducedBreaks: read.reducedBreaks.length, + solver: "cp-sat", + }; + + 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 run = await runCpsat( + cpsatProblem(inputs, fingerprint, result.ok ? result.placements : undefined), + { 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); + return { + ...describePlan(read, inputs, namesOf(inventory), input.timing), + candidateKey: key, + solverStatus: read.status, + 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 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)); + + 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, + // Deleted, or no longer in this challenge, since the schedule was saved. + removed: + !!a.entryDeletedAt || !planned.has(taskKey(a.entryId, a.challengeId)), + 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, + })), + 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), + ); + const [updated] = await tx + .update(judgingAppointments) + .set({ + roomId: choice.roomId, + startsAt: choice.startsAt, + deadlineAt: choice.deadlineAt, + endsAt: choice.endsAt, + revision: sql`${judgingAppointments.revision} + 1`, + }) + .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, + }; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.schedule.move", + resourceId: moved.appointmentId, + metadata: { hackathonId: input.hackathonId, ...moved }, + }); + + return moved; + }), + + /** 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..1a688067 --- /dev/null +++ b/packages/api/src/routers/points/me.ts @@ -0,0 +1,314 @@ +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 { rateLimit } from "../../middleware/security"; +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. + checkIn: protectedProcedure + .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, + 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.`, + }); + } + + const { pointsAwarded } = await 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/hackathon-metrics.test.ts b/packages/api/src/services/hackathon-metrics.test.ts index d7a52982..db8717ea 100644 --- a/packages/api/src/services/hackathon-metrics.test.ts +++ b/packages/api/src/services/hackathon-metrics.test.ts @@ -86,7 +86,15 @@ describe("hackathon gauges", () => { select() { return { from(table: Parameters[0]) { - const pending: any = Promise.resolve(rowsFor(getTableName(table))); + 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; 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..8ae78bb6 --- /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 is complete", () => { + const slot = { + startsAt, + endsAt, + evaluations: [ + { judgeId: "a", isComplete: true }, + { judgeId: "b", isComplete: 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 unfinished", () => { + const slot = { + startsAt, + endsAt, + evaluations: [ + { judgeId: "a", isComplete: true }, + { judgeId: "b", isComplete: 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", isComplete: true }], + presentJudgeIds: ["a", "b"], + }; + expect(appointmentStatus(slot, during)).toBe("incomplete"); + }); + + it("lets evaluations outrank the clock", () => { + const slot = { + startsAt, + endsAt, + evaluations: [{ judgeId: "a", isComplete: 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..46cffa53 --- /dev/null +++ b/packages/api/src/services/judging/appointment-status.ts @@ -0,0 +1,46 @@ +// 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 a complete evaluation +// 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; isComplete: 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.isComplete).map((e) => e.judgeId), + ); + const allSubmittedComplete = input.evaluations.every((e) => e.isComplete); + 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/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..06ec5661 --- /dev/null +++ b/packages/api/src/services/judging/checklist.ts @@ -0,0 +1,322 @@ +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"; + +// 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} = ${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..b7474e6f --- /dev/null +++ b/packages/api/src/services/judging/evaluation-store.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { + evaluatedChallenge, + guestScope, + inJudgeScope, + reconcileDueDrafts, +} from "./evaluation-store"; +import type { ChallengeRow, Seat } from "./evaluation-store"; +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("evaluatedChallenge", () => { + it("evaluates a child challenge under its root", () => { + expect(evaluatedChallenge(member, challenges, "mongo", null).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("falls back to the first root when there is no General", () => { + const noGeneral = challenges.filter((c) => c.id !== "general"); + expect(evaluatedChallenge(member, noGeneral, undefined, null).id).toBe( + "sponsor", + ); + }); + + 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); + }); +}); 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..591173e8 --- /dev/null +++ b/packages/api/src/services/judging/evaluation-store.ts @@ -0,0 +1,661 @@ +import { TRPCError } from "@trpc/server"; +import { and, asc, eq, gt, inArray, isNull, lte, or } 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 { GUEST_SESSION_ENDED } from "./principal"; +import type { Judge } from "./principal"; +import { descendantsOf, effectiveMode, rootOf } from "./scope"; +import type { ChallengeMode } from "./scope"; +import { evaluationTiming } 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, + }; +} + +/** + * 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. + */ +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.", + }); + } + 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; +}; + +/** + * 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. + */ +export async function evaluationAccess( + db: JudgingDb, + input: { + hackathonId: string; + judgeId: string; + entryId: string; + challenge: ChallengeRow; + challenges: readonly ChallengeRow[]; + evaluation: { appointmentId: string | null } | null; + }, + now = new Date(), +): Promise<{ timing: EvaluationTiming; presentation: Presentation | 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, + }) + .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 timing = evaluationTiming( + { + hasSchedule: !!schedule, + mode: effectiveMode(input.challenges, input.challenge.id) ?? "scheduled", + challengeId: input.challenge.id, + evaluation: input.evaluation, + appointment: presentation ?? null, + presenceChallengeId: seat?.challengeId ?? null, + }, + now, + ); + return { timing, presentation: presentation ?? null }; +} + +/** + * 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) keeps its answers and loses its deadline. + */ +export async function reconcileDueDrafts( + db: DrizzleDB, + hackathonId: string, + now = new Date(), +) { + const due = and( + eq(judgingEvaluationDrafts.hackathonId, hackathonId), + lte(judgingEvaluationDrafts.deadlineAt, now), + 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 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.judgeId, draft.judgeId), + eq(judgingEvaluations.entryId, draft.entryId), + eq(judgingEvaluations.challengeId, draft.challengeId), + ), + ) + .limit(1); + if (existing) { + // Never overwrite a result the judge submitted themselves. + await retain(draft.id); + continue; + } + + const check = checkAnswers(rubric, draft, kind, "lenient"); + if (!check.ok) { + 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/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..6fb6b531 --- /dev/null +++ b/packages/api/src/services/judging/itinerary.test.ts @@ -0,0 +1,414 @@ +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", + isComplete: true, + }, + { + judgeId: "b", + challengeId: "general", + appointmentId: null, + isComplete: true, + }, + { + judgeId: "c", + challengeId: "general", + appointmentId: "s9", + isComplete: true, + }, + { + judgeId: "d", + challengeId: "sponsor", + appointmentId: null, + isComplete: 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", + isComplete: 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", + isComplete: 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("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, + isComplete: true, + }, + ], + now: at(5), + }); + expect(judged.unscheduled[0]!.judged).toBe(true); + }); +}); + +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..0899078a --- /dev/null +++ b/packages/api/src/services/judging/itinerary.ts @@ -0,0 +1,327 @@ +// 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; + isComplete: 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 finished an evaluation of the project in this challenge. */ + 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.isComplete && + 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 complete member evaluations 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/placements.test.ts b/packages/api/src/services/judging/placements.test.ts new file mode 100644 index 00000000..f8161b6a --- /dev/null +++ b/packages/api/src/services/judging/placements.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { computePlacements, formatScore } from "./placements"; +import type { PlacementEvaluation } from "./placements"; + +const evaluation = ( + entryId: string, + challengeId: string, + ratings: number[], + isComplete = true, +): PlacementEvaluation => ({ entryId, challengeId, ratings, isComplete }); + +const challenges = [ + { id: "general", label: "General" }, + { id: "sponsor", label: "Sponsor Prize" }, +]; + +describe("computePlacements", () => { + 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 complete evaluations 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("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..16fdc4de --- /dev/null +++ b/packages/api/src/services/judging/placements.ts @@ -0,0 +1,99 @@ +// Turns evaluations into placings: one ranking per challenge and one overall. +// A project's score is the mean of its complete evaluations (scoring.ts); +// equal scores share a placing. 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 { 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 & { entryId: string }; + +export type ComputedPlacement = { + /** "overall" or a challenge id. */ + scope: string; + scopeLabel: string; + entryId: string; + placement: number; + score: 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); + + const evaluationsOf = new Map(); + for (const evaluation of input.evaluations) { + if (!live.has(evaluation.entryId)) continue; + const list = evaluationsOf.get(evaluation.entryId) ?? []; + list.push(evaluation); + 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) => ({ + entryId, + ...summarize(evaluationsOf.get(entryId) ?? []), + })) + .filter( + (row): row is { entryId: string; score: number; count: number } => + row.score !== null, + ); + return rankByScore(scored).map((row) => ({ + scope, + scopeLabel, + entryId: row.entryId, + placement: row.rank, + score: row.score, + 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/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..cdb1c1eb --- /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, + isComplete: 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..1f756314 --- /dev/null +++ b/packages/api/src/services/judging/schedule/board.ts @@ -0,0 +1,111 @@ +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; + isComplete: 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; +}; + +/** 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) => + e.appointmentId + ? e.appointmentId === appointment.id + : rootIdOf(e.challengeId) === appointment.challengeId, + ); + // 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..620fae94 --- /dev/null +++ b/packages/api/src/services/judging/schedule/cpsat-bridge.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { aamoghCPSolver } from "./aamogh-cp-solver"; +import { cpsatProblem, readCpsatSolution } from "./cpsat-bridge"; +import type { PlanInputs } from "./inputs"; + +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); + }); + + 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); + }); +}); 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..7e8e9f0c --- /dev/null +++ b/packages/api/src/services/judging/schedule/cpsat-bridge.ts @@ -0,0 +1,158 @@ +import { z } from "zod"; +import type { Placement, PlanInputs } from "./inputs"; +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"; + +/** + * The problem file cpsat.py reads. `hint` is a schedule to start from, such + * as the best partial one the in-app solver found. + */ +export function cpsatProblem( + inputs: PlanInputs, + fingerprint: string, + hint?: readonly Placement[], +) { + 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, + })), + } + : {}), + }; +} + +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(), +}); + +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[]; + } + | { ok: false; reason: string }; + +/** Reads and checks an uploaded solution.json against the current problem. */ +export function readCpsatSolution( + inputs: PlanInputs, + fingerprint: string, + text: string, +): 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, + }; +} 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..46a834c5 --- /dev/null +++ b/packages/api/src/services/judging/schedule/cpsat-runner.test.ts @@ -0,0 +1,291 @@ +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 { cpsatProblem, 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("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, + }); + }); +}); 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..b8ff9fea --- /dev/null +++ b/packages/api/src/services/judging/schedule/cpsat-runner.ts @@ -0,0 +1,203 @@ +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): 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; + +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. 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 * 2 + 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..ffb0aae1 --- /dev/null +++ b/packages/api/src/services/judging/schedule/inputs.ts @@ -0,0 +1,174 @@ +import { createHash } from "node:crypto"; +import { 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 general = new Set( + source.challenges.filter((c) => c.isGeneral).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: !general.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/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..dc6737f7 --- /dev/null +++ b/packages/api/src/services/judging/scope.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + descendantsOf, + effectiveMode, + 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); + }); +}); diff --git a/packages/api/src/services/judging/scope.ts b/packages/api/src/services/judging/scope.ts new file mode 100644 index 00000000..7fd40cb4 --- /dev/null +++ b/packages/api/src/services/judging/scope.ts @@ -0,0 +1,85 @@ +// 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; +} + +/** 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.test.ts b/packages/api/src/services/judging/scoring.test.ts new file mode 100644 index 00000000..6b88aab2 --- /dev/null +++ b/packages/api/src/services/judging/scoring.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { + evaluationScore, + 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("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", isComplete: true, ratings: [5, 5, 5] }, // 5 + { challengeId: "general", isComplete: true, ratings: [3, 3, 3] }, // 3 + { challengeId: "general", isComplete: false, ratings: [1, 1] }, // ignored + { challengeId: "mongo", isComplete: true, ratings: [4, 2] }, // 3 + { challengeId: "mongo", isComplete: true, ratings: [] }, // no score +]; + +describe("scopedScore", () => { + it("averages complete evaluations 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", isComplete: false, ratings: [5] }], + "general", + ), + ).toEqual({ score: null, count: 0 }); + }); +}); + +describe("overallScore", () => { + it("weighs every complete 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("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..ec77cc1b --- /dev/null +++ b/packages/api/src/services/judging/scoring.ts @@ -0,0 +1,87 @@ +// 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 complete evaluations only. Every evaluation weighs the +// same, whoever wrote it. + +/** An evaluation as scoring sees it. */ +export type ScoredEvaluation = { + challengeId: string; + isComplete: boolean; + /** The 1–5 rating values it holds. */ + ratings: readonly number[]; +}; + +export type ScoreSummary = { + /** Null when no complete 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); +} + +function summarize(evals: readonly ScoredEvaluation[]): ScoreSummary { + const scores = evals + .filter((evaluation) => evaluation.isComplete) + .map((evaluation) => 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 complete 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..69ae34f3 --- /dev/null +++ b/packages/api/src/services/judging/timing.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { evaluationTiming } 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 => ({ + 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, whatever the clock says", () => { + expect( + evaluationTiming( + scheduled({ + evaluation: { appointmentId: "appt-1" }, + presenceChallengeId: null, + }), + before, + ), + ).toEqual({ canEdit: true, deadlineAt: null, appointmentId: "appt-1" }); + }); + + 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("drops the deadline once it has passed, still tied to the slot", () => { + expect(evaluationTiming(scheduled(), afterDeadline)).toEqual({ + canEdit: true, + deadlineAt: null, + appointmentId: "appt-1", + }); + expect(evaluationTiming(scheduled(), deadlineAt)).toMatchObject({ + deadlineAt: null, + }); + }); + + 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 }); + } + }); + + 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/); + }); +}); diff --git a/packages/api/src/services/judging/timing.ts b/packages/api/src/services/judging/timing.ts new file mode 100644 index 00000000..ab529460 --- /dev/null +++ b/packages/api/src/services/judging/timing.ts @@ -0,0 +1,96 @@ +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. After the deadline the judge may still submit, +// untimed: a late evaluation is better than a missed one. +// +// An evaluation the judge already submitted is always editable while judging +// is open; the clock only governs the first submission. + +export type TimingInput = { + /** 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 } | null; + /** The challenge served by the room the judge is sitting in, if any. */ + presenceChallengeId: string | null; +}; + +export type EvaluationTiming = + | { + canEdit: true; + /** Set while a presentation is running: the draft is submitted then. */ + deadlineAt: Date | null; + appointmentId: string | null; + } + | { + canEdit: false; + reason: string; + /** When the judge can start, if waiting is all it takes. */ + opensAt: Date | null; + }; + +export function evaluationTiming( + input: TimingInput, + now: Date, +): EvaluationTiming { + if (input.evaluation) { + return { + canEdit: true, + deadlineAt: null, + appointmentId: input.evaluation.appointmentId, + }; + } + + if (!input.hasSchedule || input.mode !== "scheduled") { + 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, + }; + } + + if (input.presenceChallengeId !== input.challengeId) { + return { + canEdit: false, + reason: + "Join the room or table that judges this challenge to evaluate this project.", + opensAt: null, + }; + } + + 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, + }; + } + + return { + canEdit: true, + deadlineAt: + now < input.appointment.deadlineAt ? input.appointment.deadlineAt : null, + appointmentId: input.appointment.id, + }; +} diff --git a/packages/api/src/services/metrics.ts b/packages/api/src/services/metrics.ts index 6fa6508c..ed7814e6 100644 --- a/packages/api/src/services/metrics.ts +++ b/packages/api/src/services/metrics.ts @@ -25,8 +25,8 @@ import { ne, sum, } from "drizzle-orm"; +import type { DrizzleDB } from "@query/db"; import { - type DrizzleDB, hackathonEventAttendees, hackathonEvents, hackathonParticipants, diff --git a/packages/api/src/services/points.test.ts b/packages/api/src/services/points.test.ts new file mode 100644 index 00000000..8b175e73 --- /dev/null +++ b/packages/api/src/services/points.test.ts @@ -0,0 +1,414 @@ +import { describe, expect, it } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { + CHECK_IN_CODE_ALPHABET, + CHECK_IN_CODE_LENGTH, + alreadyCheckedInMessage, + assignCheckInCode, + canSupply, + fullName, + generateCheckInCode, + isSoldOut, + leaderboardName, + ledgerTotals, + normalizeCheckInCode, + personName, + pointsBalance, + pointsShort, + rankLeaderboard, + recordEventAttendance, + selfCheckInWindow, +} from "./points"; +import type { 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"); + }); +}); + +describe("recordEventAttendance", () => { + const fakeDb = (fail?: () => Error) => { + const inserted: unknown[] = []; + const db = { + insert: () => ({ + values: async (values: unknown) => { + if (fail) throw fail(); + inserted.push(values); + }, + }), + }; + return { db: db as unknown as PointsDb, inserted }; + }; + + const input = { + event: { id: "event-1", name: "Lunch", points: 15 }, + participant: { id: "part-1", name: "Ada Lovelace" }, + method: "manual" as const, + actorId: "staff-1", + }; + + 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", + }, + ]); + }); + + it("reads a duplicate as already checked in", async () => { + const { db } = fakeDb(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("speaks to the hacker when they entered the code", async () => { + const { db } = fakeDb(uniqueViolation); + await expect( + recordEventAttendance(db, { ...input, method: "code" }), + ).rejects.toThrow("You are already checked into Lunch."); + }); + + it("passes other failures through", async () => { + const { db } = fakeDb(() => 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..a2ad9dd4 --- /dev/null +++ b/packages/api/src/services/points.ts @@ -0,0 +1,587 @@ +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 + +/** + * 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. + */ +export async function recordEventAttendance( + db: PointsDb, + input: { + event: { id: string; name: string; points: number }; + participant: { id: string; name: string }; + method: AttendanceMethod; + actorId: string | null; + }, +): Promise<{ pointsAwarded: number }> { + const { event, participant, method, actorId } = input; + + try { + await db.insert(hackathonEventAttendees).values({ + eventId: event.id, + participantId: participant.id, + pointsAwarded: event.points, + method, + checkedInById: actorId, + }); + } 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}.`; +} + +// --------------------------------------------------------------------------- +// 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/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/src/schemas/hackathons.ts b/packages/db/src/schemas/hackathons.ts index 0ada7f93..56d0fc95 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"), ], ); @@ -497,6 +538,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..e35141ac --- /dev/null +++ b/packages/db/src/schemas/judging.ts @@ -0,0 +1,1047 @@ +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", + }), + 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), + }, + (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), + 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(), + score: numeric("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/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. */} -
-