From eee1f4fa2cc4329be12d037609b7e5e86c0b945d Mon Sep 17 00:00:00 2001 From: aamoghS Date: Wed, 7 Oct 2026 17:52:54 -0400 Subject: [PATCH 01/82] feat(metrics): publish live hackathon counts for the weekend The analytics page and the metrics scrape share registrations, badge scans, and judging totals for the edition that is actually running. --- monitoring/README.md | 8 + monitoring/clickhouse/init/02-hackathon.sql | 57 +++ .../grafana/dashboards/dsgt-portal.json | 115 ++++- monitoring/prometheus.yml | 5 +- monitoring/rules/hackathon.yml | 26 ++ .../hackathon-admin-edge.test.ts | 10 +- packages/api/src/routers/admin.ts | 25 +- .../src/services/hackathon-metrics.test.ts | 181 ++++++++ packages/api/src/services/metrics.ts | 392 +++++++++++++++--- packages/db/scripts/export-clickhouse.mts | 229 ++++++++-- .../app/(portal)/admin/analytics/page.tsx | 97 ++++- 11 files changed, 1018 insertions(+), 127 deletions(-) create mode 100644 monitoring/clickhouse/init/02-hackathon.sql create mode 100644 monitoring/rules/hackathon.yml create mode 100644 packages/api/src/services/hackathon-metrics.test.ts diff --git a/monitoring/README.md b/monitoring/README.md index 69cb9edc..46503c91 100644 --- a/monitoring/README.md +++ b/monitoring/README.md @@ -42,6 +42,12 @@ number of instances is irrelevant: - `dsgt_members_active`, `dsgt_members_lapsed` - `dsgt_bootcamp_enrolled{term}` - `dsgt_payments_by_plan{plan}` — the yearly/semester split +- `dsgt_hackathon_participants{edition,phase,status}`, `dsgt_hackathon_projects`, + `dsgt_hackathon_checkins_today`, `dsgt_hackathon_judging_projects`, + `dsgt_hackathon_votes`, `dsgt_hackathon_judging_active` — only editions that + are `open`, `closed`, or `in_progress`. A completed edition disappears on + the next refresh. Badge scans are hackathon QR rows since midnight Eastern, + not club door check-ins. - `dsgt_payments_unlinked` — paid, claimed by nobody. This is **not** zero and is not supposed to be: as of August 2026 it sits around 428 of 443 paid rows, nearly all from the August–September 2025 and January 2026 drives. Those are @@ -85,6 +91,8 @@ The alerts that matter: - `MembershipGrantFailing` — a payment was recorded and the membership was not. - `UnlinkedPaymentsClimbing` — the link paths are not claiming payments. - `ReconcileRecoveringOften` — the backstop is carrying the fast path. +- `HackathonJudgingIdle` — judging is on, tables are assigned, and no scores + have landed for 30 minutes. To route them into ClickUp, run Alertmanager with a webhook receiver and pipe its payload to `node scripts/clickup-task.mjs --stdin`. That step is not built diff --git a/monitoring/clickhouse/init/02-hackathon.sql b/monitoring/clickhouse/init/02-hackathon.sql new file mode 100644 index 00000000..12d1c5fa --- /dev/null +++ b/monitoring/clickhouse/init/02-hackathon.sql @@ -0,0 +1,57 @@ +-- Hackathon history. Same reason as the payment tables: aggregate it here, +-- not on the 0.5 GB application database. `export:clickhouse` creates these +-- too, so a volume that already ran 01-schema.sql still picks them up. + +CREATE DATABASE IF NOT EXISTS dsgt; + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_registrations +( + id String, + hackathon String, + status LowCardinality(String), + registered_at DateTime64(3), + updated_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (hackathon, id); + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_projects +( + id String, + hackathon String, + status LowCardinality(String), + submitted_at Nullable(DateTime64(3)), + updated_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (hackathon, id); + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_scans +( + id String, + hackathon String, + event_name String, + checked_in_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(checked_in_at) +ORDER BY (hackathon, id); + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_votes +( + id String, + hackathon String, + score Int32, + voted_at DateTime64(3), + updated_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (hackathon, id); + +CREATE VIEW IF NOT EXISTS dsgt.hackathon_registrations_by_status AS +SELECT + hackathon, + status, + count() AS registrations +FROM dsgt.hackathon_registrations FINAL +GROUP BY hackathon, status +ORDER BY hackathon, status; diff --git a/monitoring/grafana/dashboards/dsgt-portal.json b/monitoring/grafana/dashboards/dsgt-portal.json index 1adf141f..c7a726b0 100644 --- a/monitoring/grafana/dashboards/dsgt-portal.json +++ b/monitoring/grafana/dashboards/dsgt-portal.json @@ -144,17 +144,112 @@ }, { "type": "row", - "title": "Payments (per-instance counters — a sample in production)", + "title": "Hackathon (read from the database — correct in production)", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 }, "collapsed": false, "panels": [] }, + { + "type": "stat", + "title": "Checked in", + "description": "Accepted participants whose badge was scanned, for each live edition.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 0, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_participants{env=~\"$env\",status=\"checked_in\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Approved, not checked in", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 4, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_participants{env=~\"$env\",status=\"approved\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Projects submitted", + "description": "Not drafts, and not withdrawn by an organiser.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 8, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_projects{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Badge scans today", + "description": "Hackathon QR scans since midnight Eastern. Club door check-ins are not in this number.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 12, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_checkins_today{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Judging tables", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 16, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_judging_projects{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Judge scores", + "description": "Zero while judging is switched on is what HackathonJudgingIdle fires on.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 20, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_votes{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "row", + "title": "Payments (per-instance counters — a sample in production)", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 12 }, + "collapsed": false, + "panels": [] + }, { "type": "timeseries", "title": "Grant failures", "description": "Payment recorded, membership not. Non-zero means money taken with nothing given.", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 0, "y": 7 }, + "gridPos": { "h": 7, "w": 8, "x": 0, "y": 13 }, "fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 60, "lineWidth": 1 }, @@ -174,7 +269,7 @@ "type": "timeseries", "title": "Grants by source", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 8, "y": 7 }, + "gridPos": { "h": 7, "w": 8, "x": 8, "y": 13 }, "fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 50, "stacking": { "mode": "normal" } } }, "overrides": [] @@ -191,7 +286,7 @@ "type": "timeseries", "title": "Intents minted, by what was bought", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 16, "y": 7 }, + "gridPos": { "h": 7, "w": 8, "x": 16, "y": 13 }, "fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 50 } }, "overrides": [] @@ -207,7 +302,7 @@ { "type": "row", "title": "App health", - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 20 }, "collapsed": false, "panels": [] }, @@ -215,7 +310,7 @@ "type": "timeseries", "title": "Portal API calls / sec", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 0, "y": 15 }, + "gridPos": { "h": 7, "w": 8, "x": 0, "y": 21 }, "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }, "targets": [ { @@ -229,7 +324,7 @@ "type": "timeseries", "title": "Latency p50 / p95 / p99", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 8, "y": 15 }, + "gridPos": { "h": 7, "w": 8, "x": 8, "y": 21 }, "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, "targets": [ { @@ -253,7 +348,7 @@ "type": "timeseries", "title": "Slowest procedures (p99)", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 16, "y": 15 }, + "gridPos": { "h": 7, "w": 8, "x": 16, "y": 21 }, "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, "targets": [ { @@ -267,7 +362,7 @@ "type": "timeseries", "title": "Process memory", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 6, "w": 12, "x": 0, "y": 22 }, + "gridPos": { "h": 6, "w": 12, "x": 0, "y": 28 }, "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }, "targets": [ { @@ -286,7 +381,7 @@ "type": "timeseries", "title": "Event loop lag (p99)", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 6, "w": 12, "x": 12, "y": 22 }, + "gridPos": { "h": 6, "w": 12, "x": 12, "y": 28 }, "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, "targets": [ { diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml index fd446485..dc988376 100644 --- a/monitoring/prometheus.yml +++ b/monitoring/prometheus.yml @@ -29,8 +29,9 @@ scrape_configs: # # Read the numbers it returns with the instance model in mind. App Hosting # autoscales, so each scrape lands on one arbitrary instance: - # - dsgt_members_*, dsgt_payments_* are read from the database on scrape and - # are therefore correct regardless of which instance answered. + # - dsgt_members_*, dsgt_payments_*, and dsgt_hackathon_* are read from the + # database on scrape and are therefore correct regardless of which + # instance answered. # - counters and histograms are that one instance's, since its own start, # and vanish when it scales down. Treat them as a sample, not a total. # diff --git a/monitoring/rules/hackathon.yml b/monitoring/rules/hackathon.yml new file mode 100644 index 00000000..2f275809 --- /dev/null +++ b/monitoring/rules/hackathon.yml @@ -0,0 +1,26 @@ +# Alerts for a live hackathon edition. The series are database gauges, so +# they are the same number on every instance. See monitoring/README.md. + +groups: + - name: hackathon + rules: + # Judging switched on, tables assigned, and still no scores. Thirty + # minutes is long enough that flipping the switch before judges reach + # the room does not page, and short enough that a broken submit does + # not sit through a whole judging block. + - alert: HackathonJudgingIdle + expr: >- + ( + dsgt_hackathon_judging_active > 0 + and on (edition) (dsgt_hackathon_judging_projects > 0) + ) + unless on (edition) (dsgt_hackathon_votes > 0) + for: 30m + labels: + severity: page + annotations: + summary: "Judging is on for {{ $labels.edition }} and no scores have landed" + description: >- + The edition has judging switched on and projects on the floor, and + the vote count is still zero. Judges may be blocked from + submitting, or judging was opened before anyone was at a table. diff --git a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts index e4fefad7..50f9a94d 100644 --- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts +++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts @@ -1417,17 +1417,17 @@ describe("Hackathon admin management edge cases", () => { // ===================================================================== describe("Day-of dashboard", () => { - // BUG: analyticsOverview counts eventCheckIns (the club check-in table), - // not hackathonEventAttendees, which is what scanParticipantPass writes. - it("counts today's hackathon badge scans", async () => { + it("counts today's hackathon badge scans apart from club door check-ins", async () => { const caller = adminCaller({}, "admin"); mockCount.mockImplementation((t: string) => - t === "hackathonEventAttendees" ? 7 : 0, + t === "hackathonEventAttendees" ? 7 : t === "eventCheckIns" ? 2 : 0, ); const res = await caller.admin.analyticsOverview(); - expect(res.checkinsToday).toBe(7); + expect(res.badgeScansToday).toBe(7); + expect(res.doorCheckinsToday).toBe(2); + expect(res.checkinsToday).toBe(9); }); }); }); diff --git a/packages/api/src/routers/admin.ts b/packages/api/src/routers/admin.ts index 96de8cdb..c180ea82 100644 --- a/packages/api/src/routers/admin.ts +++ b/packages/api/src/routers/admin.ts @@ -18,6 +18,7 @@ import { compareTerms, currentTerm } from "@query/db/services/membership"; import { isExpiredAdmin, isStaffRole } from "../types/portal-context"; import type { DrizzleDB } from "@query/db"; import { startOfEasternDay } from "../services/eastern-time"; +import { readLiveHackathons } from "../services/metrics"; export const adminRouter = createTRPCRouter({ isAdmin: protectedProcedure.query(async ({ ctx }) => { @@ -78,6 +79,8 @@ export const adminRouter = createTRPCRouter({ totalEvents: number; totalHackathons: number; checkinsToday: number; + badgeScansToday: number; + doorCheckinsToday: number; }>( cacheKey, async () => { @@ -114,13 +117,19 @@ export const adminRouter = createTRPCRouter({ .where(gte(eventCheckIns.checkedInAt, startOfToday)), ]); + const badgeScansToday = badgeScansResult[0]?.count ?? 0; + const doorCheckinsToday = doorCheckinsResult[0]?.count ?? 0; + const result = { totalParticipants: participantsResult[0]?.count ?? 0, totalEvents: eventsResult[0]?.count ?? 0, totalHackathons: hackathonsResult[0]?.count ?? 0, - checkinsToday: - (badgeScansResult[0]?.count ?? 0) + - (doorCheckinsResult[0]?.count ?? 0), + // Kept as the sum so a caller that only knew the blended number + // still moves. The page reads the two halves, because a hackathon + // weekend and a club meeting are not the same turnout. + badgeScansToday, + doorCheckinsToday, + checkinsToday: badgeScansToday + doorCheckinsToday, }; return result; @@ -129,6 +138,16 @@ export const adminRouter = createTRPCRouter({ ); }), + // The same counts the Prometheus gauges use, for the analytics page. A + // scrape is optional; this is what staff open during the weekend. + hackathonLive: isAdmin.query(async ({ ctx }) => { + return await ctx.cache.getOrSet( + "admin:hackathon-live", + () => readLiveHackathons(ctx.db as DrizzleDB), + 15, + ); + }), + // Membership growth for the Insights page. One pass over the member table // instead of a bucket query per month: the club is a few thousand rows, and // the cumulative series has to see everything before the window anyway. diff --git a/packages/api/src/services/hackathon-metrics.test.ts b/packages/api/src/services/hackathon-metrics.test.ts new file mode 100644 index 00000000..d7a52982 --- /dev/null +++ b/packages/api/src/services/hackathon-metrics.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { getTableName } from "drizzle-orm"; +import type { DrizzleDB } from "@query/db"; +import { readLiveHackathons, refreshDbGauges, registry } from "./metrics"; + +const rules = readFileSync( + fileURLToPath( + new URL("../../../../monitoring/rules/hackathon.yml", import.meta.url), + ), + "utf-8", +); + +type GaugeReading = { + values: { value: number; labels: Record }[]; +}; + +/** Drizzle conditions point back at their tables, so this only follows SQL text. */ +function sqlText(condition: unknown) { + const parts: string[] = []; + const visit = (value: unknown, seen: Set) => { + if (typeof value === "string") { + parts.push(value); + return; + } + if (!value || typeof value !== "object" || seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + for (const item of value) visit(item, seen); + return; + } + const record = value as { value?: unknown; queryChunks?: unknown }; + if ("value" in record) visit(record.value, seen); + if ("queryChunks" in record) visit(record.queryChunks, seen); + }; + visit(condition, new Set()); + return parts.join(" "); +} + +function readings(name: string) { + return registry.getSingleMetric(name) as { + get: () => Promise | GaugeReading; + set: (labels: Record, value: number) => void; + }; +} + +describe("hackathon gauges", () => { + it("counts a live edition and drops one that is no longer live", async () => { + readings("dsgt_hackathon_projects").set( + { edition: "Hacklytics 2026", phase: "completed" }, + 40, + ); + + const queries: string[] = []; + const rowsFor = (table: string) => { + if (table === "hackathon") { + return [ + { + id: "hack_1", + edition: "Hacklytics 2027", + phase: "in_progress", + judgingActive: true, + }, + ]; + } + if (table === "hackathon_participant") { + return [ + { hackathonId: "hack_1", status: "approved", total: 12 }, + { hackathonId: "hack_1", status: "checked_in", total: 3 }, + ]; + } + if (table === "hackathon_project") { + return [{ hackathonId: "hack_1", total: 4 }]; + } + if (table === "hackathon_event_attendee") { + return [{ hackathonId: "hack_1", total: 9 }]; + } + if (table === "judging_project") { + return [{ hackathonId: "hack_1", total: 4 }]; + } + return []; + }; + + const db = { + select() { + return { + from(table: Parameters[0]) { + const pending: any = Promise.resolve(rowsFor(getTableName(table))); + pending.from = () => pending; + pending.innerJoin = () => pending; + pending.groupBy = () => pending; + pending.where = (condition: unknown) => { + queries.push(sqlText(condition)); + return pending; + }; + return pending; + }, + }; + }, + execute: async (query: unknown) => { + const text = JSON.stringify(query); + if (text.includes("member_resume")) { + return { rows: [{ files: "1", bytes: "10" }] }; + } + if (text.includes("metadata")) { + return { rows: [{ plan: "annual", total: "2" }] }; + } + return { + rows: [{ active: "5", lapsed: "1", bootcamp: "2", unlinked: "0" }], + }; + }, + }; + + const live = await readLiveHackathons(db as unknown as DrizzleDB); + expect(live).toEqual([ + { + edition: "Hacklytics 2027", + phase: "in_progress", + judgingActive: true, + projects: 4, + checkinsToday: 9, + judgingProjects: 4, + votes: 0, + participants: { + pending: 0, + approved: 12, + rejected: 0, + waitlisted: 0, + checked_in: 3, + }, + }, + ]); + + await refreshDbGauges(db as unknown as DrizzleDB); + + const projects = (await readings("dsgt_hackathon_projects").get()).values; + expect(projects).toEqual([ + { + labels: { edition: "Hacklytics 2027", phase: "in_progress" }, + value: 4, + }, + ]); + + const participants = (await readings("dsgt_hackathon_participants").get()) + .values; + expect( + participants.find((row) => row.labels.status === "checked_in")?.value, + ).toBe(3); + expect( + participants.find((row) => row.labels.status === "pending")?.value, + ).toBe(0); + expect( + participants.find((row) => row.labels.edition === "Hacklytics 2026"), + ).toBeUndefined(); + + expect( + (await readings("dsgt_hackathon_checkins_today").get()).values[0]?.value, + ).toBe(9); + expect( + (await readings("dsgt_hackathon_votes").get()).values[0]?.value, + ).toBe(0); + expect( + (await readings("dsgt_hackathon_judging_active").get()).values[0]?.value, + ).toBe(1); + + const filters = queries.join("\n"); + expect(filters).toContain("in_progress"); + expect(filters).toContain("draft"); + expect(filters).toContain("is null"); + expect(filters).not.toContain("completed"); + }); + + it("alerts when judging is on, tables are assigned, and no scores exist", () => { + expect(rules).toContain("alert: HackathonJudgingIdle"); + expect(rules).toContain("dsgt_hackathon_judging_active"); + expect(rules).toContain("dsgt_hackathon_judging_projects"); + expect(rules).toContain("dsgt_hackathon_votes"); + expect(rules).toContain("for: 30m"); + }); +}); diff --git a/packages/api/src/services/metrics.ts b/packages/api/src/services/metrics.ts index f73541b0..6fa6508c 100644 --- a/packages/api/src/services/metrics.ts +++ b/packages/api/src/services/metrics.ts @@ -12,8 +12,33 @@ import { Gauge, collectDefaultMetrics, } from "prom-client"; -import { sql } from "drizzle-orm"; -import type { DrizzleDB } from "@query/db"; +import { + and, + count, + eq, + gt, + gte, + inArray, + isNotNull, + isNull, + lte, + ne, + sum, +} from "drizzle-orm"; +import { + type DrizzleDB, + hackathonEventAttendees, + hackathonEvents, + hackathonParticipants, + hackathonProjects, + hackathons, + judgeVotes, + judgingProjects, + memberResumes, + members, + stripePayments, +} from "@query/db"; +import { startOfEasternDay } from "./eastern-time"; export const registry = new Registry(); @@ -137,6 +162,215 @@ const gaugeRefreshFailures = new Counter({ registers: [registry], }); +// Live editions only: open (registration), closed (registration shut, event +// not finished), in progress (the weekend). A completed edition drops off on +// the next refresh, so last year does not sit on the dashboard as current. +// Same rule as the other gauges — counted from the database, so which instance +// Prometheus scraped does not matter. + +const PARTICIPANT_STATUSES = [ + "pending", + "approved", + "rejected", + "waitlisted", + "checked_in", +] as const; + +const participantGauge = new Gauge({ + name: "dsgt_hackathon_participants", + help: "Registrations for a live hackathon edition, by status.", + labelNames: ["edition", "phase", "status"] as const, + registers: [registry], +}); + +const projectGauge = new Gauge({ + name: "dsgt_hackathon_projects", + help: "Submitted projects for a live edition, excluding admin withdrawals and drafts.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonCheckinsToday = new Gauge({ + name: "dsgt_hackathon_checkins_today", + help: "Hackathon badge scans since midnight Eastern, for a live edition.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonJudgingProjects = new Gauge({ + name: "dsgt_hackathon_judging_projects", + help: "Projects on the judging floor for a live edition, excluding withdrawals.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonVotes = new Gauge({ + name: "dsgt_hackathon_votes", + help: "Judge scores recorded for a live edition.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonJudgingActive = new Gauge({ + name: "dsgt_hackathon_judging_active", + help: "1 when judging is switched on for a live edition, otherwise 0.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +export type LiveHackathon = { + edition: string; + phase: string; + judgingActive: boolean; + projects: number; + checkinsToday: number; + judgingProjects: number; + votes: number; + participants: Record<(typeof PARTICIPANT_STATUSES)[number], number>; +}; + +const LIVE_PHASES = ["open", "closed", "in_progress"] as const; + +const blankParticipants = (): LiveHackathon["participants"] => ({ + pending: 0, + approved: 0, + rejected: 0, + waitlisted: 0, + checked_in: 0, +}); + +const countByEdition = ( + rows: { hackathonId: string; total: number }[], +) => { + const totals = new Map(); + for (const row of rows) totals.set(row.hackathonId, Number(row.total)); + return totals; +}; + +// Shared with the admin analytics page. Prometheus is optional; the portal +// has to be able to show the same counts when nothing is scraping. +export async function readLiveHackathons( + db: DrizzleDB, + now = new Date(), +): Promise { + const startOfToday = startOfEasternDay(now); + + const editions = await db + .select({ + id: hackathons.id, + edition: hackathons.name, + phase: hackathons.status, + judgingActive: hackathons.judgingActive, + }) + .from(hackathons) + .where(inArray(hackathons.status, [...LIVE_PHASES])); + + if (editions.length === 0) return []; + + const ids = editions.map((edition) => edition.id); + + const [byStatus, projectRows, scanRows, judgingRows, voteRows] = + await Promise.all([ + db + .select({ + hackathonId: hackathonParticipants.hackathonId, + status: hackathonParticipants.registrationStatus, + total: count(), + }) + .from(hackathonParticipants) + .where(inArray(hackathonParticipants.hackathonId, ids)) + .groupBy( + hackathonParticipants.hackathonId, + hackathonParticipants.registrationStatus, + ), + db + .select({ + hackathonId: hackathonProjects.hackathonId, + total: count(), + }) + .from(hackathonProjects) + .where( + and( + inArray(hackathonProjects.hackathonId, ids), + ne(hackathonProjects.status, "draft"), + isNull(hackathonProjects.withdrawnByAdminAt), + ), + ) + .groupBy(hackathonProjects.hackathonId), + db + .select({ + hackathonId: hackathonEvents.hackathonId, + total: count(), + }) + .from(hackathonEventAttendees) + .innerJoin( + hackathonEvents, + eq(hackathonEvents.id, hackathonEventAttendees.eventId), + ) + .where( + and( + inArray(hackathonEvents.hackathonId, ids), + gte(hackathonEventAttendees.checkedInAt, startOfToday), + ), + ) + .groupBy(hackathonEvents.hackathonId), + db + .select({ + hackathonId: judgingProjects.hackathonId, + total: count(), + }) + .from(judgingProjects) + .where( + and( + inArray(judgingProjects.hackathonId, ids), + isNull(judgingProjects.withdrawnAt), + ), + ) + .groupBy(judgingProjects.hackathonId), + db + .select({ + hackathonId: judgingProjects.hackathonId, + total: count(), + }) + .from(judgeVotes) + .innerJoin( + judgingProjects, + eq(judgingProjects.id, judgeVotes.projectId), + ) + .where( + and( + inArray(judgingProjects.hackathonId, ids), + isNull(judgingProjects.withdrawnAt), + ), + ) + .groupBy(judgingProjects.hackathonId), + ]); + + const participantsByEdition = new Map(); + for (const row of byStatus) { + const bucket = + participantsByEdition.get(row.hackathonId) ?? blankParticipants(); + if (row.status in bucket) bucket[row.status] = Number(row.total); + participantsByEdition.set(row.hackathonId, bucket); + } + + const projects = countByEdition(projectRows); + const scans = countByEdition(scanRows); + const judging = countByEdition(judgingRows); + const votes = countByEdition(voteRows); + + return editions.map((edition) => ({ + edition: edition.edition, + phase: edition.phase, + judgingActive: edition.judgingActive, + projects: projects.get(edition.id) ?? 0, + checkinsToday: scans.get(edition.id) ?? 0, + judgingProjects: judging.get(edition.id) ?? 0, + votes: votes.get(edition.id) ?? 0, + participants: participantsByEdition.get(edition.id) ?? blankParticipants(), + })); +} + /** Long enough that a 30s scrape loop cannot turn into a query loop. */ const GAUGE_TTL_MS = 60_000; @@ -171,6 +405,14 @@ const gaugeCache = new GaugeCache(); // Eastern time like currentTerm, or the label flips four hours early at the // May and December boundaries. If the two disagree the gauge is mislabelled, // nothing more. +/** Null metadata is a year. Anything that is not a JSON object is left out. */ +function planFromMetadata(metadata: string | null) { + if (metadata == null) return "annual"; + if (!metadata.startsWith("{")) return null; + const parsed = JSON.parse(metadata) as { plan?: unknown }; + return typeof parsed.plan === "string" && parsed.plan ? parsed.plan : "annual"; +} + const currentTermLabel = (now = new Date()) => { const parts = new Intl.DateTimeFormat("en-US", { timeZone: "America/New_York", @@ -191,65 +433,103 @@ export async function refreshDbGauges(db: DrizzleDB | null | undefined) { await gaugeCache.refresh(async () => { try { const term = currentTermLabel(); - - const totals = await db.execute<{ - active: string; - lapsed: string; - bootcamp: string; - unlinked: string; - }>(sql` - select - count(*) filter ( - where "is_active" and "membership_end_date" > now() - ) as active, - count(*) filter ( - where "membership_end_date" is not null - and "membership_end_date" <= now() - ) as lapsed, - count(*) filter (where "bootcamp_term" = ${term}) as bootcamp, - ( - select count(*) from "stripe_payment" - where "payment_status" = 'paid' and "linked_user_id" is null - ) as unlinked - from "member" - `); - - const counts = totals.rows[0]; - - if (counts) { - membersActive.set(Number(counts.active)); - membersLapsed.set(Number(counts.lapsed)); - bootcampEnrolled.set({ term }, Number(counts.bootcamp)); - paymentsUnlinked.set(Number(counts.unlinked)); + const now = new Date(); + + const [ + [active], + [lapsed], + [bootcamp], + [unlinked], + paidRows, + [resumeTotals], + ] = await Promise.all([ + db + .select({ total: count() }) + .from(members) + .where( + and(eq(members.isActive, true), gt(members.membershipEndDate, now)), + ), + db + .select({ total: count() }) + .from(members) + .where( + and( + isNotNull(members.membershipEndDate), + lte(members.membershipEndDate, now), + ), + ), + db + .select({ total: count() }) + .from(members) + .where(eq(members.bootcampTerm, term)), + db + .select({ total: count() }) + .from(stripePayments) + .where( + and( + eq(stripePayments.paymentStatus, "paid"), + isNull(stripePayments.linkedUserId), + ), + ), + db + .select({ metadata: stripePayments.metadata }) + .from(stripePayments) + .where(eq(stripePayments.paymentStatus, "paid")), + db + .select({ + files: count(), + bytes: sum(memberResumes.sizeBytes), + }) + .from(memberResumes), + ]); + + membersActive.set(Number(active?.total ?? 0)); + membersLapsed.set(Number(lapsed?.total ?? 0)); + bootcampEnrolled.set({ term }, Number(bootcamp?.total ?? 0)); + paymentsUnlinked.set(Number(unlinked?.total ?? 0)); + + // Plan lives in the payment metadata string. Rows that are not JSON are + // skipped; a missing plan is the only product that existed then, a year. + // One unparseable object still fails the refresh, same as a bad cast did. + const plans = new Map(); + for (const row of paidRows) { + const plan = planFromMetadata(row.metadata); + if (!plan) continue; + plans.set(plan, (plans.get(plan) ?? 0) + 1); } - // `like '{%'` before the jsonb cast, deliberately: one row of unparseable - // metadata would error the whole statement, and rows written before the plan - // existed have no `plan` key — those bought the only thing on offer, a year. - const byPlan = await db.execute<{ plan: string; total: string }>(sql` - select - coalesce(("metadata"::jsonb ->> 'plan'), 'annual') as plan, - count(*) as total - from "stripe_payment" - where "payment_status" = 'paid' - and ("metadata" is null or "metadata" like '{%') - group by 1 - `); - paymentsByPlan.reset(); - for (const row of byPlan.rows) { - paymentsByPlan.set({ plan: row.plan }, Number(row.total)); + for (const [plan, total] of plans) { + paymentsByPlan.set({ plan }, total); } - const resumes = await db.execute<{ files: string; bytes: string }>(sql` - select count(*) as files, coalesce(sum("size_bytes"), 0) as bytes - from "member_resume" - `); - - const resumeTotals = resumes.rows[0]; - if (resumeTotals) { - resumesStored.set(Number(resumeTotals.files)); - resumeBytesStored.set(Number(resumeTotals.bytes)); + resumesStored.set(Number(resumeTotals?.files ?? 0)); + resumeBytesStored.set(Number(resumeTotals?.bytes ?? 0)); + + const live = await readLiveHackathons(db); + + // reset before set: an edition that left the live set has to disappear, + // or the dashboard keeps yesterday's registration count. + participantGauge.reset(); + projectGauge.reset(); + hackathonCheckinsToday.reset(); + hackathonJudgingProjects.reset(); + hackathonVotes.reset(); + hackathonJudgingActive.reset(); + + for (const edition of live) { + const labels = { edition: edition.edition, phase: edition.phase }; + projectGauge.set(labels, edition.projects); + hackathonCheckinsToday.set(labels, edition.checkinsToday); + hackathonJudgingProjects.set(labels, edition.judgingProjects); + hackathonVotes.set(labels, edition.votes); + hackathonJudgingActive.set(labels, edition.judgingActive ? 1 : 0); + for (const status of PARTICIPANT_STATUSES) { + participantGauge.set( + { ...labels, status }, + edition.participants[status], + ); + } } } catch { // Stale gauges beat a 500 on the scrape endpoint. diff --git a/packages/db/scripts/export-clickhouse.mts b/packages/db/scripts/export-clickhouse.mts index b4941d00..77535c6e 100644 --- a/packages/db/scripts/export-clickhouse.mts +++ b/packages/db/scripts/export-clickhouse.mts @@ -1,16 +1,19 @@ /** - * Copies payment and membership history from Postgres into ClickHouse. + * Copies payment, membership, and hackathon history from Postgres into ClickHouse. * * pnpm --filter @query/db export:clickhouse * - * Read-only against Postgres — it runs two SELECTs and writes nothing back. + * Read-only against Postgres — it runs SELECTs and writes nothing back. * That is deliberate: the application database is a 0.5 GB Neon instance, so * history that exists to be aggregated belongs somewhere that is not it. * - * Safe to re-run. Both ClickHouse tables are ReplacingMergeTree keyed on the + * Safe to re-run. The ClickHouse tables are ReplacingMergeTree keyed on the * row id, so a second run replaces rows rather than duplicating them, and the * whole thing can go on a cron without any watermark bookkeeping. * + * Hackathon rows are ids, edition names, and statuses. Names and emails stay + * in Postgres. + * * Env: * DATABASE_URL Postgres to read from. Prod is fine — nothing is written. * CLICKHOUSE_URL default http://localhost:8123 @@ -18,7 +21,22 @@ * CLICKHOUSE_PASSWORD default dsgt * CLICKHOUSE_DB default dsgt */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { eq } from "drizzle-orm"; import { Pool } from "pg"; +import { + hackathonEventAttendees, + hackathonEvents, + hackathonParticipants, + hackathonProjects, + hackathons, + judgeVotes, + judgingProjects, + membershipHistory, + stripePayments, +} from "../src/schemas"; const { DATABASE_URL, @@ -64,6 +82,42 @@ async function insert(table: string, rows: unknown[]) { const stamp = (value: Date | null | undefined) => (value ?? new Date(0)).toISOString().replace("T", " ").replace("Z", ""); +const stampOrNull = (value: Date | null | undefined) => + value ? stamp(value) : null; + +/** Applies monitoring/clickhouse/init/02-hackathon.sql. Init only runs on an empty volume. */ +async function ensureHackathonTables() { + const file = fileURLToPath( + new URL( + "../../../monitoring/clickhouse/init/02-hackathon.sql", + import.meta.url, + ), + ); + const statements = readFileSync(file, "utf-8") + .split("\n") + .filter((line) => !line.trim().startsWith("--")) + .join("\n") + .split(";") + .map((statement) => statement.trim()) + .filter(Boolean); + + for (const statement of statements) { + const url = `${CLICKHOUSE_URL}/?query=${encodeURIComponent(statement)}`; + const res = await fetch(url, { + method: "POST", + headers: { + "X-ClickHouse-User": CLICKHOUSE_USER, + "X-ClickHouse-Key": CLICKHOUSE_PASSWORD, + }, + }); + if (!res.ok) { + throw new Error( + `ClickHouse rejected schema setup: ${res.status} ${await res.text()}`, + ); + } + } +} + const readJson = (raw: string | null) => { if (!raw) return {} as Record; try { @@ -74,76 +128,161 @@ const readJson = (raw: string | null) => { }; const pool = new Pool({ connectionString: DATABASE_URL, max: 2 }); +const db = drizzle(pool); try { - const payments = await pool.query<{ - id: string; - created_at: Date; - updated_at: Date | null; - amount_total: number | null; - currency: string | null; - payment_status: string; - metadata: string | null; - linked_user_id: string | null; - customer_email: string; - }>( - `select id, created_at, updated_at, amount_total, currency, - payment_status, metadata, linked_user_id, customer_email - from "stripe_payment"`, - ); + await ensureHackathonTables(); + const payments = await db + .select({ + id: stripePayments.id, + createdAt: stripePayments.createdAt, + updatedAt: stripePayments.updatedAt, + amountTotal: stripePayments.amountTotal, + currency: stripePayments.currency, + paymentStatus: stripePayments.paymentStatus, + metadata: stripePayments.metadata, + linkedUserId: stripePayments.linkedUserId, + customerEmail: stripePayments.customerEmail, + }) + .from(stripePayments); - const paymentRows = payments.rows.map((row) => { + const paymentRows = payments.map((row) => { const meta = readJson(row.metadata); return { id: row.id, - created_at: stamp(row.created_at), - updated_at: stamp(row.updated_at ?? row.created_at), - amount_cents: row.amount_total ?? 0, + created_at: stamp(row.createdAt), + updated_at: stamp(row.updatedAt ?? row.createdAt), + amount_cents: row.amountTotal ?? 0, currency: row.currency ?? "usd", - payment_status: row.payment_status, + payment_status: row.paymentStatus, // Rows written before the plan existed bought the only thing on offer. plan: meta.plan === "semester" ? "semester" : "annual", bootcamp: meta.bootcamp === "true" ? 1 : 0, addon_only: meta.type === "bootcamp_addon" ? 1 : 0, - linked: row.linked_user_id ? 1 : 0, - customer_email: row.customer_email, + linked: row.linkedUserId ? 1 : 0, + customer_email: row.customerEmail, }; }); - const history = await pool.query<{ - id: string; - member_id: string; - action: string; - start_date: Date | null; - end_date: Date | null; - created_at: Date; - }>( - `select id, member_id, action, start_date, end_date, created_at - from "membership_history"`, - ); + const history = await db + .select({ + id: membershipHistory.id, + memberId: membershipHistory.memberId, + action: membershipHistory.action, + startDate: membershipHistory.startDate, + endDate: membershipHistory.endDate, + createdAt: membershipHistory.createdAt, + }) + .from(membershipHistory); const DAY_MS = 24 * 60 * 60 * 1000; - const historyRows = history.rows.map((row) => ({ + const historyRows = history.map((row) => ({ id: row.id, - member_id: row.member_id, + member_id: row.memberId, action: row.action, - start_date: stamp(row.start_date), - end_date: stamp(row.end_date), - created_at: stamp(row.created_at), + start_date: stamp(row.startDate), + end_date: stamp(row.endDate), + created_at: stamp(row.createdAt), term_days: - row.start_date && row.end_date + row.startDate && row.endDate ? Math.round( - (row.end_date.getTime() - row.start_date.getTime()) / DAY_MS, + (row.endDate.getTime() - row.startDate.getTime()) / DAY_MS, ) : 0, })); + const registrations = await db + .select({ + id: hackathonParticipants.id, + hackathon: hackathons.name, + status: hackathonParticipants.registrationStatus, + registeredAt: hackathonParticipants.registeredAt, + updatedAt: hackathonParticipants.updatedAt, + }) + .from(hackathonParticipants) + .innerJoin( + hackathons, + eq(hackathons.id, hackathonParticipants.hackathonId), + ); + + const registrationRows = registrations.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + status: row.status, + registered_at: stamp(row.registeredAt), + updated_at: stamp(row.updatedAt), + })); + + const projects = await db + .select({ + id: hackathonProjects.id, + hackathon: hackathons.name, + status: hackathonProjects.status, + submittedAt: hackathonProjects.submittedAt, + updatedAt: hackathonProjects.updatedAt, + }) + .from(hackathonProjects) + .innerJoin(hackathons, eq(hackathons.id, hackathonProjects.hackathonId)); + + const projectRows = projects.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + status: row.status, + submitted_at: stampOrNull(row.submittedAt), + updated_at: stamp(row.updatedAt), + })); + + const scans = await db + .select({ + id: hackathonEventAttendees.id, + hackathon: hackathons.name, + eventName: hackathonEvents.name, + checkedInAt: hackathonEventAttendees.checkedInAt, + }) + .from(hackathonEventAttendees) + .innerJoin( + hackathonEvents, + eq(hackathonEvents.id, hackathonEventAttendees.eventId), + ) + .innerJoin(hackathons, eq(hackathons.id, hackathonEvents.hackathonId)); + + const scanRows = scans.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + event_name: row.eventName, + checked_in_at: stamp(row.checkedInAt), + })); + + const votes = await db + .select({ + id: judgeVotes.id, + hackathon: hackathons.name, + score: judgeVotes.score, + votedAt: judgeVotes.votedAt, + updatedAt: judgeVotes.updatedAt, + }) + .from(judgeVotes) + .innerJoin(judgingProjects, eq(judgingProjects.id, judgeVotes.projectId)) + .innerJoin(hackathons, eq(hackathons.id, judgingProjects.hackathonId)); + + const voteRows = votes.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + score: row.score, + voted_at: stamp(row.votedAt), + updated_at: stamp(row.updatedAt), + })); + const written = (await insert("payments", paymentRows)) + - (await insert("membership_events", historyRows)); + (await insert("membership_events", historyRows)) + + (await insert("hackathon_registrations", registrationRows)) + + (await insert("hackathon_projects", projectRows)) + + (await insert("hackathon_scans", scanRows)) + + (await insert("hackathon_votes", voteRows)); console.log( - `Exported ${paymentRows.length} payments and ${historyRows.length} membership events (${written} rows).`, + `Exported ${paymentRows.length} payments, ${historyRows.length} membership events, ${registrationRows.length} registrations, ${projectRows.length} projects, ${scanRows.length} scans, and ${voteRows.length} votes (${written} rows).`, ); } catch (error) { console.error("Export failed:", error); diff --git a/sites/mainweb/app/(portal)/admin/analytics/page.tsx b/sites/mainweb/app/(portal)/admin/analytics/page.tsx index 13b57a95..ea777b3d 100644 --- a/sites/mainweb/app/(portal)/admin/analytics/page.tsx +++ b/sites/mainweb/app/(portal)/admin/analytics/page.tsx @@ -12,6 +12,7 @@ import { itemTitle, label, meta, + body, pageDek, sectionTitle, } from "@/components/portal/ui"; @@ -69,6 +70,13 @@ function readTokens(element: HTMLElement): ChartTokens { }; } +function phaseLabel(phase: string) { + if (phase === "open") return "Registration open"; + if (phase === "closed") return "Registration closed"; + if (phase === "in_progress") return "In progress"; + return phase; +} + /** `2026-fall` is how it is stored; nobody should have to read it that way. */ function termLabel(term: string) { const [year, season] = term.split("-"); @@ -145,6 +153,11 @@ export default function AnalyticsPage() { { enabled: !!session, refetchInterval: 15000 }, ); + const live = trpc.admin.hackathonLive.useQuery(undefined, { + enabled: !!session, + refetchInterval: 15000, + }); + // Growth moves on a monthly clock, so it is fetched once rather than polled. const growth = trpc.admin.growth.useQuery(undefined, { enabled: !!session }); @@ -475,15 +488,15 @@ export default function AnalyticsPage() { {/* Events and hackathons */}

Events and hackathons

-
+
{isLoading ? ( - [1, 2, 3, 4].map((i) => ) + [1, 2, 3, 4, 5].map((i) => ) ) : ( <> + )}
+ +
+

Live hackathon

+

+ Editions that are open, registration-closed, or in progress. These + are the same counts the metrics endpoint publishes. +

+ {live.isPending ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ ) : !live.data?.length ? ( +

+ No edition is open, registration-closed, or in progress. +

+ ) : ( + live.data.map((edition) => { + const judgingIdle = + edition.judgingActive && + edition.judgingProjects > 0 && + edition.votes === 0; + return ( +
+

{edition.edition}

+

{phaseLabel(edition.phase)}

+ {judgingIdle && ( +

+ Judging is on and no scores have landed. +

+ )} +
+ + + + + + + +
+

+ Waitlisted {edition.participants.waitlisted} · Rejected{" "} + {edition.participants.rejected} +

+
+ ); + }) + )} +
); } From aaad2da573abe51dbbbba54e0462b4d409fc863a Mon Sep 17 00:00:00 2001 From: aamoghS Date: Wed, 7 Oct 2026 18:17:20 -0400 Subject: [PATCH 02/82] feat(panel): start a standalone judging core Ranking and dispatch have to be extractable later, so they live under judging/ as pure functions with no host imports. Co-authored-by: Cursor --- PLAN.md | 454 ++++++++++++++++++ judging/LICENSE | 21 + judging/README.md | 32 ++ judging/core/eslint.config.mjs | 45 ++ judging/core/package.json | 22 + judging/core/src/aggregate/bayes.test.ts | 27 ++ judging/core/src/aggregate/bayes.ts | 19 + judging/core/src/aggregate/blend.test.ts | 18 + judging/core/src/aggregate/blend.ts | 11 + .../core/src/aggregate/bradleyTerry.test.ts | 50 ++ judging/core/src/aggregate/bradleyTerry.ts | 72 +++ judging/core/src/aggregate/rank.test.ts | 204 ++++++++ judging/core/src/aggregate/rank.ts | 304 ++++++++++++ judging/core/src/aggregate/zscore.test.ts | 79 +++ judging/core/src/aggregate/zscore.ts | 22 + judging/core/src/comparison.ts | 5 + judging/core/src/dispatch.test.ts | 139 ++++++ judging/core/src/dispatch.ts | 128 +++++ judging/core/src/distance.test.ts | 31 ++ judging/core/src/distance.ts | 30 ++ judging/core/src/events.test.ts | 34 ++ judging/core/src/events.ts | 55 +++ judging/core/src/index.ts | 54 +++ judging/core/src/match.test.ts | 21 + judging/core/src/match.ts | 20 + judging/core/src/package.test.ts | 14 + judging/core/src/phase.test.ts | 59 +++ judging/core/src/phase.ts | 63 +++ judging/core/src/rubric.test.ts | 67 +++ judging/core/src/rubric.ts | 85 ++++ judging/core/src/timers.test.ts | 88 ++++ judging/core/src/timers.ts | 74 +++ judging/core/src/uncertainty.test.ts | 66 +++ judging/core/src/uncertainty.ts | 66 +++ judging/core/tsconfig.json | 10 + judging/core/vitest.config.ts | 7 + judging/docs/adr/001-standalone.md | 22 + judging/docs/adr/002-pure-core.md | 22 + package.json | 2 +- pnpm-lock.yaml | 18 + pnpm-workspace.yaml | 1 + 41 files changed, 2560 insertions(+), 1 deletion(-) create mode 100644 PLAN.md create mode 100644 judging/LICENSE create mode 100644 judging/README.md create mode 100644 judging/core/eslint.config.mjs create mode 100644 judging/core/package.json create mode 100644 judging/core/src/aggregate/bayes.test.ts create mode 100644 judging/core/src/aggregate/bayes.ts create mode 100644 judging/core/src/aggregate/blend.test.ts create mode 100644 judging/core/src/aggregate/blend.ts create mode 100644 judging/core/src/aggregate/bradleyTerry.test.ts create mode 100644 judging/core/src/aggregate/bradleyTerry.ts create mode 100644 judging/core/src/aggregate/rank.test.ts create mode 100644 judging/core/src/aggregate/rank.ts create mode 100644 judging/core/src/aggregate/zscore.test.ts create mode 100644 judging/core/src/aggregate/zscore.ts create mode 100644 judging/core/src/comparison.ts create mode 100644 judging/core/src/dispatch.test.ts create mode 100644 judging/core/src/dispatch.ts create mode 100644 judging/core/src/distance.test.ts create mode 100644 judging/core/src/distance.ts create mode 100644 judging/core/src/events.test.ts create mode 100644 judging/core/src/events.ts create mode 100644 judging/core/src/index.ts create mode 100644 judging/core/src/match.test.ts create mode 100644 judging/core/src/match.ts create mode 100644 judging/core/src/package.test.ts create mode 100644 judging/core/src/phase.test.ts create mode 100644 judging/core/src/phase.ts create mode 100644 judging/core/src/rubric.test.ts create mode 100644 judging/core/src/rubric.ts create mode 100644 judging/core/src/timers.test.ts create mode 100644 judging/core/src/timers.ts create mode 100644 judging/core/src/uncertainty.test.ts create mode 100644 judging/core/src/uncertainty.ts create mode 100644 judging/core/tsconfig.json create mode 100644 judging/core/vitest.config.ts create mode 100644 judging/docs/adr/001-standalone.md create mode 100644 judging/docs/adr/002-pure-core.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..00cca049 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,454 @@ +# Judging platform plan + +**Repo:** `query` · **Drafted:** 2026-10-07 · **Working name:** `panel` (rename freely) + +A standalone hackathon judging platform, built inside this monorepo under +`judging/`, with no knowledge of DSGT in it. Hacklytics is its first user +through an adapter. When it is stable it leaves this repo with +`git subtree split --prefix=judging` and ships as an open-source project with +its own licence, docs, CI and Docker image. + +The current judging code in `packages/api/src/routers/judge` and +`sites/mainweb` keeps running until cutover, then is deleted. + +--- + +## 0. Why build it this way + +**What exists** works for one event: pool dispatch with QR arrival +(`dispatch.ts`), per-judge z-scores with a Bayesian shrink (`rankings.ts`), +frozen results, printable table cards. It is also welded to one edition: a +five-column rubric in `judge_vote`, timers as constants, tracks as `text[]`, +a `"createx"` string in the matcher, one results track, table distance as +`abs(n1 - n2)`, auth and roles borrowed from the club portal, cache +per-instance and polling for every live view. + +**What "real infrastructure" means here** + +1. **A product, not a feature.** Own data model, own API, own UI, own deploy. + Anyone with Postgres can run it. The club site is a client of it. +2. **Everything is data.** Rubric, tracks, prizes, timers, rooms, scoring + weights, dispatch strategy, branding: rows and config, never code. +3. **Correct under load and partition.** Advisory locks and unique indexes for + every write that matters; realtime as a convenience layer that can be + switched off without losing correctness. +4. **Boring and portable.** Postgres, Node, one Docker image, optional Redis. + No vendor SDK in the core. Cloud bits live behind adapters. +5. **Pure core.** Every decision (next table, score, rank) is a function from + plain data to plain data, tested without a database. + +--- + +## 1. Layout + +``` +judging/ + core/ @panel/core pure TS, zero runtime deps. rubric validation, + dispatch, distance, uncertainty, aggregators, + phase state machine, timers + db/ @panel/db Drizzle schema, SQL migrations (generated, + committed), seed, demo data + server/ @panel/server Node service: tRPC over HTTP, OpenAPI mirror, + WebSocket hub, auth adapters, outbox + webhooks, + Prometheus metrics + web/ @panel/web Next.js: judge PWA, admin console, live board, + public leaderboard, team feedback + cli/ @panel/cli import/export/seed/migrate/doctor + infra/ + docker/ Dockerfiles, docker-compose.yml (full stack) + gcp/ optional Terraform module (Cloud Run, Memorystore, + Secret Manager, Monitoring) + docs/ architecture, ADRs, deploy, API, rubric guide + README.md LICENSE (MIT) CONTRIBUTING.md CHANGELOG.md +``` + +Registered as workspaces in `pnpm-workspace.yaml`. Internal imports are +`@panel/*` only; nothing under `judging/` imports from `packages/*` or +`sites/*`. A lint rule (`no-restricted-imports`) enforces the boundary, which is +what makes the later extraction a one-command job. + +The reverse direction, `packages/api` importing `@panel/*`, is allowed and is +how the club site integrates (§7). + +--- + +## 2. Domain model (`@panel/db`) + +Multi-tenant from day one so one deployment can host many events; a +single-event self-host simply has one org and one event. + +``` +organization id, slug, name, branding jsonb (logo, colors, tagline) +event id, org_id, slug, name, starts_at, ends_at, + phase enum (setup | submissions_open | submissions_closed | + judging_live | judging_closed | published | archived) +event_config event_id pk, target_seconds, hard_limit_seconds, + walk_limit_seconds, submit_grace_seconds, + min_looks_per_project, dispatch_strategy (coverage|uncertainty), + pairwise_enabled, pairwise_weight, bayesian_c, + calibration_looks, calibration_weight, + feedback_cards_enabled, leaderboard_public bool, + board_poll_seconds (fallback when ws is off) + +track id, event_id, slug, name, kind (main|sponsor|special), + judge_group text, rubric_id null, position, is_active +prize id, track_id, place, title, amount, description + +rubric id, event_id, name, is_default +criterion id, rubric_id, position, key, label, description, + min, max, weight, anchors jsonb + +zone id, event_id, name, position +table event_id, number, zone_id, x null, y null unique(event_id, number) + +project id, event_id, external_id null, name, description, + team_name, members jsonb, links jsonb, table_number null, + zone_id null, qr_token uuid unique, arrived_first_at, + withdrawn_at +project_track project_id, track_id + +judge id, event_id, external_id null, name, email, org, title, + status (invited|applied|approved|suspended), + track_id null, zone_id null, is_lead + unique(event_id, email) + +visit id, event_id, judge_id, project_id, judge_group, + handed_out_at, arrived_at null, completed_at null, + voided_at null, void_reason null +vote id, visit_id unique, judge_id, project_id, event_id, + total numeric, comment, duration_seconds, is_calibration +vote_score vote_id, criterion_id, value unique(vote_id, criterion_id) +comparison id, event_id, judge_id, judge_group, a_project_id, + b_project_id, outcome (a|b|tie), created_at + +result_run id, event_id, computed_at, computed_by, config_snapshot jsonb, + published_at null, notes +result run_id, project_id, track_id, placement, score numeric, + rubric_component, pairwise_component, vote_count, + comparison_count, flags text[] + unique(run_id, project_id, track_id) + +event_log id, event_id, kind, actor jsonb, subject jsonb, payload jsonb, + created_at -- append only +outbox id, event_id, topic, payload jsonb, created_at, + delivered_at null, attempts +webhook id, org_id, url, secret, topics text[], is_active +api_key id, org_id, hashed_key, scopes text[], created_at, revoked_at + +user id, email, name -- platform identities +membership user_id, org_id, role (owner|admin|organizer|volunteer) +judge_identity judge_id, user_id -- which login is which judge +``` + +Design notes: + +- `visit` replaces `judge_queue`: it is a record of a hand-out, not a plan. + A visit without a vote past the hard limit is voided, not deleted. +- `vote.total` is derived at write time from `vote_score` and the criterion + weights; the score rows are the truth. +- `result_run` snapshots the config used, so a result is reproducible and an + admin can compute twice and compare before publishing one. +- `external_id` on project and judge is how an integrator (the club site, a + Devpost CSV, a Google Form) keeps its own identity in sync. +- Migrations are generated SQL files committed under `judging/db/migrations`, + applied with `panel migrate`. No `drizzle-kit push` in this product; a + self-hoster needs reproducible, reviewable migrations. + +--- + +## 3. The core (`@panel/core`) + +No IO, no dates from the clock (every function takes `now`), no randomness +without an injected source. Each module has a fixture-based test file. + +- `phase.ts` state machine: allowed transitions, what each phase permits + (submit, apply, dispatch, vote, compute, publish). Server checks it on + every mutation. +- `rubric.ts` `validateScores(criteria, input)`, `weightedTotal(criteria, scores)`. +- `timers.ts` `isLive`, `isPastCutoff`, taking the config. +- `distance.ts` `distance(a, b, zones)`: same zone `abs(n1 - n2)`; different + zone a large constant plus the diff; Euclidean when `x, y` set. +- `dispatch.ts` `pickNext(candidates, judge, config, random)` with strategies: + - `coverage`: fewest looks, nearest, random (today's algorithm). + - `uncertainty`: coverage until `min_looks_per_project` everywhere, then + highest rank-uncertainty first. Uncertainty = variance of normalised + scores / sqrt(votes), plus a bonus for contested pairwise neighbours. +- `aggregate/zscore.ts`, `aggregate/bayes.ts`, `aggregate/bradleyTerry.ts`, + `aggregate/blend.ts`, `aggregate/rank.ts` composing them per track and + judge group, honouring calibration down-weighting. +- `uncertainty.ts` per-project vector, consumed by dispatch. +- `events.ts` typed `kind` union for the log, outbox topics and ws messages, + shared by server and web. + +Published to npm on release so anyone can run the ranking offline on an +export. + +--- + +## 4. Server (`@panel/server`) + +Node 22, Hono as the HTTP shell, tRPC router mounted on it, `trpc-openapi` +exposing the same procedures as REST with a generated OpenAPI document for +non-TypeScript integrators. Single process; horizontal scale is stateless +plus Redis. + +**Routers** + +- `org`, `event`, `config`, `track`, `prize`, `rubric`, `zone`, `table`: + organizer CRUD, phase-gated. +- `project`: import (API, CSV via CLI), assign tables (per zone, by track + balance), withdraw, QR token rotate. +- `judge`: invite, apply, approve, suspend, assign track and zone. +- `session` (judge-facing): `next`, `arrive(qrToken)`, `vote`, `compare`, + `skip`, `progress`, `rubric`. +- `results`: `compute` (new `result_run`), `diff(runA, runB)`, `publish`, + `unpublish`, `export`. +- `live`: snapshot for the admin board and public leaderboard. +- `feedback`: a team's card after publish. +- `webhook`, `apiKey`: integrator management. + +**Concurrency** + +- `pg_advisory_xact_lock(hashtext('dispatch:' || event_id))` around `next`, + exactly as today. Dispatch is milliseconds and runs a few times a minute + even at 60 judges. +- Unique indexes on `(judge_id, project_id)` across live visits (partial + index where `voided_at is null`), on `vote.visit_id`, on + `(run_id, project_id, track_id)`. +- Every mutation writes its `event_log` row and any `outbox` row in the same + transaction. The outbox is drained by the same process on a short loop (no + external scheduler) and by `panel outbox drain` for operators who want cron. + +**Realtime** + +- WebSocket hub on `/ws` (`ws` library). Clients subscribe to channels: + `event:{id}:board`, `event:{id}:leaderboard`, `judge:{id}`. +- Fan-out adapter interface: `InMemoryBus` (single instance, default) and + `RedisBus` (ioredis pub/sub) selected by `REDIS_URL`. Correctness never + depends on the bus; a client that misses a message gets the same state on + its next snapshot, and the web falls back to polling at `board_poll_seconds` + when the socket is down. +- Server pushes: dispatch handed out, arrival, vote landed, judge overtime, + phase change, results published. Admin can push `recall` to a judge + (bring them back to the desk) and `void` a visit. + +**Auth adapters** (`server/src/auth/`) + +- `magic-link`: email code, built in, zero config beyond SMTP. +- `oidc`: any provider (Google, GitHub, Okta) via `openid-client`. +- `trusted-jwt`: an integrator (the club site) mints a short-lived JWT with + `{ sub, email, name, org, role, judge_external_id }`; the server verifies + with a shared JWKS. This is how mainweb users land in the judge PWA with + no second login. +- `api-key`: for machine integrators (import, export, webhooks). + +Roles: `owner`, `admin`, `organizer`, `volunteer` on the org; `judge` is a +row on the event, not a role, and `is_lead` is a flag. Every procedure +declares the minimum role; there is no unguarded "protected" alias, the lesson +from `../PLAN.md` W8. + +**Observability** + +- `/metrics` Prometheus: dispatch latency, votes per minute, live judges, + coverage histogram per event, ws connections, outbox lag. +- Structured JSON logs with `event_id`, `judge_id`, `visit_id` on every line. +- `/healthz` (process), `/readyz` (Postgres, bus). + +--- + +## 5. Web (`@panel/web`) + +Next.js app, standalone output, one Docker image. Branding from +`organization.branding`; no hardcoded name or colours. + +- **Judge PWA** (`/j/[eventSlug]`): installable, works on a phone with one + hand. Screens: queue/next, walking (shows table, zone and a count-up), + arrive by camera QR or typed table number, score (criteria rendered from + rubric rows, anchors as tap targets, comment), compare (previous table vs + this one, one tap), done. Scores are held in IndexedDB until the server + acknowledges, so a dead Wi-Fi corner loses nothing; the server rejects a + late vote past the cutoff and the client shows why. +- **Admin console** (`/o/[orgSlug]/[eventSlug]`): phase control, rubric and + track editors, zone and table layout (grid with drag to set `x, y`), + judges (invite, approve, assign, recall), projects (import, tables, + withdraw), table cards print view per zone, live board (coverage heatmap by + table, judges on the floor, overtime, idle), results (compute, diff two + runs, inspect a project's rubric and pairwise components, publish), logs. +- **Public** (`/e/[orgSlug]/[eventSlug]`): leaderboard after publish, live + "judging in progress" board if `leaderboard_public` (no scores, just + coverage and phase), prize list per track. +- **Team feedback** (`/e/.../feedback/[token]`): per-criterion mean vs event + median, anonymised comments, placement per track or "not placed". Token + minted per project at publish; integrators can fetch it by `external_id`. + +--- + +## 6. CLI (`@panel/cli`) + +`panel migrate`, `panel seed demo` (an event with 40 projects, 6 judges, +three tracks, a sponsor rubric), `panel import projects ` (column map in +a yaml; Devpost export supported out of the box), `panel export results + --format csv|json`, `panel outbox drain`, `panel doctor` (checks +Postgres, Redis, migrations, config). + +CSV import was removed from the club site on purpose (`../PLAN.md` §"what not +to build"). It is in the product because a self-hoster has no other source. +The club adapter does not use it; it imports through the API. + +--- + +## 7. Integrating the club site + +Thin and replaceable. + +- `packages/api/src/routers/judging.ts` (new, small): on `promoteSubmissions` + calls `@panel/server` `project.upsert` with `external_id = hackathon_project.id`; + on judge approval calls `judge.upsert`; on publish, pulls results and + feedback tokens back into `hackathon_result` so `/hackathons/[id]` keeps + rendering from its own tables. +- `sites/mainweb` `/judge` becomes a trusted-JWT handoff to the judge PWA. + Admin "Judging" tab deep-links into the console with the same handoff. +- Old `routers/judge/*` and the judging UI stay behind a feature flag per + edition (`hackathon.judging_backend = legacy | panel`) until one event has + run on the new system, then are deleted. +- Deploy: `@panel/server` and `@panel/web` as two Cloud Run services in the + same GCP project, Postgres as a second Neon database (not a schema in the + club DB, so the extraction is clean), Memorystore Redis only if more than + one server instance is wanted. Terraform under `judging/infra/gcp`. + +--- + +## 8. Phases + +Each phase ships green on `pnpm test`, `lint --max-warnings 0`, `typecheck`, +`build`, and leaves the current event-day path untouched. + +### Phase 0 — Scaffold and core (1 week) + +- Workspaces, tsconfig, eslint boundary rule, `judging/README.md` with the + vision, MIT `LICENSE`, ADR-001 (why standalone), ADR-002 (pure core). +- Port `pickNext`, `isLive`, `isPastCutoff`, `projectMatchesTrack`, + `zNormalize`, the Bayesian step into `@panel/core` as parametrised + functions. Bring `dispatch.test.ts` cases with them. +- Add `distance`, `phase`, `rubric`, `bradleyTerry`, `blend`, `uncertainty` + with tests. + +**Verify:** `@panel/core` has no dependencies and 100% of exported functions +have a test. The legacy `rankings.ts` output on the seed equals +`@panel/core` `rank()` output byte for byte at `pairwise_weight = 0`. + +### Phase 1 — Data and server (2 weeks) + +- Schema and first migration. Seed and demo. +- Hono + tRPC + OpenAPI. Routers from §4 except `live` and `feedback`. +- Auth: magic-link and trusted-jwt. Roles enforced per procedure. +- Advisory lock dispatch, visit voiding, outbox table and in-process drain. +- `event_log` on every mutation. `/metrics`, `/healthz`, `/readyz`. +- Dockerfile, `docker-compose.yml` (Postgres, server). + +**Verify:** DB tests: two concurrent `next` calls never hand out the same +table; a vote after cutoff is rejected with the configured limit in the +message; phase machine refuses `vote` outside `judging_live`; every mutation +in the flow test produces exactly one log row. `panel doctor` passes in the +compose stack. + +### Phase 2 — Judge PWA and admin console (3 weeks) + +- Judge flow end to end with offline hold. Camera QR via `getUserMedia`. +- Admin: phases, rubric, tracks, prizes, zones, tables, judges, projects, + table cards, results compute and publish. +- Branding from org row. + +**Verify:** Playwright: seed demo, approve a judge, run a full visit on a +phone viewport, compute, publish, see the leaderboard. Kill the network +mid-score, restore, vote lands once. + +### Phase 3 — Realtime and live board (1 week) + +- WebSocket hub, `InMemoryBus`, `RedisBus`. Live board and leaderboard + subscribe; fallback polling. +- Admin `recall` and `void` pushes. + +**Verify:** two server instances behind compose with Redis; a vote on one +appears on a board connected to the other within one second. Stop Redis; +boards degrade to polling with a visible indicator and no errors. + +### Phase 4 — Hybrid scoring and uncertainty dispatch (2 weeks) + +- Compare screen, `comparison` rows, Bradley–Terry in results, blend + components visible in the results inspector and the run diff. +- `uncertainty` strategy with a cached vector per event (in-process, hint + only). +- Team feedback cards and tokens. + +**Verify:** property test: consistent comparisons produce the transitive +order at `w = 1`; `w = 0` reproduces Phase 2 results. Simulation: 200 +projects, 20 judges, three synthetic hours; top-10 Kendall tau is higher +under `uncertainty` than `coverage` at equal looks. Feedback returns 403 +before publish and for the wrong token. + +### Phase 5 — Club integration and first live event (2 weeks plus the event) + +- `routers/judging.ts` adapter, trusted-JWT handoff, results pull-back, + feature flag per edition. +- Terraform: two Cloud Run services, Neon connection via Secret Manager, + optional Memorystore, uptime check and alert on `/readyz`. +- Run a small internal event on `panel`, then Hacklytics. + +**Verify:** the legacy and new systems produce the same placings on a replay +of an old event's votes. Post-event retro recorded as ADR-00x. + +### Phase 6 — Open-source release (1 week) + +- `CONTRIBUTING.md`, issue templates, GitHub Actions (test, lint, typecheck, + build, Docker publish to GHCR, `@panel/core` to npm on tag). +- Docs: quick start (`docker compose up`, `panel seed demo`), deploy guide, + API reference from OpenAPI, rubric design guide, scoring explainer. +- `git subtree split --prefix=judging -b panel-main`, push to the new repo, + replace `judging/` here with a git submodule or a pinned npm dependency. +- Delete `routers/judge/*` and the legacy judging UI from the club site. + +**Verify:** a fresh clone of the new repo runs the demo in under five +minutes with nothing but Docker. No string "DSGT", "Hacklytics" or +"datasciencegt" anywhere under `judging/`. + +--- + +## 9. Decisions taken and open + +**Taken** + +- Standalone product in `judging/`, extracted later by subtree. Not a schema + inside the club database, so the extraction has no data entanglement. +- WebSockets on our own server with a bus adapter; Redis optional. +- Terraform for GCP as an optional module; Docker Compose is the primary + documented deploy. +- Committed SQL migrations, not `push`. +- Pairwise comparisons against the judge's previous table, not a + server-chosen comparator (no extra walk, no recall burden). Revisit after + the Phase 4 simulation. +- CSV import exists in the product; the club adapter does not use it. + +**Open** + +- **Name.** `panel` is a placeholder; check npm and GitHub availability + before Phase 6. +- **Rank visibility.** Teams outside the podium see "not placed", not a + number. Organizer's call per event; default off. +- **Hosted mode.** Multi-tenant tables exist, but billing, org sign-up and + abuse controls are not in this plan. Self-host first. +- **Peer voting.** Possible as `judge_group = 'peer'` with its own weight. + Not designed here. + +--- + +## 10. Not in this plan + +- A scheduler or job queue service. The outbox drain runs in-process; cron is + an operator option, not a requirement. +- Devpost API sync. CSV import covers it; an API adapter can come as a + community contribution. +- Replacing the club site's registration, check-in, teams or submissions. + Those stay in `packages/api`; only judging moves. +- Any change to `packages/db` schema beyond the `judging_backend` flag and the + feedback token column needed for cutover. diff --git a/judging/LICENSE b/judging/LICENSE new file mode 100644 index 00000000..354a5c40 --- /dev/null +++ b/judging/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Panel contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/judging/README.md b/judging/README.md new file mode 100644 index 00000000..5b99dcec --- /dev/null +++ b/judging/README.md @@ -0,0 +1,32 @@ +# Panel + +A hackathon judging product: its own data, its own API, its own web app. +Rubrics, tracks, timers, rooms, and dispatch strategy are rows, not code. +Postgres and Node are enough to run it. Redis is optional and never required +for a correct result. + +`judging/` does not import the host application. The host may call `@panel/*`. +When the product is stable it leaves this repository with +`git subtree split --prefix=judging`. + +## Layout + +| Path | Package | What it is | +| --- | --- | --- | +| `core/` | `@panel/core` | Pure functions. No I/O, no clock, no runtime dependencies. | +| `db/` | `@panel/db` | Schema and committed SQL migrations. Not built yet. | +| `server/` | `@panel/server` | HTTP API. Not built yet. | +| `web/` | `@panel/web` | Judge, admin, and public views. Not built yet. | +| `cli/` | `@panel/cli` | Migrate, seed, import, export. Not built yet. | + +## Run the core + +From the repository root: + +``` +pnpm --filter @panel/core test +pnpm --filter @panel/core typecheck +pnpm --filter @panel/core lint +``` + +`pnpm test` includes `@panel/core`. diff --git a/judging/core/eslint.config.mjs b/judging/core/eslint.config.mjs new file mode 100644 index 00000000..a7d0fb28 --- /dev/null +++ b/judging/core/eslint.config.mjs @@ -0,0 +1,45 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...config, + { + files: ["src/**/*.ts"], + ignores: ["src/**/*.test.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "process", + importNames: ["env"], + message: + "@panel/core takes its inputs as arguments. It does not read the environment.", + }, + ], + patterns: [ + { + group: ["@query", "@query/*"], + message: + "judging/ is a standalone product. Import from @panel/* only.", + }, + { + group: [ + "node:*", + "fs", + "path", + "crypto", + "node:fs", + "node:path", + "node:crypto", + ], + message: + "@panel/core has no runtime dependencies and does not call Node APIs.", + }, + ], + }, + ], + }, + }, +]; diff --git a/judging/core/package.json b/judging/core/package.json new file mode 100644 index 00000000..fd17a170 --- /dev/null +++ b/judging/core/package.json @@ -0,0 +1,22 @@ +{ + "name": "@panel/core", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Pure judging decisions: dispatch, rubric, phase, and ranking.", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc --noEmit", + "test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts" + }, + "devDependencies": { + "@query/eslint-config": "workspace:*", + "@query/tsconfig": "workspace:*", + "@types/node": "22.20.4", + "typescript": "7.0.2", + "vitest": "^5.0.1" + } +} diff --git a/judging/core/src/aggregate/bayes.test.ts b/judging/core/src/aggregate/bayes.test.ts new file mode 100644 index 00000000..df67d118 --- /dev/null +++ b/judging/core/src/aggregate/bayes.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { bayesianShrink, round2 } from "./bayes"; + +describe("bayesianShrink", () => { + it("is zero when nobody voted", () => { + expect(bayesianShrink(9, 0, 5, 2)).toBe(0); + }); + + it("weighs two votes equally with the prior at c = 2", () => { + expect(bayesianShrink(8, 2, 4, 2)).toBe(6); + }); + + it("approaches the project's own average as votes accumulate", () => { + const few = bayesianShrink(10, 1, 0, 2); + const many = bayesianShrink(10, 100, 0, 2); + expect(many).toBeGreaterThan(few); + expect(many).toBeGreaterThan(9.5); + }); +}); + +describe("round2", () => { + it("rounds half up to the hundredth", () => { + expect(round2(1.234)).toBe(1.23); + expect(round2(1.235)).toBe(1.24); + expect(round2(7.085)).toBe(7.09); + }); +}); diff --git a/judging/core/src/aggregate/bayes.ts b/judging/core/src/aggregate/bayes.ts new file mode 100644 index 00000000..15f764fe --- /dev/null +++ b/judging/core/src/aggregate/bayes.ts @@ -0,0 +1,19 @@ +/** + * Pull a project's normalised average toward the field when few judges have + * seen it. `c` is how many average votes the prior is worth. At `c = 2`, + * two real votes and the prior weigh the same. + */ +export function bayesianShrink( + normalizedAvg: number, + n: number, + globalAvg: number, + c: number, +): number { + if (n <= 0) return 0; + return (n / (n + c)) * normalizedAvg + (c / (n + c)) * globalAvg; +} + +/** Hundredths, same rounding the published placings use. */ +export function round2(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/judging/core/src/aggregate/blend.test.ts b/judging/core/src/aggregate/blend.test.ts new file mode 100644 index 00000000..d40cf033 --- /dev/null +++ b/judging/core/src/aggregate/blend.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { blend } from "./blend"; + +describe("blend", () => { + it("returns the rubric at weight 0 and the pairwise score at weight 1", () => { + expect(blend(4, 10, 0)).toBe(4); + expect(blend(4, 10, 1)).toBe(10); + }); + + it("takes the midpoint at a half", () => { + expect(blend(4, 10, 0.5)).toBe(7); + }); + + it("rejects a weight outside 0 to 1", () => { + expect(() => blend(1, 1, 1.1)).toThrow(/weight/); + expect(() => blend(1, 1, -0.1)).toThrow(/weight/); + }); +}); diff --git a/judging/core/src/aggregate/blend.ts b/judging/core/src/aggregate/blend.ts new file mode 100644 index 00000000..9b3bba95 --- /dev/null +++ b/judging/core/src/aggregate/blend.ts @@ -0,0 +1,11 @@ +/** `weight` is the share of the pairwise component. Zero is rubric only. */ +export function blend( + rubric: number, + pairwise: number, + weight: number, +): number { + if (!Number.isFinite(weight) || weight < 0 || weight > 1) { + throw new Error("pairwise weight must be between 0 and 1"); + } + return (1 - weight) * rubric + weight * pairwise; +} diff --git a/judging/core/src/aggregate/bradleyTerry.test.ts b/judging/core/src/aggregate/bradleyTerry.test.ts new file mode 100644 index 00000000..3a54f9b6 --- /dev/null +++ b/judging/core/src/aggregate/bradleyTerry.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { bradleyTerry } from "./bradleyTerry"; + +describe("bradleyTerry", () => { + it("returns an empty map when there is nobody", () => { + expect(bradleyTerry([], []).size).toBe(0); + }); + + it("leaves everyone equal when there are no comparisons", () => { + const strength = bradleyTerry(["a", "b"], []); + expect(strength.get("a")).toBe(1); + expect(strength.get("b")).toBe(1); + }); + + it("ranks a consistent set in transitive order", () => { + const strength = bradleyTerry( + ["a", "b", "c"], + [ + { a: "a", b: "b", outcome: "a" }, + { a: "b", b: "c", outcome: "a" }, + { a: "a", b: "c", outcome: "a" }, + ], + ); + const a = strength.get("a") ?? 0; + const b = strength.get("b") ?? 0; + const c = strength.get("c") ?? 0; + expect(a).toBeGreaterThan(b); + expect(b).toBeGreaterThan(c); + }); + + it("puts a tie closer together than a sweep", () => { + const tied = bradleyTerry( + ["a", "b"], + [ + { a: "a", b: "b", outcome: "tie" }, + { a: "a", b: "b", outcome: "tie" }, + ], + ); + const swept = bradleyTerry( + ["a", "b"], + [ + { a: "a", b: "b", outcome: "a" }, + { a: "a", b: "b", outcome: "a" }, + ], + ); + const tieGap = Math.abs((tied.get("a") ?? 0) - (tied.get("b") ?? 0)); + const sweepGap = Math.abs((swept.get("a") ?? 0) - (swept.get("b") ?? 0)); + expect(tieGap).toBeLessThan(sweepGap); + }); +}); diff --git a/judging/core/src/aggregate/bradleyTerry.ts b/judging/core/src/aggregate/bradleyTerry.ts new file mode 100644 index 00000000..71132a65 --- /dev/null +++ b/judging/core/src/aggregate/bradleyTerry.ts @@ -0,0 +1,72 @@ +import type { Comparison } from "../comparison"; + +/** + * Bradley-Terry strengths from pairwise outcomes. A tie is half a win each. + * Strengths are scaled so their mean is 1. Projects that never appear in a + * comparison are left at 1. Iteration is the minorization-maximization + * update; `iterations` is a cap, not a clock. + */ +export function bradleyTerry( + projectIds: readonly string[], + comparisons: readonly Comparison[], + iterations = 50, +): Map { + const ids = [...new Set(projectIds)]; + const strength = new Map(ids.map((id) => [id, 1])); + if (ids.length === 0 || comparisons.length === 0) return strength; + + const wins = new Map(ids.map((id) => [id, 0])); + const games = new Map>(); + + const addGame = (left: string, right: string) => { + const row = games.get(left) ?? new Map(); + row.set(right, (row.get(right) ?? 0) + 1); + games.set(left, row); + }; + + for (const comparison of comparisons) { + if ( + comparison.a === comparison.b || + !strength.has(comparison.a) || + !strength.has(comparison.b) + ) { + continue; + } + if (comparison.outcome === "tie") { + wins.set(comparison.a, (wins.get(comparison.a) ?? 0) + 0.5); + wins.set(comparison.b, (wins.get(comparison.b) ?? 0) + 0.5); + } else if (comparison.outcome === "a") { + wins.set(comparison.a, (wins.get(comparison.a) ?? 0) + 1); + } else { + wins.set(comparison.b, (wins.get(comparison.b) ?? 0) + 1); + } + addGame(comparison.a, comparison.b); + addGame(comparison.b, comparison.a); + } + + for (let step = 0; step < iterations; step += 1) { + const next = new Map(); + for (const id of ids) { + const opponents = games.get(id); + if (!opponents || opponents.size === 0) { + next.set(id, strength.get(id) ?? 1); + continue; + } + let denominator = 0; + for (const [other, count] of opponents) { + const pi = strength.get(id) ?? 1; + const pj = strength.get(other) ?? 1; + const sum = pi + pj; + if (sum > 0) denominator += count / sum; + } + const won = wins.get(id) ?? 0; + next.set(id, denominator > 0 ? won / denominator : 0); + } + let total = 0; + for (const value of next.values()) total += value; + const mean = total / ids.length || 1; + for (const id of ids) strength.set(id, (next.get(id) ?? 0) / mean); + } + + return strength; +} diff --git a/judging/core/src/aggregate/rank.test.ts b/judging/core/src/aggregate/rank.test.ts new file mode 100644 index 00000000..ce684acd --- /dev/null +++ b/judging/core/src/aggregate/rank.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import { zNormalize } from "./zscore"; +import { bayesianShrink, round2 } from "./bayes"; +import { rank } from "./rank"; +import type { ProjectInput, RankConfig, VoteInput } from "./rank"; + +/** + * The ranking the current event publishes: z-score per judge against the + * population mean and spread, round the project mean, shrink toward the mean + * of those means with c = 2, round again. Same raw score maps to one + * normalised value because the z-score is linear, so a value-keyed lookup + * and an index-aligned one agree. + */ +function legacyRank( + projects: readonly { id: string }[], + votes: readonly { judgeId: string; projectId: string; score: number }[], +) { + const byJudge = new Map(); + for (const vote of votes) { + const list = byJudge.get(vote.judgeId) ?? []; + list.push(vote.score); + byJudge.set(vote.judgeId, list); + } + const raw = [...byJudge.values()].flat(); + const globalMean = raw.length > 0 ? raw.reduce((a, b) => a + b, 0) / raw.length : 0; + const globalVariance = + raw.length > 0 + ? raw.reduce((sum, value) => sum + (value - globalMean) ** 2, 0) / raw.length + : 1; + const globalStd = Math.sqrt(globalVariance) || 1; + + const normalizedByJudge = new Map>(); + for (const [judgeId, scores] of byJudge) { + const normalized = zNormalize(scores, globalMean, globalStd); + const lookup = new Map(); + scores.forEach((score, index) => { + const existing = lookup.get(score); + const value = normalized[index] ?? globalMean; + lookup.set(score, existing !== undefined ? (existing + value) / 2 : value); + }); + normalizedByJudge.set(judgeId, lookup); + } + + const rows = projects.map((project) => { + const mine = votes.filter((vote) => vote.projectId === project.id); + const normalizedScores = mine.map( + (vote) => normalizedByJudge.get(vote.judgeId)?.get(vote.score) ?? vote.score, + ); + const normalizedAvg = + mine.length > 0 + ? round2(normalizedScores.reduce((a, b) => a + b, 0) / mine.length) + : 0; + return { id: project.id, voteCount: mine.length, normalizedAvg }; + }); + + const voted = rows.filter((row) => row.voteCount > 0); + const globalAvg = + voted.length > 0 + ? round2(voted.reduce((sum, row) => sum + row.normalizedAvg, 0) / voted.length) + : 0; + + return rows + .map((row) => ({ + id: row.id, + voteCount: row.voteCount, + score: + row.voteCount > 0 + ? round2(bayesianShrink(row.normalizedAvg, row.voteCount, globalAvg, 2)) + : 0, + })) + .filter((row) => row.voteCount > 0) + .sort((a, b) => b.score - a.score || projects.findIndex((p) => p.id === a.id) - projects.findIndex((p) => p.id === b.id)); +} + +const config: RankConfig = { + bayesianC: 2, + pairwiseWeight: 0, + calibrationWeight: 1, +}; + +const projects: ProjectInput[] = [ + { id: "A", trackIds: ["overall"] }, + { id: "B", trackIds: ["overall"] }, + { id: "C", trackIds: ["overall"] }, +]; + +function vote(judgeId: string, projectId: string, total: number): VoteInput { + return { judgeId, projectId, judgeGroup: "main", total }; +} + +describe("rank matches the current event's pipeline at pairwise weight 0", () => { + const votes = [ + vote("j1", "A", 8), + vote("j1", "B", 5), + vote("j1", "C", 2), + vote("j2", "A", 9), + vote("j2", "B", 9), + vote("j2", "C", 6), + ]; + + it("places the same projects at the same hundredths", () => { + const legacy = legacyRank(projects, votes.map((item) => ({ + judgeId: item.judgeId, + projectId: item.projectId, + score: item.total, + }))); + const ranked = rank(projects, votes, [], config) + .filter((row) => row.placement !== null) + .sort((a, b) => (a.placement ?? 0) - (b.placement ?? 0)); + + expect(ranked.map((row) => [row.projectId, row.score])).toEqual( + legacy.map((row) => [row.id, row.score]), + ); + expect(ranked.map((row) => row.score)).toEqual([7.71, 6.94, 4.85]); + }); + + it("ignores comparisons when the pairwise weight is zero", () => { + const withPairs = rank( + projects, + votes, + [ + { + judgeId: "j1", + judgeGroup: "main", + a: "C", + b: "A", + outcome: "a", + }, + ], + config, + ); + const without = rank(projects, votes, [], config); + expect(withPairs.map((row) => row.score)).toEqual(without.map((row) => row.score)); + }); +}); + +describe("rank", () => { + it("does not place a project nobody scored", () => { + const rows = rank( + [ + { id: "seen", trackIds: ["overall"] }, + { id: "missed", trackIds: ["overall"] }, + ], + [vote("j1", "seen", 8), vote("j2", "seen", 6)], + [], + config, + ); + const seen = rows.find((row) => row.projectId === "seen"); + const missed = rows.find((row) => row.projectId === "missed"); + expect(seen?.placement).toBe(1); + expect(missed?.placement).toBeNull(); + }); + + it("keeps judge groups apart", () => { + const rows = rank( + [{ id: "A", trackIds: ["overall"] }], + [ + { judgeId: "j1", projectId: "A", judgeGroup: "main", total: 10 }, + { judgeId: "j2", projectId: "A", judgeGroup: "sponsor", total: 1 }, + ], + [], + config, + ); + expect(rows).toHaveLength(2); + expect(rows.find((row) => row.judgeGroup === "main")?.voteCount).toBe(1); + expect(rows.find((row) => row.judgeGroup === "sponsor")?.voteCount).toBe(1); + }); + + it("drops a calibration vote whose weight is zero", () => { + const base = rank(projects, [vote("j1", "A", 8), vote("j1", "B", 4)], [], config); + const extra = rank( + projects, + [ + vote("j1", "A", 8), + vote("j1", "B", 4), + { ...vote("j1", "C", 1), isCalibration: true }, + ], + [], + { ...config, calibrationWeight: 0 }, + ); + const score = (rows: typeof base, id: string) => + rows.find((row) => row.projectId === id)?.score; + expect(score(extra, "A")).toBe(score(base, "A")); + expect(score(extra, "B")).toBe(score(base, "B")); + expect(extra.find((row) => row.projectId === "C")?.voteCount).toBe(0); + }); + + it("orders a pairwise-only field by who won, at weight 1", () => { + const rows = rank( + projects, + [], + [ + { judgeId: "j1", judgeGroup: "main", a: "A", b: "B", outcome: "a" }, + { judgeId: "j1", judgeGroup: "main", a: "B", b: "C", outcome: "a" }, + { judgeId: "j1", judgeGroup: "main", a: "A", b: "C", outcome: "a" }, + ], + { ...config, pairwiseWeight: 1 }, + ); + const placed = rows + .filter((row) => row.placement !== null) + .sort((a, b) => (a.placement ?? 0) - (b.placement ?? 0)); + expect(placed.map((row) => row.projectId)).toEqual(["A", "B", "C"]); + }); +}); diff --git a/judging/core/src/aggregate/rank.ts b/judging/core/src/aggregate/rank.ts new file mode 100644 index 00000000..0b687d32 --- /dev/null +++ b/judging/core/src/aggregate/rank.ts @@ -0,0 +1,304 @@ +import type { Comparison } from "../comparison"; +import { bayesianShrink, round2 } from "./bayes"; +import { bradleyTerry } from "./bradleyTerry"; +import { blend } from "./blend"; +import { zNormalize } from "./zscore"; + +export type VoteInput = { + judgeId: string; + projectId: string; + judgeGroup: string; + total: number; + isCalibration?: boolean; +}; + +export type RankComparison = Comparison & { + judgeId: string; + judgeGroup: string; +}; + +export type ProjectInput = { + id: string; + trackIds: readonly string[]; +}; + +export type RankConfig = { + bayesianC: number; + pairwiseWeight: number; + /** How much a calibration vote counts, relative to a normal vote of 1. */ + calibrationWeight: number; +}; + +export type RankedRow = { + projectId: string; + trackId: string; + judgeGroup: string; + placement: number | null; + score: number; + rubricComponent: number; + pairwiseComponent: number; + voteCount: number; + comparisonCount: number; +}; + +/** + * Rank each track and judge group. + * + * Rubric side, at `pairwiseWeight = 0` and `calibrationWeight = 1`: z-score + * each judge onto the group's mean and spread, average per project (rounded + * to hundredths), shrink toward the mean of those averages with `bayesianC`, + * round again, and place by that score. That is the pipeline the existing + * event uses, so a replay of its votes lands on the same numbers. + * + * Pairwise side: Bradley-Terry on comparisons in the group, scaled onto the + * rubric scores, then blended. Weight 0 leaves the rubric number untouched. + * A calibration vote is down-weighted in the average and in the shrink's + * sample size. It still informs that judge's own z-score. + */ +export function rank( + projects: readonly ProjectInput[], + votes: readonly VoteInput[], + comparisons: readonly RankComparison[], + config: RankConfig, +): RankedRow[] { + const known = new Set(projects.map((project) => project.id)); + const tracks = trackOrder(projects); + const groups = groupOrder(votes, comparisons); + const rows: RankedRow[] = []; + + for (const judgeGroup of groups) { + const groupVotes = votes.filter( + (vote) => vote.judgeGroup === judgeGroup && known.has(vote.projectId), + ); + const normalized = normalize(groupVotes, config); + for (const trackId of tracks) { + const onTrack = projects.filter((project) => + project.trackIds.includes(trackId), + ); + const onTrackIds = new Set(onTrack.map((project) => project.id)); + const trackVotes = groupVotes + .map((vote, index) => ({ + vote, + normalized: normalized[index] ?? 0, + })) + .filter(({ vote }) => onTrackIds.has(vote.projectId)); + const trackComparisons = comparisons.filter( + (comparison) => + comparison.judgeGroup === judgeGroup && + onTrackIds.has(comparison.a) && + onTrackIds.has(comparison.b), + ); + rows.push( + ...rankTrack( + onTrack, + trackVotes, + trackComparisons, + trackId, + judgeGroup, + config, + ), + ); + } + } + + return rows; +} + +function weightOf(vote: VoteInput, config: RankConfig): number { + return vote.isCalibration ? config.calibrationWeight : 1; +} + +function spread(scores: readonly number[]): { mean: number; std: number } { + if (scores.length === 0) return { mean: 0, std: 1 }; + const mean = scores.reduce((sum, value) => sum + value, 0) / scores.length; + const variance = + scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / scores.length; + return { mean, std: Math.sqrt(variance) || 1 }; +} + +function normalize( + votes: readonly VoteInput[], + config: RankConfig, +): number[] { + const weights = votes.map((vote) => weightOf(vote, config)); + const raw: number[] = []; + for (let index = 0; index < votes.length; index += 1) { + const vote = votes[index]; + if (vote && (weights[index] ?? 0) > 0) raw.push(vote.total); + } + const { mean, std } = spread(raw); + + const byJudge = new Map(); + votes.forEach((vote, index) => { + if ((weights[index] ?? 0) <= 0) return; + const list = byJudge.get(vote.judgeId) ?? []; + list.push(index); + byJudge.set(vote.judgeId, list); + }); + + const out = votes.map(() => 0); + for (const indexes of byJudge.values()) { + const scores = indexes.map((index) => votes[index]?.total ?? 0); + const normalized = zNormalize(scores, mean, std); + indexes.forEach((index, offset) => { + out[index] = normalized[offset] ?? 0; + }); + } + return out; +} + +type CarriedVote = { vote: VoteInput; normalized: number }; + +function rankTrack( + projects: readonly ProjectInput[], + votes: readonly CarriedVote[], + comparisons: readonly RankComparison[], + trackId: string, + judgeGroup: string, + config: RankConfig, +): RankedRow[] { + const stats = new Map(); + for (const project of projects) { + let weightedSum = 0; + let valueSum = 0; + let count = 0; + for (const item of votes) { + if (item.vote.projectId !== project.id) continue; + const weight = weightOf(item.vote, config); + if (weight <= 0) continue; + weightedSum += weight; + valueSum += item.normalized * weight; + count += 1; + } + stats.set(project.id, { + avg: weightedSum > 0 ? round2(valueSum / weightedSum) : 0, + n: weightedSum, + count, + }); + } + + const votedAverages: number[] = []; + for (const row of stats.values()) { + if (row.n > 0) votedAverages.push(row.avg); + } + const globalAvg = + votedAverages.length > 0 + ? round2( + votedAverages.reduce((sum, value) => sum + value, 0) / + votedAverages.length, + ) + : 0; + + const rubricScore = new Map(); + const anchors: number[] = []; + for (const project of projects) { + const row = stats.get(project.id); + const n = row?.n ?? 0; + const score = + n > 0 + ? round2(bayesianShrink(row?.avg ?? 0, n, globalAvg, config.bayesianC)) + : 0; + rubricScore.set(project.id, score); + if (n > 0) anchors.push(score); + } + + const compared = new Set(); + for (const comparison of comparisons) { + compared.add(comparison.a); + compared.add(comparison.b); + } + const scaled = scaleStrengths( + bradleyTerry([...compared], comparisons), + anchors, + compared, + ); + + const draft: RankedRow[] = projects.map((project) => { + const row = stats.get(project.id); + const rubricComponent = rubricScore.get(project.id) ?? 0; + const comparisonCount = comparisons.filter( + (comparison) => + comparison.a === project.id || comparison.b === project.id, + ).length; + const scaledValue = scaled.get(project.id); + const pairwiseForBlend = scaledValue ?? rubricComponent; + const score = + config.pairwiseWeight === 0 + ? rubricComponent + : round2( + blend(rubricComponent, pairwiseForBlend, config.pairwiseWeight), + ); + return { + projectId: project.id, + trackId, + judgeGroup, + placement: null, + score, + rubricComponent, + pairwiseComponent: scaledValue ?? 0, + voteCount: row?.count ?? 0, + comparisonCount, + }; + }); + + const order = new Map(projects.map((project, index) => [project.id, index])); + const placeable = draft.filter( + (row) => + row.voteCount > 0 || + (config.pairwiseWeight > 0 && row.comparisonCount > 0), + ); + placeable.sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; + return (order.get(a.projectId) ?? 0) - (order.get(b.projectId) ?? 0); + }); + placeable.forEach((row, index) => { + row.placement = index + 1; + }); + return draft; +} + +function scaleStrengths( + strength: ReadonlyMap, + anchors: readonly number[], + compared: ReadonlySet, +): Map { + const out = new Map(); + if (compared.size === 0) return out; + const values = [...compared].map((id) => strength.get(id) ?? 0); + const lowStrength = Math.min(...values); + const highStrength = Math.max(...values); + const low = anchors.length > 0 ? Math.min(...anchors) : lowStrength; + const high = anchors.length > 0 ? Math.max(...anchors) : highStrength; + for (const id of compared) { + const value = strength.get(id) ?? 0; + if (anchors.length === 0) out.set(id, value); + else if (highStrength === lowStrength) out.set(id, (low + high) / 2); + else { + out.set( + id, + low + + ((value - lowStrength) / (highStrength - lowStrength)) * (high - low), + ); + } + } + return out; +} + +function trackOrder(projects: readonly ProjectInput[]): string[] { + const seen = new Set(); + for (const project of projects) { + for (const trackId of project.trackIds) seen.add(trackId); + } + return [...seen]; +} + +function groupOrder( + votes: readonly VoteInput[], + comparisons: readonly RankComparison[], +): string[] { + const seen = new Set(); + for (const vote of votes) seen.add(vote.judgeGroup); + for (const comparison of comparisons) seen.add(comparison.judgeGroup); + if (seen.size === 0) seen.add("main"); + return [...seen]; +} diff --git a/judging/core/src/aggregate/zscore.test.ts b/judging/core/src/aggregate/zscore.test.ts new file mode 100644 index 00000000..29722da2 --- /dev/null +++ b/judging/core/src/aggregate/zscore.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { zNormalize } from "./zscore"; + +const mean = (xs: number[]) => xs.reduce((sum, value) => sum + value, 0) / xs.length; +const stddev = (xs: number[]) => { + const center = mean(xs); + return Math.sqrt(mean(xs.map((value) => (value - center) ** 2))); +}; + +describe("zNormalize", () => { + it("returns the global mean when fewer than two scores", () => { + expect(zNormalize([8], 5, 2)).toEqual([5]); + expect(zNormalize([], 5, 2)).toEqual([]); + }); + + it("returns the global mean when a judge scored everything the same", () => { + expect(zNormalize([7, 7, 7], 5, 2)).toEqual([5, 5, 5]); + }); + + it("spreads scores around the global mean", () => { + const normalized = zNormalize([2, 5, 8], 5, 2); + expect(normalized[0] ?? 0).toBeLessThan(5); + expect(normalized[1] ?? 0).toBeCloseTo(5, 1); + expect(normalized[2] ?? 0).toBeGreaterThan(5); + }); + + it("preserves a judge's own ordering", () => { + const normalized = zNormalize([1, 4, 9, 10], 5, 2); + for (let index = 1; index < normalized.length; index += 1) { + expect(normalized[index] ?? 0).toBeGreaterThan(normalized[index - 1] ?? 0); + } + }); + + it("makes a harsh judge and a lenient judge agree", () => { + const harsh = zNormalize([1, 2, 3], 5, 2); + const lenient = zNormalize([8, 9, 10], 5, 2); + expect(harsh[0] ?? 0).toBeCloseTo(lenient[0] ?? 0, 10); + expect(harsh[1] ?? 0).toBeCloseTo(lenient[1] ?? 0, 10); + expect(harsh[2] ?? 0).toBeCloseTo(lenient[2] ?? 0, 10); + }); + + it("makes a wide-spread judge and a narrow-spread judge agree", () => { + const wide = zNormalize([0, 5, 10], 5, 2); + const narrow = zNormalize([4, 5, 6], 5, 2); + expect(wide[0] ?? 0).toBeCloseTo(narrow[0] ?? 0, 10); + expect(wide[2] ?? 0).toBeCloseTo(narrow[2] ?? 0, 10); + }); + + it("centres the normalized scores on the global mean", () => { + expect(mean(zNormalize([1, 4, 9, 10], 7, 2))).toBeCloseTo(7, 10); + }); + + it("gives the normalized scores the global spread", () => { + expect(stddev(zNormalize([1, 4, 9, 10], 5, 3))).toBeCloseTo(3, 10); + }); + + it("never produces NaN or Infinity for degenerate input", () => { + const cases = [ + zNormalize([7, 7], 5, 2), + zNormalize([0, 0, 0], 5, 2), + zNormalize([5], 5, 0), + zNormalize([1, 2], 0, 0), + ]; + for (const result of cases) { + for (const value of result) expect(Number.isFinite(value)).toBe(true); + } + }); + + it("handles negative scores and keeps equals equal", () => { + const negative = zNormalize([-5, 0, 5], 5, 2); + expect(negative[0] ?? 0).toBeLessThan(negative[2] ?? 0); + const tied = zNormalize([3, 3, 9], 5, 2); + expect(tied[0] ?? 0).toBeCloseTo(tied[1] ?? 0, 10); + }); + + it("collapses to the global mean when the global spread is zero", () => { + expect(zNormalize([1, 5, 9], 6, 0)).toEqual([6, 6, 6]); + }); +}); diff --git a/judging/core/src/aggregate/zscore.ts b/judging/core/src/aggregate/zscore.ts new file mode 100644 index 00000000..e6c96bb4 --- /dev/null +++ b/judging/core/src/aggregate/zscore.ts @@ -0,0 +1,22 @@ +/** + * Z-score a judge's scores against their own mean and spread, then place + * that shape on the global mean and spread. A judge with fewer than two + * scores, or who gave everything the same number, has no spread of their + * own, so every score becomes the global mean. A global spread of zero + * collapses everyone onto the global mean too. + */ +export function zNormalize( + scores: readonly number[], + globalMean: number, + globalStd: number, +): number[] { + if (scores.length < 2) return scores.map(() => globalMean); + const mean = scores.reduce((sum, value) => sum + value, 0) / scores.length; + const variance = + scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / scores.length; + const std = Math.sqrt(variance); + if (std === 0 || globalStd === 0) return scores.map(() => globalMean); + return scores.map( + (value) => globalMean + ((value - mean) / std) * globalStd, + ); +} diff --git a/judging/core/src/comparison.ts b/judging/core/src/comparison.ts new file mode 100644 index 00000000..7937600b --- /dev/null +++ b/judging/core/src/comparison.ts @@ -0,0 +1,5 @@ +export type Comparison = { + a: string; + b: string; + outcome: "a" | "b" | "tie"; +}; diff --git a/judging/core/src/dispatch.test.ts b/judging/core/src/dispatch.test.ts new file mode 100644 index 00000000..9c2f0ec5 --- /dev/null +++ b/judging/core/src/dispatch.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { pickNext } from "./dispatch"; +import type { Candidate, DispatchConfig } from "./dispatch"; + +const t0 = new Date("2027-02-28T13:00:00Z"); +const at = (seconds: number) => new Date(t0.getTime() + seconds * 1000); +const first = () => 0; +const coverage: DispatchConfig = { + strategy: "coverage", + minLooksPerProject: 1, +}; + +const c = ( + id: string, + tableNumber: number, + looks = 0, + claimedAt: number | null = null, + extra: Partial = {}, +): Candidate => ({ + id, + tableNumber, + coverage: looks, + claimedAt, + ...extra, +}); + +describe("pickNext coverage", () => { + it("returns nothing when there is nothing left", () => { + expect(pickNext([], { lastTable: null }, coverage, first)).toBeNull(); + }); + + it("takes the least-covered project", () => { + expect( + pickNext( + [c("a", 1, 2), c("b", 2, 0), c("c", 3, 1)], + { lastTable: null }, + coverage, + first, + )?.id, + ).toBe("b"); + }); + + it("breaks a coverage tie by the table nearest the judge's last one", () => { + const pick = pickNext( + [c("far", 90, 1), c("near", 12, 1), c("mid", 40, 1)], + { lastTable: 10 }, + coverage, + first, + ); + expect(pick?.id).toBe("near"); + }); + + it("never trades coverage for distance", () => { + expect( + pickNext( + [c("next-door", 11, 2), c("across", 200, 1)], + { lastTable: 10 }, + coverage, + first, + )?.id, + ).toBe("across"); + }); + + it("passes over a table someone is at while a free one exists", () => { + expect( + pickNext( + [c("taken", 1, 0, at(0).getTime()), c("free", 2, 3)], + { lastTable: null }, + coverage, + first, + )?.id, + ).toBe("free"); + }); + + it("sends the judge to the longest-held table when every one is taken", () => { + const pick = pickNext( + [ + c("recent", 1, 0, at(100).getTime()), + c("oldest", 2, 0, at(0).getTime()), + ], + { lastTable: null }, + coverage, + first, + ); + expect(pick?.id).toBe("oldest"); + }); + + it("chooses among equals at random", () => { + const tier = [c("a", 5, 0), c("b", 15, 0)]; + expect(pickNext(tier, { lastTable: 10 }, coverage, () => 0)?.id).toBe("a"); + expect(pickNext(tier, { lastTable: 10 }, coverage, () => 1)?.id).toBe("b"); + }); + + it("prefers the same zone over a closer table number in another zone", () => { + const pick = pickNext( + [ + c("other-room", 2, 0, null, { zoneId: "atrium" }), + c("same-room", 30, 0, null, { zoneId: "hall" }), + ], + { lastTable: 4, zoneId: "hall" }, + coverage, + first, + ); + expect(pick?.id).toBe("same-room"); + }); +}); + +describe("pickNext uncertainty", () => { + const uncertainty: DispatchConfig = { + strategy: "uncertainty", + minLooksPerProject: 2, + }; + + it("keeps covering while any project is under the minimum", () => { + const pick = pickNext( + [ + c("thin", 1, 0, null, { uncertainty: 0 }), + c("shaky", 2, 5, null, { uncertainty: 9 }), + ], + { lastTable: null }, + uncertainty, + first, + ); + expect(pick?.id).toBe("thin"); + }); + + it("takes the least settled project once coverage is met", () => { + const pick = pickNext( + [ + c("settled", 1, 2, null, { uncertainty: 0.1 }), + c("open", 9, 2, null, { uncertainty: 4 }), + ], + { lastTable: 1 }, + uncertainty, + first, + ); + expect(pick?.id).toBe("open"); + }); +}); diff --git a/judging/core/src/dispatch.ts b/judging/core/src/dispatch.ts new file mode 100644 index 00000000..aa453f98 --- /dev/null +++ b/judging/core/src/dispatch.ts @@ -0,0 +1,128 @@ +import { distance } from "./distance"; +import type { TablePoint } from "./distance"; + +export type Candidate = { + id: string; + tableNumber: number; + zoneId?: string | null; + x?: number | null; + y?: number | null; + /** Scores plus live claims from judges in the same group. */ + coverage: number; + /** Most recent live claim by another judge, if any (ms epoch). */ + claimedAt: number | null; + /** Used by the uncertainty strategy once every project has its minimum looks. */ + uncertainty?: number; +}; + +export type JudgeSpot = { + lastTable: number | null; + zoneId?: string | null; + x?: number | null; + y?: number | null; +}; + +export type DispatchStrategy = "coverage" | "uncertainty"; + +export type DispatchConfig = { + strategy: DispatchStrategy; + /** Uncertainty waits until every candidate has at least this many looks. */ + minLooksPerProject: number; +}; + +/** + * The next project for one judge, from the projects they are eligible for + * and have not had. + * + * Coverage: fewest looks, then the nearest table, then `random`. A table + * someone is already at is passed over while any other exists. When every + * remaining one is taken, the one claimed longest ago, because an idle + * judge is worse than two at a table. + * + * Uncertainty: the same coverage rule until `minLooksPerProject` is met + * everywhere in `candidates`, then the highest uncertainty, then nearest. + */ +export function pickNext( + candidates: readonly Candidate[], + judge: JudgeSpot, + config: DispatchConfig, + random: (n: number) => number, +): Candidate | null { + if (candidates.length === 0) return null; + + const free = candidates.filter((candidate) => candidate.claimedAt === null); + if (free.length === 0) return oldestClaim(candidates); + + const stillCovering = + config.strategy === "coverage" || + candidates.some( + (candidate) => candidate.coverage < config.minLooksPerProject, + ); + + const tier = stillCovering ? fewestLooks(free) : highestUncertainty(free); + return narrow(tier, judge, random); +} + +function fewestLooks(free: readonly Candidate[]): Candidate[] { + const fewest = Math.min(...free.map((candidate) => candidate.coverage)); + return free.filter((candidate) => candidate.coverage === fewest); +} + +function highestUncertainty(free: readonly Candidate[]): Candidate[] { + const highest = Math.max( + ...free.map((candidate) => candidate.uncertainty ?? 0), + ); + return free.filter( + (candidate) => (candidate.uncertainty ?? 0) === highest, + ); +} + +function oldestClaim(candidates: readonly Candidate[]): Candidate | null { + let best: Candidate | null = null; + for (const candidate of candidates) { + if (!best || (candidate.claimedAt ?? 0) < (best.claimedAt ?? 0)) { + best = candidate; + } + } + return best; +} + +function narrow( + tier: readonly Candidate[], + judge: JudgeSpot, + random: (n: number) => number, +): Candidate | null { + if (tier.length === 0) return null; + if (judge.lastTable === null) return choose(tier, random); + + const here: TablePoint = { + number: judge.lastTable, + zoneId: judge.zoneId ?? null, + x: judge.x ?? null, + y: judge.y ?? null, + }; + const nearest = Math.min( + ...tier.map((candidate) => distance(here, candidatePoint(candidate))), + ); + const closest = tier.filter( + (candidate) => distance(here, candidatePoint(candidate)) === nearest, + ); + return choose(closest, random); +} + +function choose( + tier: readonly Candidate[], + random: (n: number) => number, +): Candidate | null { + if (tier.length === 0) return null; + return tier[random(tier.length)] ?? null; +} + +function candidatePoint(candidate: Candidate): TablePoint { + return { + number: candidate.tableNumber, + zoneId: candidate.zoneId ?? null, + x: candidate.x ?? null, + y: candidate.y ?? null, + }; +} diff --git a/judging/core/src/distance.test.ts b/judging/core/src/distance.test.ts new file mode 100644 index 00000000..3bd7ee03 --- /dev/null +++ b/judging/core/src/distance.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { distance, ZONE_HOP } from "./distance"; + +const table = ( + number: number, + zoneId: string | null = null, + x: number | null = null, + y: number | null = null, +) => ({ number, zoneId, x, y }); + +describe("distance", () => { + it("is the table-number gap inside one zone", () => { + expect(distance(table(4, "hall"), table(11, "hall"))).toBe(7); + }); + + it("is the same gap when neither table has a zone", () => { + expect(distance(table(4), table(11))).toBe(7); + }); + + it("adds a zone hop when the rooms differ", () => { + expect(distance(table(4, "hall"), table(6, "atrium"))).toBe(ZONE_HOP + 2); + }); + + it("uses the floor plan when both tables have coordinates", () => { + expect(distance(table(1, "hall", 0, 0), table(9, "atrium", 3, 4))).toBe(5); + }); + + it("falls back to numbers when only one table is placed", () => { + expect(distance(table(1, "hall", 0, 0), table(4, "hall"))).toBe(3); + }); +}); diff --git a/judging/core/src/distance.ts b/judging/core/src/distance.ts new file mode 100644 index 00000000..6601a79c --- /dev/null +++ b/judging/core/src/distance.ts @@ -0,0 +1,30 @@ +/** + * How far one table is from another. + * + * Same zone: the difference in table numbers, which is the walk. + * Another zone: a hop larger than any table gap, then the number difference, + * so a same-zone walk always beats changing rooms. + * When both tables have coordinates, the floor plan wins and the distance + * is Euclidean. + */ + +export type TablePoint = { + number: number; + zoneId: string | null; + x: number | null; + y: number | null; +}; + +/** Larger than any table-number gap, so a zone change never sorts as "near". */ +export const ZONE_HOP = 1_000_000; + +export function distance(a: TablePoint, b: TablePoint): number { + if (a.x !== null && a.y !== null && b.x !== null && b.y !== null) { + return Math.hypot(a.x - b.x, a.y - b.y); + } + const diff = Math.abs(a.number - b.number); + if (a.zoneId !== null && b.zoneId !== null && a.zoneId !== b.zoneId) { + return ZONE_HOP + diff; + } + return diff; +} diff --git a/judging/core/src/events.test.ts b/judging/core/src/events.test.ts new file mode 100644 index 00000000..72a63800 --- /dev/null +++ b/judging/core/src/events.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + boardChannel, + isOutboxTopic, + judgeChannel, + leaderboardChannel, + LOG_KINDS, + OUTBOX_TOPICS, +} from "./events"; +import type { LogKind } from "./events"; + +describe("events", () => { + it("only queues topics the log already knows", () => { + for (const topic of OUTBOX_TOPICS) { + expect(LOG_KINDS).toContain(topic); + expect(isOutboxTopic(topic)).toBe(true); + } + }); + + it("does not queue a log row that nobody subscribes to", () => { + expect(isOutboxTopic("project.upserted")).toBe(false); + }); + + it("names the channels the board, the leaderboard, and a judge listen on", () => { + expect(boardChannel("evt")).toBe("event:evt:board"); + expect(leaderboardChannel("evt")).toBe("event:evt:leaderboard"); + expect(judgeChannel("j1")).toBe("judge:j1"); + }); + + it("accepts every log kind as a log kind", () => { + const kind: LogKind = "vote.cast"; + expect(LOG_KINDS).toContain(kind); + }); +}); diff --git a/judging/core/src/events.ts b/judging/core/src/events.ts new file mode 100644 index 00000000..6e9278ed --- /dev/null +++ b/judging/core/src/events.ts @@ -0,0 +1,55 @@ +/** + * One vocabulary for the append-only log, the outbox, and the websocket. + * The server and the web both import this so a topic cannot drift. + */ + +export const LOG_KINDS = [ + "phase.changed", + "project.upserted", + "project.withdrawn", + "project.table_assigned", + "judge.upserted", + "judge.approved", + "judge.suspended", + "visit.handed_out", + "visit.arrived", + "visit.voided", + "vote.cast", + "comparison.cast", + "results.computed", + "results.published", + "results.unpublished", + "judge.recalled", +] as const; + +export type LogKind = (typeof LOG_KINDS)[number]; + +export const OUTBOX_TOPICS = [ + "visit.handed_out", + "visit.arrived", + "visit.voided", + "vote.cast", + "phase.changed", + "results.published", + "judge.recalled", +] as const; + +export type OutboxTopic = (typeof OUTBOX_TOPICS)[number]; + +const OUTBOX: ReadonlySet = new Set(OUTBOX_TOPICS); + +export function isOutboxTopic(kind: LogKind): kind is OutboxTopic { + return OUTBOX.has(kind); +} + +export function boardChannel(eventId: string): string { + return `event:${eventId}:board`; +} + +export function leaderboardChannel(eventId: string): string { + return `event:${eventId}:leaderboard`; +} + +export function judgeChannel(judgeId: string): string { + return `judge:${judgeId}`; +} diff --git a/judging/core/src/index.ts b/judging/core/src/index.ts new file mode 100644 index 00000000..6acfb348 --- /dev/null +++ b/judging/core/src/index.ts @@ -0,0 +1,54 @@ +export { bayesianShrink, round2 } from "./aggregate/bayes"; +export { bradleyTerry } from "./aggregate/bradleyTerry"; +export { blend } from "./aggregate/blend"; +export { rank } from "./aggregate/rank"; +export { zNormalize } from "./aggregate/zscore"; +export type { + ProjectInput, + RankComparison, + RankConfig, + RankedRow, + VoteInput, +} from "./aggregate/rank"; +export type { Comparison } from "./comparison"; +export { pickNext } from "./dispatch"; +export type { + Candidate, + DispatchConfig, + DispatchStrategy, + JudgeSpot, +} from "./dispatch"; +export { distance, ZONE_HOP } from "./distance"; +export type { TablePoint } from "./distance"; +export { + boardChannel, + isOutboxTopic, + judgeChannel, + leaderboardChannel, + LOG_KINDS, + OUTBOX_TOPICS, +} from "./events"; +export type { LogKind, OutboxTopic } from "./events"; +export { projectMatchesTrack } from "./match"; +export { + assertPhaseAllows, + assertTransition, + canTransition, + phaseAllows, + PHASES, +} from "./phase"; +export type { Phase, PhaseAction } from "./phase"; +export { validateScores, weightedTotal } from "./rubric"; +export type { Criterion, ScoreCheck, ScoreInput } from "./rubric"; +export { + DEFAULT_TIMERS, + isLive, + isOverTarget, + isPastCutoff, +} from "./timers"; +export type { TimerConfig, VisitClock } from "./timers"; +export { + contestBonus, + projectUncertainty, + scoreUncertainty, +} from "./uncertainty"; diff --git a/judging/core/src/match.test.ts b/judging/core/src/match.test.ts new file mode 100644 index 00000000..9127ca98 --- /dev/null +++ b/judging/core/src/match.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { projectMatchesTrack } from "./match"; + +describe("projectMatchesTrack", () => { + const project = { + tracks: ["Finance"], + challenges: ["Sponsor"], + isCreateX: true, + }; + + it("matches every project when the judge has no track", () => { + expect(projectMatchesTrack(project, null)).toBe(true); + }); + + it("matches by track, challenge, and the createx flag", () => { + expect(projectMatchesTrack(project, "Finance")).toBe(true); + expect(projectMatchesTrack(project, "Sponsor")).toBe(true); + expect(projectMatchesTrack(project, "createX")).toBe(true); + expect(projectMatchesTrack(project, "Health")).toBe(false); + }); +}); diff --git a/judging/core/src/match.ts b/judging/core/src/match.ts new file mode 100644 index 00000000..e2abcea4 --- /dev/null +++ b/judging/core/src/match.ts @@ -0,0 +1,20 @@ +/** + * Label match for an import that still carries track strings on the project. + * The product path is a project_track row. This remains so those strings + * route the same way they did before tracks were rows. + */ +export function projectMatchesTrack( + project: { + tracks: string[] | null; + challenges: string[] | null; + isCreateX?: boolean | null; + }, + track: string | null, +): boolean { + if (!track) return true; + const inTracks = project.tracks?.includes(track) ?? false; + const inChallenges = project.challenges?.includes(track) ?? false; + const matchCreateX = + track.toLowerCase() === "createx" && !!project.isCreateX; + return inTracks || inChallenges || matchCreateX; +} diff --git a/judging/core/src/package.test.ts b/judging/core/src/package.test.ts new file mode 100644 index 00000000..678fffae --- /dev/null +++ b/judging/core/src/package.test.ts @@ -0,0 +1,14 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +describe("@panel/core package", () => { + it("has no runtime dependencies", () => { + const packageJson = join(dirname(fileURLToPath(import.meta.url)), "../package.json"); + const pkg = JSON.parse(readFileSync(packageJson, "utf8")) as { + dependencies?: Record; + }; + expect(pkg.dependencies ?? {}).toEqual({}); + }); +}); diff --git a/judging/core/src/phase.test.ts b/judging/core/src/phase.test.ts new file mode 100644 index 00000000..a4f885ba --- /dev/null +++ b/judging/core/src/phase.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + assertPhaseAllows, + assertTransition, + canTransition, + phaseAllows, +} from "./phase"; + +describe("phase", () => { + it("walks an event from setup through archive", () => { + const path = [ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", + ] as const; + for (let index = 1; index < path.length; index += 1) { + const from = path[index - 1]; + const to = path[index]; + if (!from || !to) throw new Error("path"); + expect(canTransition(from, to)).toBe(true); + } + }); + + it("allows the steps an organizer actually takes backwards", () => { + expect(canTransition("submissions_open", "setup")).toBe(true); + expect(canTransition("submissions_closed", "submissions_open")).toBe(true); + expect(canTransition("judging_closed", "judging_live")).toBe(true); + expect(canTransition("published", "judging_closed")).toBe(true); + }); + + it("refuses a jump and a move out of the archive", () => { + expect(canTransition("setup", "judging_live")).toBe(false); + expect(canTransition("archived", "setup")).toBe(false); + expect(() => assertTransition("setup", "published")).toThrow(/setup/); + }); + + it("permits a vote only while judging is live", () => { + expect(phaseAllows("judging_live", "vote")).toBe(true); + expect(phaseAllows("judging_closed", "vote")).toBe(false); + expect(phaseAllows("submissions_open", "vote")).toBe(false); + expect(() => assertPhaseAllows("setup", "vote")).toThrow(/vote/); + }); + + it("permits dispatch only while judging is live, and publish only after it closes", () => { + expect(phaseAllows("judging_live", "dispatch")).toBe(true); + expect(phaseAllows("judging_closed", "dispatch")).toBe(false); + expect(phaseAllows("judging_closed", "publish")).toBe(true); + expect(phaseAllows("judging_live", "publish")).toBe(false); + expect(phaseAllows("judging_live", "compute")).toBe(true); + expect(phaseAllows("submissions_open", "submit")).toBe(true); + expect(phaseAllows("judging_live", "submit")).toBe(false); + expect(phaseAllows("setup", "apply")).toBe(true); + expect(phaseAllows("judging_live", "apply")).toBe(false); + }); +}); diff --git a/judging/core/src/phase.ts b/judging/core/src/phase.ts new file mode 100644 index 00000000..7a947a65 --- /dev/null +++ b/judging/core/src/phase.ts @@ -0,0 +1,63 @@ +/** + * What an event is allowed to do, and which phase it may move to next. + * The server checks this on every mutation. The core does not persist it. + */ + +export const PHASES = [ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", +] as const; + +export type Phase = (typeof PHASES)[number]; + +export type PhaseAction = + | "submit" + | "apply" + | "dispatch" + | "vote" + | "compute" + | "publish"; + +const TRANSITIONS: Record = { + setup: ["submissions_open"], + submissions_open: ["submissions_closed", "setup"], + submissions_closed: ["judging_live", "submissions_open"], + judging_live: ["judging_closed"], + judging_closed: ["published", "judging_live"], + published: ["archived", "judging_closed"], + archived: [], +}; + +const PERMISSIONS: Record = { + submit: ["submissions_open"], + apply: ["setup", "submissions_open", "submissions_closed"], + dispatch: ["judging_live"], + vote: ["judging_live"], + compute: ["judging_live", "judging_closed"], + publish: ["judging_closed"], +}; + +export function canTransition(from: Phase, to: Phase): boolean { + return TRANSITIONS[from].includes(to); +} + +export function assertTransition(from: Phase, to: Phase): void { + if (!canTransition(from, to)) { + throw new Error(`Cannot move an event from ${from} to ${to}`); + } +} + +export function phaseAllows(phase: Phase, action: PhaseAction): boolean { + return PERMISSIONS[action].includes(phase); +} + +export function assertPhaseAllows(phase: Phase, action: PhaseAction): void { + if (!phaseAllows(phase, action)) { + throw new Error(`${action} is not allowed while the event is ${phase}`); + } +} diff --git a/judging/core/src/rubric.test.ts b/judging/core/src/rubric.test.ts new file mode 100644 index 00000000..589db6f1 --- /dev/null +++ b/judging/core/src/rubric.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { validateScores, weightedTotal } from "./rubric"; +import type { Criterion } from "./rubric"; + +const criteria: Criterion[] = [ + { id: "creativity", min: 0, max: 10, weight: 1 }, + { id: "impact", min: 0, max: 10, weight: 3 }, +]; + +describe("validateScores", () => { + it("accepts one in-range value per criterion", () => { + expect( + validateScores(criteria, [ + { criterionId: "creativity", value: 0 }, + { criterionId: "impact", value: 10 }, + ]), + ).toEqual({ ok: true }); + }); + + it("rejects a missing, duplicate, unknown, or out-of-range score", () => { + expect(validateScores(criteria, []).ok).toBe(false); + expect( + validateScores(criteria, [ + { criterionId: "creativity", value: 1 }, + { criterionId: "creativity", value: 2 }, + { criterionId: "impact", value: 3 }, + ]).ok, + ).toBe(false); + expect( + validateScores(criteria, [ + { criterionId: "creativity", value: 1 }, + { criterionId: "nope", value: 1 }, + ]).ok, + ).toBe(false); + expect( + validateScores(criteria, [ + { criterionId: "creativity", value: 11 }, + { criterionId: "impact", value: 1 }, + ]).ok, + ).toBe(false); + }); +}); + +describe("weightedTotal", () => { + it("is the weighted mean", () => { + expect( + weightedTotal(criteria, [ + { criterionId: "creativity", value: 4 }, + { criterionId: "impact", value: 8 }, + ]), + ).toBe(7); + }); + + it("is zero when every weight is zero", () => { + const flat = criteria.map((criterion) => ({ ...criterion, weight: 0 })); + expect( + weightedTotal(flat, [ + { criterionId: "creativity", value: 4 }, + { criterionId: "impact", value: 8 }, + ]), + ).toBe(0); + }); + + it("throws when the scores are not valid", () => { + expect(() => weightedTotal(criteria, [])).toThrow(/missing/); + }); +}); diff --git a/judging/core/src/rubric.ts b/judging/core/src/rubric.ts new file mode 100644 index 00000000..14292b8d --- /dev/null +++ b/judging/core/src/rubric.ts @@ -0,0 +1,85 @@ +/** + * A score is one number per criterion, inside that criterion's range. + * The total is the weighted mean, so it stays on the same scale as the + * criteria. The rows are the truth; the total is derived. + */ + +export type Criterion = { + id: string; + min: number; + max: number; + weight: number; +}; + +export type ScoreInput = { + criterionId: string; + value: number; +}; + +export type ScoreCheck = { ok: true } | { ok: false; reason: string }; + +export function validateScores( + criteria: readonly Criterion[], + input: readonly ScoreInput[], +): ScoreCheck { + for (const criterion of criteria) { + if (!Number.isFinite(criterion.weight) || criterion.weight < 0) { + return { + ok: false, + reason: `weight for ${criterion.id} must be zero or more`, + }; + } + if ( + !Number.isFinite(criterion.min) || + !Number.isFinite(criterion.max) || + criterion.min > criterion.max + ) { + return { ok: false, reason: `range for ${criterion.id} is invalid` }; + } + } + + const seen = new Set(); + for (const score of input) { + if (seen.has(score.criterionId)) { + return { ok: false, reason: `duplicate score for ${score.criterionId}` }; + } + seen.add(score.criterionId); + const criterion = criteria.find((item) => item.id === score.criterionId); + if (!criterion) { + return { ok: false, reason: `unknown criterion ${score.criterionId}` }; + } + if ( + !Number.isFinite(score.value) || + score.value < criterion.min || + score.value > criterion.max + ) { + return { + ok: false, + reason: `${score.criterionId} must be between ${criterion.min} and ${criterion.max}`, + }; + } + } + + for (const criterion of criteria) { + if (!seen.has(criterion.id)) { + return { ok: false, reason: `missing score for ${criterion.id}` }; + } + } + + return { ok: true }; +} + +export function weightedTotal( + criteria: readonly Criterion[], + input: readonly ScoreInput[], +): number { + const check = validateScores(criteria, input); + if (!check.ok) throw new Error(check.reason); + const weight = criteria.reduce((sum, criterion) => sum + criterion.weight, 0); + if (weight === 0) return 0; + const byId = new Map(input.map((score) => [score.criterionId, score.value])); + const sum = criteria.reduce((total, criterion) => { + return total + (byId.get(criterion.id) ?? 0) * criterion.weight; + }, 0); + return sum / weight; +} diff --git a/judging/core/src/timers.test.ts b/judging/core/src/timers.test.ts new file mode 100644 index 00000000..24c3a19d --- /dev/null +++ b/judging/core/src/timers.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_TIMERS, isLive, isOverTarget, isPastCutoff } from "./timers"; + +const t0 = new Date("2027-02-28T13:00:00Z"); +const at = (seconds: number) => new Date(t0.getTime() + seconds * 1000); +const timers = DEFAULT_TIMERS; + +describe("isLive", () => { + const walking = { + handedOutAt: t0, + arrivedAt: null, + completedAt: null, + }; + const atTable = { + handedOutAt: t0, + arrivedAt: at(60), + completedAt: null, + }; + + it("holds a table for the walk limit after hand-out", () => { + expect(isLive(walking, at(timers.walkLimitSeconds), timers)).toBe(true); + expect(isLive(walking, at(timers.walkLimitSeconds + 1), timers)).toBe( + false, + ); + }); + + it("holds it through the hard limit and grace after arrival", () => { + const end = + 60 + timers.hardLimitSeconds + timers.submitGraceSeconds; + expect(isLive(atTable, at(end), timers)).toBe(true); + expect(isLive(atTable, at(end + 1), timers)).toBe(false); + }); + + it("measures from arrival, not hand-out, once the judge has tapped in", () => { + expect(isLive(atTable, at(timers.walkLimitSeconds + 30), timers)).toBe( + true, + ); + }); + + it("is never live once completed, or when never handed out", () => { + expect( + isLive({ ...atTable, completedAt: at(61) }, at(61), timers), + ).toBe(false); + expect( + isLive( + { handedOutAt: null, arrivedAt: null, completedAt: null }, + t0, + timers, + ), + ).toBe(false); + }); + + it("honours a shorter limit from config", () => { + const short = { ...timers, walkLimitSeconds: 30 }; + expect(isLive(walking, at(30), short)).toBe(true); + expect(isLive(walking, at(31), short)).toBe(false); + }); +}); + +describe("isPastCutoff", () => { + it("allows a score at the hard limit and within the grace after it", () => { + expect(isPastCutoff(t0, at(timers.hardLimitSeconds), timers)).toBe(false); + expect( + isPastCutoff( + t0, + at(timers.hardLimitSeconds + timers.submitGraceSeconds), + timers, + ), + ).toBe(false); + }); + + it("refuses one after the grace", () => { + expect( + isPastCutoff( + t0, + at(timers.hardLimitSeconds + timers.submitGraceSeconds + 1), + timers, + ), + ).toBe(true); + }); +}); + +describe("isOverTarget", () => { + it("turns over at the target and not before", () => { + expect(isOverTarget(t0, at(timers.targetSeconds), timers)).toBe(false); + expect(isOverTarget(t0, at(timers.targetSeconds + 1), timers)).toBe(true); + }); +}); diff --git a/judging/core/src/timers.ts b/judging/core/src/timers.ts new file mode 100644 index 00000000..d14bdd5b --- /dev/null +++ b/judging/core/src/timers.ts @@ -0,0 +1,74 @@ +/** + * Visit clocks. Every function takes `now` so a replay of an event does not + * depend on when the code happens to run. + * + * The defaults match the timings the first event used: three minutes at a + * table, four minutes hard, four minutes to walk, fifteen seconds of grace + * for a submit that left the phone on the buzzer. + */ + +export type TimerConfig = { + targetSeconds: number; + hardLimitSeconds: number; + walkLimitSeconds: number; + submitGraceSeconds: number; +}; + +export const DEFAULT_TIMERS: TimerConfig = { + targetSeconds: 180, + hardLimitSeconds: 240, + walkLimitSeconds: 240, + submitGraceSeconds: 15, +}; + +export type VisitClock = { + handedOutAt: Date | null; + arrivedAt: Date | null; + completedAt: Date | null; +}; + +/** + * Whether a visit still holds its table. Walking: until the walk limit after + * hand-out. At the table: until the hard limit (plus grace) after arrival. + */ +export function isLive( + slot: VisitClock, + now: Date, + config: TimerConfig, +): boolean { + if (slot.completedAt) return false; + const t = now.getTime(); + if (slot.arrivedAt) { + return ( + t <= + slot.arrivedAt.getTime() + + (config.hardLimitSeconds + config.submitGraceSeconds) * 1000 + ); + } + if (slot.handedOutAt) { + return t <= slot.handedOutAt.getTime() + config.walkLimitSeconds * 1000; + } + return false; +} + +/** Past the hard cutoff, with grace: a score now arrives too late to count. */ +export function isPastCutoff( + arrivedAt: Date, + now: Date, + config: TimerConfig, +): boolean { + return ( + now.getTime() > + arrivedAt.getTime() + + (config.hardLimitSeconds + config.submitGraceSeconds) * 1000 + ); +} + +/** The judge page turns amber here. Measured from arrival, not hand-out. */ +export function isOverTarget( + arrivedAt: Date, + now: Date, + config: TimerConfig, +): boolean { + return now.getTime() > arrivedAt.getTime() + config.targetSeconds * 1000; +} diff --git a/judging/core/src/uncertainty.test.ts b/judging/core/src/uncertainty.test.ts new file mode 100644 index 00000000..d97fc34e --- /dev/null +++ b/judging/core/src/uncertainty.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + contestBonus, + projectUncertainty, + scoreUncertainty, +} from "./uncertainty"; + +describe("scoreUncertainty", () => { + it("is infinite when nobody has scored the project", () => { + expect(scoreUncertainty([])).toBe(Number.POSITIVE_INFINITY); + }); + + it("is zero when every score agrees", () => { + expect(scoreUncertainty([5, 5, 5])).toBe(0); + }); + + it("shrinks as the same spread is seen more times", () => { + const small = scoreUncertainty([0, 10]); + const large = scoreUncertainty([0, 10, 0, 10]); + expect(large).toBeLessThan(small); + }); +}); + +describe("contestBonus", () => { + it("is zero when one project always wins", () => { + expect( + contestBonus( + [ + { a: "a", b: "b", outcome: "a" }, + { a: "a", b: "b", outcome: "a" }, + ], + "a", + ), + ).toBe(0); + }); + + it("is one when the two projects split", () => { + expect( + contestBonus( + [ + { a: "a", b: "b", outcome: "a" }, + { a: "a", b: "b", outcome: "b" }, + ], + "a", + ), + ).toBe(1); + }); + + it("counts a tie as contested", () => { + expect(contestBonus([{ a: "a", b: "b", outcome: "tie" }], "a")).toBe(1); + }); +}); + +describe("projectUncertainty", () => { + it("adds the contest bonus on top of the score spread", () => { + const scores = [0, 10]; + const bonus = contestBonus([{ a: "a", b: "b", outcome: "tie" }], "a"); + expect( + projectUncertainty(scores, [{ a: "a", b: "b", outcome: "tie" }], "a"), + ).toBeCloseTo(scoreUncertainty(scores) + bonus, 10); + }); + + it("stays infinite when there are no scores yet", () => { + expect(projectUncertainty([], [], "a")).toBe(Number.POSITIVE_INFINITY); + }); +}); diff --git a/judging/core/src/uncertainty.ts b/judging/core/src/uncertainty.ts new file mode 100644 index 00000000..07a5b4b2 --- /dev/null +++ b/judging/core/src/uncertainty.ts @@ -0,0 +1,66 @@ +import type { Comparison } from "./comparison"; + +/** + * How unsettled a project's rank is. + * Score spread shrinks as more judges agree and as the sample grows. + * A pairwise neighbour that is split (or tied) adds a bonus, because those + * two projects are the ones another look can still reorder. + */ + +export function scoreUncertainty(scores: readonly number[]): number { + const n = scores.length; + if (n === 0) return Number.POSITIVE_INFINITY; + const mean = scores.reduce((sum, value) => sum + value, 0) / n; + const variance = + scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / n; + return variance / Math.sqrt(n); +} + +export function contestBonus( + comparisons: readonly Comparison[], + projectId: string, +): number { + const against = new Map< + string, + { wins: number; losses: number; ties: number } + >(); + + for (const comparison of comparisons) { + const opponent = + comparison.a === projectId + ? comparison.b + : comparison.b === projectId + ? comparison.a + : null; + if (!opponent) continue; + const row = against.get(opponent) ?? { wins: 0, losses: 0, ties: 0 }; + if (comparison.outcome === "tie") row.ties += 1; + else if ( + (comparison.outcome === "a" && comparison.a === projectId) || + (comparison.outcome === "b" && comparison.b === projectId) + ) { + row.wins += 1; + } else { + row.losses += 1; + } + against.set(opponent, row); + } + + let bonus = 0; + for (const row of against.values()) { + const n = row.wins + row.losses + row.ties; + if (n === 0) continue; + bonus += (n - Math.abs(row.wins - row.losses)) / n; + } + return bonus; +} + +export function projectUncertainty( + scores: readonly number[], + comparisons: readonly Comparison[], + projectId: string, +): number { + const spread = scoreUncertainty(scores); + if (!Number.isFinite(spread)) return spread; + return spread + contestBonus(comparisons, projectId); +} diff --git a/judging/core/tsconfig.json b/judging/core/tsconfig.json new file mode 100644 index 00000000..f54bde2c --- /dev/null +++ b/judging/core/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tooling/typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/judging/core/vitest.config.ts b/judging/core/vitest.config.ts new file mode 100644 index 00000000..ae847ff6 --- /dev/null +++ b/judging/core/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/judging/docs/adr/001-standalone.md b/judging/docs/adr/001-standalone.md new file mode 100644 index 00000000..6c417fb3 --- /dev/null +++ b/judging/docs/adr/001-standalone.md @@ -0,0 +1,22 @@ +# ADR-001: Standalone product + +## Status + +Accepted + +## Decision + +Panel lives under `judging/` with its own packages, its own Postgres database, +and its own deploy. It does not share a schema or a module graph with the +application that hosts the first event. + +Internal imports are `@panel/*` only. A lint rule rejects imports from the +host. The host is allowed to import `@panel/*`. That one-way edge is what +makes `git subtree split --prefix=judging` a later extraction rather than a +rewrite. + +## Why + +Judging welded into one event's tables cannot be given to the next event, or +to anyone else, without carrying the rest of that application with it. +A separate database means the extraction has no data to untangle. diff --git a/judging/docs/adr/002-pure-core.md b/judging/docs/adr/002-pure-core.md new file mode 100644 index 00000000..1421615c --- /dev/null +++ b/judging/docs/adr/002-pure-core.md @@ -0,0 +1,22 @@ +# ADR-002: Pure core + +## Status + +Accepted + +## Decision + +Every decision Panel makes — the next table, whether a visit is still live, +whether a score is valid, how a set of votes becomes a ranking — is a function +in `@panel/core`. Those functions take plain data, including `now` and a +`random` source, and return plain data. They do not read the clock, generate +randomness, or touch a database, the network, or the filesystem. + +The server is the only place that performs I/O. It loads rows, calls the +core, and writes the result in the same transaction as the event log. + +## Why + +A ranking you cannot recompute from the votes is not a result you can defend. +Tests of the core need no database, so the scoring bugs surface without +standing up Postgres. The same functions can later run offline on an export. diff --git a/package.json b/package.json index 6dcbb690..f3ddfc8d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint": "turbo run lint", "format": "prettier --write .", "typecheck": "turbo run typecheck", - "test": "vitest run packages/api packages/auth packages/db sites/mainweb/lib" + "test": "vitest run packages/api packages/auth packages/db sites/mainweb/lib judging/core" }, "dependencies": { "next": "16.3.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8907bf4d..056a6a71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,24 @@ importers: specifier: ^5.0.1 version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + judging/core: + devDependencies: + '@query/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@query/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: ^5.0.1 + version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + packages/api: dependencies: '@query/auth': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4da7f960..c9f5f1e7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,3 +7,4 @@ packages: - packages/auth - packages/db - packages/ui + - judging/* From c3070d856fecdc1825e414a62f5e980921d9b60d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:43:32 +0000 Subject: [PATCH 03/82] chore(deps): bump actions/upload-artifact from 4 to 6 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0d58879..b7085ce4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: run: pnpm turbo run build - name: Upload Hacklytics build - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: hacklytics-out path: sites/hacklytics2027/out From 429dba0edf2ba9fa1967f697589d942911d09769 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:43:41 +0000 Subject: [PATCH 04/82] chore(deps): bump actions/download-artifact from 4 to 7 Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 7. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0d58879..1b579b0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v7 with: name: hacklytics-out path: sites/hacklytics2027/out @@ -94,7 +94,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v7 with: name: hacklytics-out path: sites/hacklytics2027/out From f4fa8fa5ce8d324c2e9512d0e9705bb0f9e23b0d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:46:24 +0000 Subject: [PATCH 05/82] chore(deps-dev): bump vitest from 5.0.1 to 5.0.3 Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 5.0.1 to 5.0.3. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.3/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 5.0.3 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package.json | 2 +- packages/api/package.json | 2 +- pnpm-lock.yaml | 114 ++++++++++++++++---------------------- 3 files changed, 50 insertions(+), 68 deletions(-) diff --git a/package.json b/package.json index 6dcbb690..90ca1add 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "devDependencies": { "turbo": "^2.11.3", "vite": "^7.3.5", - "vitest": "^5.0.1" + "vitest": "^5.0.3" }, "pnpm": { "overrides": { diff --git a/packages/api/package.json b/packages/api/package.json index 4f8b792d..9f6b350f 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -46,6 +46,6 @@ "@types/sanitize-html": "^2.16.0", "typescript": "7.0.2", "vite": "^7.3.5", - "vitest": "^5.0.1" + "vitest": "^5.0.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8907bf4d..c18ff46c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,8 +38,8 @@ importers: specifier: ^7.3.5 version: 7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) vitest: - specifier: ^5.0.1 - version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + specifier: ^5.0.3 + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) packages/api: dependencies: @@ -99,8 +99,8 @@ importers: specifier: ^7.3.5 version: 7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) vitest: - specifier: ^5.0.1 - version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + specifier: ^5.0.3 + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) packages/auth: dependencies: @@ -1172,9 +1172,6 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} @@ -2372,8 +2369,8 @@ packages: cpu: [x64] os: [win32] - '@vitest/mocker@5.0.1': - resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==} + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} peerDependencies: msw: ^2.4.9 vite: ^7.3.5 @@ -2383,8 +2380,8 @@ packages: vite: optional: true - '@vitest/spy@5.0.1': - resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==} + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} '@yudiel/react-qr-scanner@2.6.0': resolution: {integrity: sha512-WjNcSt7qquYUz1LVbwKzSnRQI47sNrh4Vh6nNiGKVxnYcP+KgN5q9rcSzXyZ/7GRV9PPrnzoIuE5YdOxB9yAog==} @@ -2630,8 +2627,8 @@ packages: caniuse-lite@1.0.30001812: resolution: {integrity: sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==} - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} chart.js@4.5.1: @@ -3711,8 +3708,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magic-string@1.4.2: - resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} @@ -4337,9 +4334,6 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - sirv@2.0.4: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} @@ -4359,11 +4353,8 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@4.2.0: - resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} @@ -4485,12 +4476,12 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} - tinybench@6.1.4: - resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} engines: {node: '>=20.0.0'} - tinyexec@1.3.0: - resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} tinyglobby@0.2.17: @@ -4656,20 +4647,20 @@ packages: yaml: optional: true - vitest@5.0.1: - resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==} + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.1 - '@vitest/browser-preview': 5.0.1 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.1 - '@vitest/coverage-v8': 5.0.1 - '@vitest/ui': 5.0.1 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 happy-dom: '*' jsdom: '*' vite: ^7.3.5 @@ -4740,9 +4731,9 @@ packages: engines: {node: '>= 8'} hasBin: true - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} hasBin: true word-wrap@1.2.5: @@ -4769,7 +4760,7 @@ packages: optional: true xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: - resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} + resolution: {tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} version: 0.20.3 engines: {node: '>=0.8'} hasBin: true @@ -5338,8 +5329,6 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': @@ -6445,16 +6434,16 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/mocker@5.0.1(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15))': + '@vitest/mocker@5.0.3(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15))': dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.1 + '@vitest/spy': 5.0.3 estree-walker: 3.0.3 - magic-string: 1.4.2 + magic-string: 1.4.3 optionalDependencies: vite: 7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) - '@vitest/spy@5.0.1': {} + '@vitest/spy@5.0.3': {} '@yudiel/react-qr-scanner@2.6.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: @@ -6728,7 +6717,7 @@ snapshots: caniuse-lite@1.0.30001812: {} - chai@6.2.2: {} + chai@6.3.0: {} chart.js@4.5.1: dependencies: @@ -7851,9 +7840,9 @@ snapshots: magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - magic-string@1.4.2: + magic-string@1.4.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -8474,8 +8463,6 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - siginfo@2.0.0: {} - sirv@2.0.4: dependencies: '@polka/url': 1.0.0-next.29 @@ -8493,9 +8480,7 @@ snapshots: split2@4.2.0: {} - stackback@0.0.2: {} - - std-env@4.2.0: {} + std-env@4.3.0: {} stop-iteration-iterator@1.1.0: dependencies: @@ -8653,9 +8638,9 @@ snapshots: transitivePeerDependencies: - react-native-b4a - tinybench@6.1.4: {} + tinybench@6.2.0: {} - tinyexec@1.3.0: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: dependencies: @@ -8845,22 +8830,22 @@ snapshots: lightningcss: 1.32.0 tsx: 4.23.15 - vitest@5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)): + vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.1(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) - chai: 6.2.2 + '@vitest/mocker': 5.0.3(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) + chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 - magic-string: 1.4.2 + magic-string: 1.4.3 obug: 2.2.1 picomatch: 4.0.7 - std-env: 4.2.0 - tinybench: 6.1.4 - tinyexec: 1.3.0 + std-env: 4.3.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 tinyglobby: 0.2.17 vite: 7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) - why-is-node-running: 2.3.0 + why-is-node-running: 3.2.1 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 22.20.4 @@ -8946,10 +8931,7 @@ snapshots: dependencies: isexe: 2.0.0 - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 + why-is-node-running@3.2.1: {} word-wrap@1.2.5: {} From 3434dc2b748a701130cf8efdb710d018ba69e01d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:14:26 -0400 Subject: [PATCH 06/82] chore(deps): bump @typescript-eslint/eslint-plugin from 8.70.1 to 8.71.0 (#485) Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.70.1 to 8.71.0. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.0/packages/eslint-plugin) --- updated-dependencies: - dependency-name: "@typescript-eslint/eslint-plugin" dependency-version: 8.71.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/auth/package.json | 2 +- pnpm-lock.yaml | 220 ++++++++++++++++++++++++++++++++++-- tooling/eslint/package.json | 2 +- 3 files changed, 211 insertions(+), 13 deletions(-) diff --git a/packages/auth/package.json b/packages/auth/package.json index a8db62e8..e87f59e7 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -25,7 +25,7 @@ "devDependencies": { "@query/tsconfig": "workspace:*", "@types/node": "^22.20.4", - "@typescript-eslint/eslint-plugin": "^8.70.1", + "@typescript-eslint/eslint-plugin": "^8.71.0", "@typescript-eslint/parser": "^8.70.1", "@typescript-eslint/utils": "^8.70.1", "eslint": "^10.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c18ff46c..ebe02b31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,8 +127,8 @@ importers: specifier: ^22.20.4 version: 22.20.4 '@typescript-eslint/eslint-plugin': - specifier: ^8.70.1 - version: 8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + specifier: ^8.71.0 + version: 8.71.0(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) '@typescript-eslint/parser': specifier: ^8.70.1 version: 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) @@ -455,8 +455,8 @@ importers: specifier: 16.3.7 version: 16.3.7(eslint@10.11.0(jiti@2.7.0)) '@typescript-eslint/eslint-plugin': - specifier: 8.70.1 - version: 8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) + specifier: 8.71.0 + version: 8.71.0(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) '@typescript-eslint/parser': specifier: 8.70.1 version: 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) @@ -2198,6 +2198,14 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/eslint-plugin@8.71.0': + resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.71.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.70.1': resolution: {integrity: sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2211,16 +2219,32 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.71.0': + resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.70.1': resolution: {integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.71.0': + resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.70.1': resolution: {integrity: sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/tsconfig-utils@8.71.0': + resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.70.1': resolution: {integrity: sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2228,16 +2252,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.71.0': + resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.70.1': resolution: {integrity: sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.71.0': + resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.70.1': resolution: {integrity: sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.71.0': + resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.70.1': resolution: {integrity: sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2245,10 +2286,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.71.0': + resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.70.1': resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.71.0': + resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -3388,8 +3440,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.10: - resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} + ignore@7.0.12: + resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} engines: {node: '>= 4'} image-size@2.0.4: @@ -6213,7 +6265,7 @@ snapshots: '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) '@typescript-eslint/visitor-keys': 8.70.1 eslint: 10.11.0(jiti@2.7.0) - ignore: 7.0.10 + ignore: 7.0.12 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 @@ -6229,7 +6281,39 @@ snapshots: '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) '@typescript-eslint/visitor-keys': 8.70.1 eslint: 10.11.0(jiti@2.7.0) - ignore: 7.0.10 + ignore: 7.0.12 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@7.0.2) + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/type-utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.71.0 + eslint: 10.11.0(jiti@2.7.0) + ignore: 7.0.12 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2))(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/type-utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + '@typescript-eslint/visitor-keys': 8.71.0 + eslint: 10.11.0(jiti@2.7.0) + ignore: 7.0.12 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@7.0.2) typescript: 7.0.2 @@ -6263,7 +6347,7 @@ snapshots: '@typescript-eslint/project-service@8.70.1(typescript@6.0.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@6.0.2) - '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/types': 8.71.0 debug: 4.4.3 typescript: 6.0.2 transitivePeerDependencies: @@ -6272,7 +6356,25 @@ snapshots: '@typescript-eslint/project-service@8.70.1(typescript@7.0.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@7.0.2) - '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/types': 8.71.0 + debug: 4.4.3 + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.71.0(typescript@6.0.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.2) + '@typescript-eslint/types': 8.71.0 + debug: 4.4.3 + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.71.0(typescript@7.0.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@7.0.2) + '@typescript-eslint/types': 8.71.0 debug: 4.4.3 typescript: 7.0.2 transitivePeerDependencies: @@ -6283,6 +6385,11 @@ snapshots: '@typescript-eslint/types': 8.70.1 '@typescript-eslint/visitor-keys': 8.70.1 + '@typescript-eslint/scope-manager@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/tsconfig-utils@8.70.1(typescript@6.0.2)': dependencies: typescript: 6.0.2 @@ -6291,6 +6398,14 @@ snapshots: dependencies: typescript: 7.0.2 + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@6.0.2)': + dependencies: + typescript: 6.0.2 + + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@7.0.2)': + dependencies: + typescript: 7.0.2 + '@typescript-eslint/type-utils@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': dependencies: '@typescript-eslint/types': 8.70.1 @@ -6315,8 +6430,34 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@6.0.2) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) + debug: 4.4.3 + eslint: 10.11.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2)': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@7.0.2) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2) + debug: 4.4.3 + eslint: 10.11.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@7.0.2) + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.70.1': {} + '@typescript-eslint/types@8.71.0': {} + '@typescript-eslint/typescript-estree@8.70.1(typescript@6.0.2)': dependencies: '@typescript-eslint/project-service': 8.70.1(typescript@6.0.2) @@ -6347,6 +6488,36 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.71.0(typescript@6.0.2)': + dependencies: + '@typescript-eslint/project-service': 8.71.0(typescript@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.2) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.71.0(typescript@7.0.2)': + dependencies: + '@typescript-eslint/project-service': 8.71.0(typescript@7.0.2) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@7.0.2) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@7.0.2) + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) @@ -6369,11 +6540,38 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@6.0.2) + eslint: 10.11.0(jiti@2.7.0) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@7.0.2)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@7.0.2) + eslint: 10.11.0(jiti@2.7.0) + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.70.1': dependencies: '@typescript-eslint/types': 8.70.1 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + eslint-visitor-keys: 5.0.1 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -7555,7 +7753,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.10: {} + ignore@7.0.12: {} image-size@2.0.4: {} diff --git a/tooling/eslint/package.json b/tooling/eslint/package.json index 97139cf6..4c94254d 100644 --- a/tooling/eslint/package.json +++ b/tooling/eslint/package.json @@ -19,7 +19,7 @@ "@eslint/compat": "2.1.1", "@eslint/js": "10.0.1", "@next/eslint-plugin-next": "16.3.7", - "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/eslint-plugin": "8.71.0", "@typescript-eslint/parser": "8.70.1", "eslint-config-prettier": "10.1.8", "eslint-plugin-import": "2.32.0", From cc354b3b7e4c650827c4048eade7b0da0f6e7519 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:15:56 -0400 Subject: [PATCH 07/82] chore(deps-dev): bump dotenv from 18.0.3 to 18.0.5 (#484) Bumps [dotenv](https://github.com/motdotla/dotenv) from 18.0.3 to 18.0.5. - [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md) - [Commits](https://github.com/motdotla/dotenv/compare/v18.0.3...v18.0.5) --- updated-dependencies: - dependency-name: dotenv dependency-version: 18.0.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/db/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/db/package.json b/packages/db/package.json index 7ccc9e35..a83cbc11 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -30,7 +30,7 @@ "devDependencies": { "@query/tsconfig": "workspace:*", "@types/pg": "^8.23.1", - "dotenv": "^18.0.3", + "dotenv": "^18.0.5", "drizzle-kit": "0.31.11", "tsx": "^4.23.15", "typescript": "^7.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebe02b31..24ea0386 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,8 +173,8 @@ importers: specifier: ^8.23.1 version: 8.23.1 dotenv: - specifier: ^18.0.3 - version: 18.0.3 + specifier: ^18.0.5 + version: 18.0.5 drizzle-kit: specifier: 0.31.11 version: 0.31.11 @@ -2859,8 +2859,8 @@ packages: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} - dotenv@18.0.3: - resolution: {integrity: sha512-/8CJTyL817qPnfbQrJgOEcrU3fDrJkRIoXBE566wHuvIXKOCHUZhPqGPbCPi9SogjdGnreF0TlvnTgK6J/8zpA==} + dotenv@18.0.5: + resolution: {integrity: sha512-aBrGvt6KhjxbEnatqMWOMidftwsrCiCvqh1yNIGB9QyF0J+VcWkKDMsvYOEm7ZS82oiV+BT1sTOtIlAmUlTpwQ==} engines: {node: '>=12'} hasBin: true @@ -7079,7 +7079,7 @@ snapshots: dotenv@16.0.3: {} - dotenv@18.0.3: {} + dotenv@18.0.5: {} drizzle-kit@0.31.11: dependencies: From 98dda7037ef355ad49853ec32287061add481a86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:16:14 -0400 Subject: [PATCH 08/82] chore(deps-dev): bump turbo from 2.11.3 to 2.11.7 (#483) Bumps [turbo](https://github.com/vercel/turborepo) from 2.11.3 to 2.11.7. - [Release notes](https://github.com/vercel/turborepo/releases) - [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md) - [Commits](https://github.com/vercel/turborepo/compare/v2.11.3...v2.11.7) --- updated-dependencies: - dependency-name: turbo dependency-version: 2.11.7 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- pnpm-lock.yaml | 68 +++++++++++++++++++------------------- sites/mainweb/package.json | 2 +- 3 files changed, 36 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index 90ca1add..c88627b1 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "typescript": "^7.0.2" }, "devDependencies": { - "turbo": "^2.11.3", + "turbo": "^2.11.7", "vite": "^7.3.5", "vitest": "^5.0.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24ea0386..efaead41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,8 +32,8 @@ importers: version: 7.0.2 devDependencies: turbo: - specifier: ^2.11.3 - version: 2.11.3 + specifier: ^2.11.7 + version: 2.11.7 vite: specifier: ^7.3.5 version: 7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15) @@ -434,8 +434,8 @@ importers: specifier: 4.3.3 version: 4.3.3 turbo: - specifier: ^2.11.3 - version: 2.11.3 + specifier: ^2.11.7 + version: 2.11.7 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -477,7 +477,7 @@ importers: version: 7.1.1(eslint@10.11.0(jiti@2.7.0)) eslint-plugin-turbo: specifier: ^2.11.3 - version: 2.11.3(eslint@10.11.0(jiti@2.7.0))(turbo@2.11.3) + version: 2.11.3(eslint@10.11.0(jiti@2.7.0))(turbo@2.11.7) globals: specifier: 17.12.0 version: 17.12.0 @@ -2098,33 +2098,33 @@ packages: peerDependencies: typescript: '>=5.7.2' - '@turbo/darwin-64@2.11.3': - resolution: {integrity: sha512-D4EIKTgPpxFQL5aZNbNqy57XOiRldM+mwP1ME9ZLM4DJoV3oNK7GKyQWVusz1umjiFNmLvunNGw7Jq4RUyYL3A==} + '@turbo/darwin-64@2.11.7': + resolution: {integrity: sha512-Z2LFOxwbiJueD1miByXJS/BrW21vv6YaQXZustty5jEX4vQYw6zyh101Sz1QO7WG0mqBzKagZ/DoAeeujI0/Sw==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.11.3': - resolution: {integrity: sha512-WOvgWIO86LzBQmL9aRFekROing1CZMVDjmdD7Dk6R65qFAFdrlKkufojenjW1AbUAqytpG6xsXzq8UEScdSdDQ==} + '@turbo/darwin-arm64@2.11.7': + resolution: {integrity: sha512-Vhv8ybgIflosNDSSRD9fNXHlCFA60dsGyTqQmc7l28SEX5jOaweCCD4BBYNd8/p0WGkw3e7tZbfA9w6FsujGvw==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.11.3': - resolution: {integrity: sha512-zIgNl9KkMnsdU54JrnazELatpMJDSktKjD3kjKVliUNMl/5uhFRrqQ+Z1qaACKw/2ybL7xzlsHir9s5q7sYlQg==} + '@turbo/linux-64@2.11.7': + resolution: {integrity: sha512-rAqRp3blQKk1+ylMGlZHAW0eJjVNuuGUm4ZrC6RU1Im8tLryvaO4a2zLQ/FQi1TRlmKqKV9YXBki2YeytlLbXA==} cpu: [x64] os: [android, linux] - '@turbo/linux-arm64@2.11.3': - resolution: {integrity: sha512-aJToWHx5JYvpPwIrAt1DuYrHWcpQGjGf0XVPRMKQBHou//JUYR6oowHeUZp9Oq7e3XDWA75E37C2GnNaBqCVnA==} + '@turbo/linux-arm64@2.11.7': + resolution: {integrity: sha512-thVYGov7S+llXAxrl3kBb/efalav0xhrjl5NHCR6AfKx14JkbqaetvbVwyss4FvZ9rt+Pv3xsTb+8SIYix8DTg==} cpu: [arm64] os: [android, linux] - '@turbo/windows-64@2.11.3': - resolution: {integrity: sha512-hu5biiwVGdMvHB/vvaoPjtudNBn3TqcsKK2A06ap3O0wnSZwAtTXe92tKTckpecDeVs+IC+4r8s2wXpbdSPWoA==} + '@turbo/windows-64@2.11.7': + resolution: {integrity: sha512-ZIoKT7J7KutgYl4bGxmmgeUQjG+7E0nY8BF9dxUOiMH2vdTsM4JQ2uSwTYFkTR2Llbg6ghGjSmuZ8LQtkLEYjg==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.11.3': - resolution: {integrity: sha512-naDEb/31UmOOJOChi+LebLMKWKa1kId0yig1yTRZzDViDg1lBw1Ptu5iQYCvOfOOrx6swUUucf3zPWuPGWz7Jw==} + '@turbo/windows-arm64@2.11.7': + resolution: {integrity: sha512-88Xv2f7SLGVJ68lRRx8o375dKmnZYNdDtvlKcPA0kZpcF93eAQd8l96BKQ0/AMHG0bMJA0Z5QqoqtetxzNsC+g==} cpu: [arm64] os: [win32] @@ -4571,8 +4571,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo@2.11.3: - resolution: {integrity: sha512-Pfg2dWNOUPwJYbR+eYAcjoBbo4Kmoc8uQb80hzKrBgNEQtxvwf2ucXG7C28MyLVhV/zJSKvChrR+2CP/BONpoA==} + turbo@2.11.7: + resolution: {integrity: sha512-RhXHbqfvY6o3xt/zSHCwSNwLrZlXiqHzewi4eAj9PtZ1T1CqgMJ2yIDb7SKPTG9IikTm925mfzL6hldQVdyNWw==} hasBin: true type-check@0.4.0: @@ -6167,22 +6167,22 @@ snapshots: dependencies: typescript: 7.0.2 - '@turbo/darwin-64@2.11.3': + '@turbo/darwin-64@2.11.7': optional: true - '@turbo/darwin-arm64@2.11.3': + '@turbo/darwin-arm64@2.11.7': optional: true - '@turbo/linux-64@2.11.3': + '@turbo/linux-64@2.11.7': optional: true - '@turbo/linux-arm64@2.11.3': + '@turbo/linux-arm64@2.11.7': optional: true - '@turbo/windows-64@2.11.3': + '@turbo/windows-64@2.11.7': optional: true - '@turbo/windows-arm64@2.11.3': + '@turbo/windows-arm64@2.11.7': optional: true '@tweenjs/tween.js@23.1.3': {} @@ -7378,11 +7378,11 @@ snapshots: string.prototype.matchall: 4.1.0 string.prototype.repeat: 1.0.0 - eslint-plugin-turbo@2.11.3(eslint@10.11.0(jiti@2.7.0))(turbo@2.11.3): + eslint-plugin-turbo@2.11.3(eslint@10.11.0(jiti@2.7.0))(turbo@2.11.7): dependencies: dotenv: 16.0.3 eslint: 10.11.0(jiti@2.7.0) - turbo: 2.11.3 + turbo: 2.11.7 eslint-scope@9.1.2: dependencies: @@ -8878,14 +8878,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo@2.11.3: + turbo@2.11.7: optionalDependencies: - '@turbo/darwin-64': 2.11.3 - '@turbo/darwin-arm64': 2.11.3 - '@turbo/linux-64': 2.11.3 - '@turbo/linux-arm64': 2.11.3 - '@turbo/windows-64': 2.11.3 - '@turbo/windows-arm64': 2.11.3 + '@turbo/darwin-64': 2.11.7 + '@turbo/darwin-arm64': 2.11.7 + '@turbo/linux-64': 2.11.7 + '@turbo/linux-arm64': 2.11.7 + '@turbo/windows-64': 2.11.7 + '@turbo/windows-arm64': 2.11.7 type-check@0.4.0: dependencies: diff --git a/sites/mainweb/package.json b/sites/mainweb/package.json index 6b16eb4a..16866b9a 100644 --- a/sites/mainweb/package.json +++ b/sites/mainweb/package.json @@ -65,7 +65,7 @@ "eslint": "10.11.0", "postcss": "^8.5.28", "tailwindcss": "4.3.3", - "turbo": "^2.11.3", + "turbo": "^2.11.7", "typescript": "^7.0.2", "typescript-eslint": "^8.70.1" } From 5ee687360325f32e0461ec45fc1d1f10efe7db0d Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 8 Oct 2026 13:14:42 -0400 Subject: [PATCH 09/82] feat(panel): run judging on the shared database One API and the club Postgres, with the judge desk, organizer console, and a handoff from the club site when an edition opts in. Co-authored-by: Cursor --- .github/workflows/panel.yml | 75 ++ docs/architecture.md | 2 +- judging/.github/ISSUE_TEMPLATE/bug.md | 11 + judging/.github/ISSUE_TEMPLATE/feature.md | 9 + judging/CHANGELOG.md | 8 + judging/CONTRIBUTING.md | 28 + judging/README.md | 38 +- judging/cli/eslint.config.mjs | 27 + judging/cli/package.json | 24 + judging/cli/src/csv.test.ts | 41 ++ judging/cli/src/csv.ts | 127 ++++ judging/cli/src/main.ts | 153 ++++ judging/cli/src/transfer.ts | 67 ++ judging/cli/tsconfig.json | 10 + judging/core/package.json | 11 + judging/core/scripts/emit-extensions.mjs | 25 + judging/core/src/aggregate/rank.test.ts | 42 +- judging/core/src/aggregate/rank.ts | 8 + judging/core/src/dispatch.sim.test.ts | 120 ++++ judging/core/src/events.ts | 11 + judging/core/src/index.ts | 2 + judging/core/src/standalone.test.ts | 34 + judging/core/src/tables.test.ts | 34 + judging/core/src/tables.ts | 52 ++ judging/core/tsconfig.build.json | 12 + judging/db/eslint.config.mjs | 24 + judging/db/package.json | 25 + judging/db/src/client.ts | 16 + judging/db/src/env.ts | 11 + judging/db/src/index.ts | 6 + judging/db/src/migrate.ts | 69 ++ judging/db/src/schema.ts | 433 ++++++++++++ judging/db/src/seed.ts | 238 +++++++ judging/db/tsconfig.json | 10 + judging/docs/adr/001-standalone.md | 10 +- judging/docs/adr/003-one-database.md | 26 + judging/docs/api.md | 17 + judging/docs/deploy.md | 25 + judging/docs/rubric.md | 7 + judging/docs/scoring.md | 9 + judging/infra/docker/Dockerfile | 15 + judging/infra/docker/Dockerfile.web | 30 + judging/infra/docker/docker-compose.yml | 30 + judging/infra/gcp/main.tf | 129 ++++ judging/server/eslint.config.mjs | 28 + judging/server/package.json | 38 + judging/server/src/app.test.ts | 18 + judging/server/src/app.ts | 391 +++++++++++ judging/server/src/auth.test.ts | 16 + judging/server/src/auth.ts | 100 +++ judging/server/src/bus.test.ts | 51 ++ judging/server/src/bus.ts | 131 ++++ judging/server/src/cross-server.check.ts | 111 +++ judging/server/src/env.ts | 11 + judging/server/src/hub.ts | 31 + judging/server/src/index.ts | 73 ++ judging/server/src/log.ts | 3 + judging/server/src/mail.ts | 12 + judging/server/src/metrics.ts | 40 ++ judging/server/src/router.ts | 652 ++++++++++++++++++ judging/server/src/services/arrive.ts | 76 ++ judging/server/src/services/catalog.ts | 454 ++++++++++++ judging/server/src/services/compare.ts | 63 ++ .../src/services/dispatch.concurrent.test.ts | 33 + judging/server/src/services/dispatch.ts | 413 +++++++++++ judging/server/src/services/feedback.test.ts | 18 + judging/server/src/services/feedback.ts | 17 + judging/server/src/services/floor.test.ts | 29 + judging/server/src/services/floor.ts | 79 +++ .../src/services/flow.integration.test.ts | 110 +++ judging/server/src/services/live.ts | 122 ++++ judging/server/src/services/logs.test.ts | 16 + judging/server/src/services/logs.ts | 28 + judging/server/src/services/outbox.ts | 52 ++ judging/server/src/services/overtime.test.ts | 30 + judging/server/src/services/overtime.ts | 49 ++ judging/server/src/services/records.ts | 358 ++++++++++ .../src/services/redis.integration.test.ts | 41 ++ judging/server/src/services/results.test.ts | 61 ++ judging/server/src/services/results.ts | 328 +++++++++ judging/server/src/services/vote.test.ts | 48 ++ judging/server/src/services/vote.ts | 175 +++++ judging/server/tsconfig.json | 10 + judging/server/vitest.config.ts | 8 + .../web/app/e/[orgSlug]/[eventSlug]/board.tsx | 92 +++ .../[eventSlug]/feedback/[token]/page.tsx | 48 ++ .../web/app/e/[orgSlug]/[eventSlug]/page.tsx | 20 + .../web/app/j/[orgSlug]/[eventSlug]/desk.tsx | 474 +++++++++++++ .../web/app/j/[orgSlug]/[eventSlug]/page.tsx | 16 + judging/web/app/layout.tsx | 30 + .../app/o/[orgSlug]/[eventSlug]/console.tsx | 408 +++++++++++ .../app/o/[orgSlug]/[eventSlug]/editors.tsx | 338 +++++++++ .../web/app/o/[orgSlug]/[eventSlug]/page.tsx | 16 + judging/web/app/page.tsx | 8 + judging/web/e2e/visit.spec.ts | 61 ++ judging/web/eslint.config.mjs | 23 + judging/web/lib/api.ts | 15 + judging/web/lib/held.ts | 59 ++ judging/web/next.config.ts | 7 + judging/web/package.json | 26 + judging/web/playwright.config.ts | 9 + judging/web/public/manifest.webmanifest | 8 + judging/web/test-results/.last-run.json | 4 + judging/web/tsconfig.json | 11 + package.json | 2 +- packages/api/src/routers/judge/admin.ts | 11 +- packages/api/src/routers/judge/portal.ts | 20 + packages/api/src/routers/judge/rankings.ts | 100 +-- packages/api/src/routers/judge/replay.test.ts | 55 ++ packages/api/src/routers/judge/weight.ts | 84 +++ packages/api/src/services/panel-sync.ts | 225 ++++++ packages/api/tsconfig.json | 2 +- packages/db/src/schemas/hackathons.ts | 5 + pnpm-lock.yaml | 286 +++++++- .../app/(portal)/admin/judging/page.tsx | 10 + sites/mainweb/app/(portal)/judge/page.tsx | 8 + turbo.json | 17 +- 117 files changed, 8810 insertions(+), 113 deletions(-) create mode 100644 .github/workflows/panel.yml create mode 100644 judging/.github/ISSUE_TEMPLATE/bug.md create mode 100644 judging/.github/ISSUE_TEMPLATE/feature.md create mode 100644 judging/CHANGELOG.md create mode 100644 judging/CONTRIBUTING.md create mode 100644 judging/cli/eslint.config.mjs create mode 100644 judging/cli/package.json create mode 100644 judging/cli/src/csv.test.ts create mode 100644 judging/cli/src/csv.ts create mode 100644 judging/cli/src/main.ts create mode 100644 judging/cli/src/transfer.ts create mode 100644 judging/cli/tsconfig.json create mode 100644 judging/core/scripts/emit-extensions.mjs create mode 100644 judging/core/src/dispatch.sim.test.ts create mode 100644 judging/core/src/standalone.test.ts create mode 100644 judging/core/src/tables.test.ts create mode 100644 judging/core/src/tables.ts create mode 100644 judging/core/tsconfig.build.json create mode 100644 judging/db/eslint.config.mjs create mode 100644 judging/db/package.json create mode 100644 judging/db/src/client.ts create mode 100644 judging/db/src/env.ts create mode 100644 judging/db/src/index.ts create mode 100644 judging/db/src/migrate.ts create mode 100644 judging/db/src/schema.ts create mode 100644 judging/db/src/seed.ts create mode 100644 judging/db/tsconfig.json create mode 100644 judging/docs/adr/003-one-database.md create mode 100644 judging/docs/api.md create mode 100644 judging/docs/deploy.md create mode 100644 judging/docs/rubric.md create mode 100644 judging/docs/scoring.md create mode 100644 judging/infra/docker/Dockerfile create mode 100644 judging/infra/docker/Dockerfile.web create mode 100644 judging/infra/docker/docker-compose.yml create mode 100644 judging/infra/gcp/main.tf create mode 100644 judging/server/eslint.config.mjs create mode 100644 judging/server/package.json create mode 100644 judging/server/src/app.test.ts create mode 100644 judging/server/src/app.ts create mode 100644 judging/server/src/auth.test.ts create mode 100644 judging/server/src/auth.ts create mode 100644 judging/server/src/bus.test.ts create mode 100644 judging/server/src/bus.ts create mode 100644 judging/server/src/cross-server.check.ts create mode 100644 judging/server/src/env.ts create mode 100644 judging/server/src/hub.ts create mode 100644 judging/server/src/index.ts create mode 100644 judging/server/src/log.ts create mode 100644 judging/server/src/mail.ts create mode 100644 judging/server/src/metrics.ts create mode 100644 judging/server/src/router.ts create mode 100644 judging/server/src/services/arrive.ts create mode 100644 judging/server/src/services/catalog.ts create mode 100644 judging/server/src/services/compare.ts create mode 100644 judging/server/src/services/dispatch.concurrent.test.ts create mode 100644 judging/server/src/services/dispatch.ts create mode 100644 judging/server/src/services/feedback.test.ts create mode 100644 judging/server/src/services/feedback.ts create mode 100644 judging/server/src/services/floor.test.ts create mode 100644 judging/server/src/services/floor.ts create mode 100644 judging/server/src/services/flow.integration.test.ts create mode 100644 judging/server/src/services/live.ts create mode 100644 judging/server/src/services/logs.test.ts create mode 100644 judging/server/src/services/logs.ts create mode 100644 judging/server/src/services/outbox.ts create mode 100644 judging/server/src/services/overtime.test.ts create mode 100644 judging/server/src/services/overtime.ts create mode 100644 judging/server/src/services/records.ts create mode 100644 judging/server/src/services/redis.integration.test.ts create mode 100644 judging/server/src/services/results.test.ts create mode 100644 judging/server/src/services/results.ts create mode 100644 judging/server/src/services/vote.test.ts create mode 100644 judging/server/src/services/vote.ts create mode 100644 judging/server/tsconfig.json create mode 100644 judging/server/vitest.config.ts create mode 100644 judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx create mode 100644 judging/web/app/e/[orgSlug]/[eventSlug]/feedback/[token]/page.tsx create mode 100644 judging/web/app/e/[orgSlug]/[eventSlug]/page.tsx create mode 100644 judging/web/app/j/[orgSlug]/[eventSlug]/desk.tsx create mode 100644 judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx create mode 100644 judging/web/app/layout.tsx create mode 100644 judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx create mode 100644 judging/web/app/o/[orgSlug]/[eventSlug]/editors.tsx create mode 100644 judging/web/app/o/[orgSlug]/[eventSlug]/page.tsx create mode 100644 judging/web/app/page.tsx create mode 100644 judging/web/e2e/visit.spec.ts create mode 100644 judging/web/eslint.config.mjs create mode 100644 judging/web/lib/api.ts create mode 100644 judging/web/lib/held.ts create mode 100644 judging/web/next.config.ts create mode 100644 judging/web/package.json create mode 100644 judging/web/playwright.config.ts create mode 100644 judging/web/public/manifest.webmanifest create mode 100644 judging/web/test-results/.last-run.json create mode 100644 judging/web/tsconfig.json create mode 100644 packages/api/src/routers/judge/replay.test.ts create mode 100644 packages/api/src/routers/judge/weight.ts create mode 100644 packages/api/src/services/panel-sync.ts diff --git a/.github/workflows/panel.yml b/.github/workflows/panel.yml new file mode 100644 index 00000000..69d4aed6 --- /dev/null +++ b/.github/workflows/panel.yml @@ -0,0 +1,75 @@ +name: panel + +on: + push: + paths: + - "judging/**" + - ".github/workflows/panel.yml" + pull_request: + paths: + - "judging/**" + - ".github/workflows/panel.yml" + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @panel/core --filter @panel/db --filter @panel/server --filter @panel/cli --filter @panel/web lint + - run: pnpm --filter @panel/core --filter @panel/db --filter @panel/server --filter @panel/cli --filter @panel/web typecheck + - run: pnpm --filter @panel/core build + - run: pnpm --filter @panel/web build + - run: pnpm --filter @panel/core --filter @panel/server --filter @panel/cli test + + publish: + if: startsWith(github.ref, 'refs/tags/panel-v') + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: . + file: judging/infra/docker/Dockerfile + push: true + tags: ghcr.io/${{ github.repository }}/panel-server:${{ github.ref_name }} + - uses: docker/build-push-action@v6 + with: + context: . + file: judging/infra/docker/Dockerfile.web + push: true + build-args: NEXT_PUBLIC_PANEL_URL=${{ vars.NEXT_PUBLIC_PANEL_URL || 'http://localhost:8787' }} + tags: ghcr.io/${{ github.repository }}/panel-web:${{ github.ref_name }} + + release-core: + if: startsWith(github.ref, 'refs/tags/panel-v') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + registry-url: https://registry.npmjs.org + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @panel/core build + - working-directory: judging/core + run: npm pkg set private=false && npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/docs/architecture.md b/docs/architecture.md index 64914af3..af80d523 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,7 +33,7 @@ ## Two products, one database -The schema is split on purpose. Mixing them previously made membership vanish when a new hackathon edition was drafted. +The schema is split on purpose. Mixing them previously made membership vanish when a new hackathon edition was drafted. Panel judging tables live in this same Postgres database. ### Club diff --git a/judging/.github/ISSUE_TEMPLATE/bug.md b/judging/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 00000000..6da6dc23 --- /dev/null +++ b/judging/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,11 @@ +--- +name: Bug +description: Something in panel behaved wrongly +labels: bug +--- + +## What happened + +## What you expected + +## How to reproduce diff --git a/judging/.github/ISSUE_TEMPLATE/feature.md b/judging/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 00000000..41836efd --- /dev/null +++ b/judging/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,9 @@ +--- +name: Feature +description: A change to how panel runs an event +labels: enhancement +--- + +## What should change + +## Who it is for diff --git a/judging/CHANGELOG.md b/judging/CHANGELOG.md new file mode 100644 index 00000000..2f5090c7 --- /dev/null +++ b/judging/CHANGELOG.md @@ -0,0 +1,8 @@ +# Panel + +## 0.0.0 + +- Pure core for dispatch, rubrics, phases, and ranking. +- Postgres schema with committed SQL migrations, a demo seed, and `panel` commands for migrate, seed, import, export, and doctor. +- HTTP server for judge sessions, results, health, metrics, and a websocket bus. +- Judge desk, organizer console, public board, and a feedback link. diff --git a/judging/CONTRIBUTING.md b/judging/CONTRIBUTING.md new file mode 100644 index 00000000..6df5c2bf --- /dev/null +++ b/judging/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contributing + +Panel is the tree under `judging/`. It does not import the rest of this repository. + +## Checks + +From the repository root: + +``` +pnpm --filter @panel/core test +pnpm --filter @panel/server test +pnpm --filter @panel/cli test +pnpm --filter @panel/core lint +pnpm --filter @panel/server lint +pnpm --filter @panel/web lint +pnpm --filter @panel/db typecheck +pnpm --filter @panel/server typecheck +pnpm --filter @panel/web typecheck +pnpm --filter @panel/cli typecheck +``` + +## Migrations + +Schema changes are SQL files in `judging/db/migrations`, applied with `panel migrate`. Do not rename or drop a column in the same change that ships code reading the new shape. + +## Pull requests + +Describe the behaviour a judge or an organizer will see. Include the command you ran. diff --git a/judging/README.md b/judging/README.md index 5b99dcec..1a23e22f 100644 --- a/judging/README.md +++ b/judging/README.md @@ -2,8 +2,10 @@ A hackathon judging product: its own data, its own API, its own web app. Rubrics, tracks, timers, rooms, and dispatch strategy are rows, not code. -Postgres and Node are enough to run it. Redis is optional and never required -for a correct result. +Postgres and Node are enough to run it. Set `REDIS_URL` when more than one +server process should share live updates. Without it, each process fans out +in memory, and a client that misses a message reloads the same state from +the next snapshot. `judging/` does not import the host application. The host may call `@panel/*`. When the product is stable it leaves this repository with @@ -14,10 +16,10 @@ When the product is stable it leaves this repository with | Path | Package | What it is | | --- | --- | --- | | `core/` | `@panel/core` | Pure functions. No I/O, no clock, no runtime dependencies. | -| `db/` | `@panel/db` | Schema and committed SQL migrations. Not built yet. | -| `server/` | `@panel/server` | HTTP API. Not built yet. | -| `web/` | `@panel/web` | Judge, admin, and public views. Not built yet. | -| `cli/` | `@panel/cli` | Migrate, seed, import, export. Not built yet. | +| `db/` | `@panel/db` | Schema, committed SQL migrations, demo seed. | +| `server/` | `@panel/server` | HTTP API: dispatch, votes, health, metrics. | +| `web/` | `@panel/web` | Judge desk, organizer console, public board. | +| `cli/` | `@panel/cli` | `panel migrate`, `panel seed demo`, `panel doctor`. | ## Run the core @@ -29,4 +31,26 @@ pnpm --filter @panel/core typecheck pnpm --filter @panel/core lint ``` -`pnpm test` includes `@panel/core`. +`pnpm test` includes `@panel/core` and `@panel/server`. + +## Database and server + +Postgres is the one database the rest of the monorepo uses (`DATABASE_URL`). Panel migrations add its tables there. `PANEL_DATABASE_URL` is only a fallback when `DATABASE_URL` is unset. + +``` +docker compose -f judging/infra/docker/docker-compose.yml up --build +``` + +On the host, pointed at that database: + +``` +$env:PANEL_DATABASE_URL = "postgres://panel:panel@localhost:54329/panel" +$env:PANEL_JWT_SECRET = "dev-only-change-me" +pnpm --filter @panel/cli panel migrate +pnpm --filter @panel/cli panel seed demo +pnpm --filter @panel/cli panel doctor +``` + +`panel doctor` checks Postgres, pending migrations, and `PANEL_JWT_SECRET`. Redis is reported and not required. + +The HTTP server is Hono. tRPC is mounted at `/trpc`. `GET /healthz` is the process, `GET /readyz` is Postgres, `GET /metrics` is Prometheus. `GET /openapi.json` lists the REST routes. How to authenticate is in `docs/api.md`. Magic-link codes are returned in the response only when `PANEL_DEV_AUTH=1`, which is how the compose stack signs in without SMTP. diff --git a/judging/cli/eslint.config.mjs b/judging/cli/eslint.config.mjs new file mode 100644 index 00000000..a96e7a28 --- /dev/null +++ b/judging/cli/eslint.config.mjs @@ -0,0 +1,27 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...config, + { + files: ["src/**/*.ts"], + rules: { "no-console": "off" }, + }, + { + files: ["src/**/*.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["@query", "@query/*"], + message: + "judging/ is a standalone product. Import from @panel/* only.", + }, + ], + }, + ], + }, + }, +]; diff --git a/judging/cli/package.json b/judging/cli/package.json new file mode 100644 index 00000000..027dc16c --- /dev/null +++ b/judging/cli/package.json @@ -0,0 +1,24 @@ +{ + "name": "@panel/cli", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "panel": "tsx src/main.ts", + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc --noEmit", + "test": "node ../../node_modules/vitest/vitest.mjs run src/csv.test.ts" + }, + "dependencies": { + "@panel/db": "workspace:*", + "@panel/server": "workspace:*", + "drizzle-orm": "0.45.3", + "tsx": "^4.23.15" + }, + "devDependencies": { + "@query/eslint-config": "workspace:*", + "@query/tsconfig": "workspace:*", + "@types/node": "22.20.4", + "typescript": "7.0.2" + } +} diff --git a/judging/cli/src/csv.test.ts b/judging/cli/src/csv.test.ts new file mode 100644 index 00000000..b451f79c --- /dev/null +++ b/judging/cli/src/csv.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { formatResultsCsv, formatResultsJson, parseProjectCsv } from "./csv"; + +describe("parseProjectCsv", () => { + it("reads a Devpost-style header", () => { + const rows = parseProjectCsv( + "Project Title,Submission Url,Team Name,Table\nAlpha,https://example.com/a,A Team,4\n", + ); + expect(rows).toEqual([ + { + externalId: "https://example.com/a", + name: "Alpha", + teamName: "A Team", + tableNumber: 4, + }, + ]); + }); + + it("uses a column map when the headers are not the defaults", () => { + const rows = parseProjectCsv("Title,Squad\nBeta,B Team\n", { name: "Title", team: "Squad" }); + expect(rows[0]?.name).toBe("Beta"); + expect(rows[0]?.teamName).toBe("B Team"); + }); + + it("rejects a sheet with no name column", () => { + expect(() => parseProjectCsv("notes\nhello\n")).toThrow(/name/); + }); +}); + +describe("formatResultsCsv", () => { + it("quotes a name that contains a comma", () => { + expect( + formatResultsCsv([{ project: "A, B", track: "general", placement: 1, score: 7.71 }]), + ).toBe('project,track,placement,score\n"A, B",general,1,7.71\n'); + }); + + it("writes the same rows as JSON", () => { + const rows = [{ project: "Alpha", track: "general", placement: 1, score: 7.71 }]; + expect(JSON.parse(formatResultsJson(rows))).toEqual(rows); + }); +}); diff --git a/judging/cli/src/csv.ts b/judging/cli/src/csv.ts new file mode 100644 index 00000000..3414472a --- /dev/null +++ b/judging/cli/src/csv.ts @@ -0,0 +1,127 @@ +export type ImportedProject = { + externalId: string; + name: string; + teamName: string | null; + tableNumber: number | null; +}; + +/** Devpost exports and a plain name,team,table sheet. The first row is headers. + * A column map renames headers: `name: Project Title`. + */ +export function parseProjectCsv( + text: string, + columnMap: Record = {}, +): ImportedProject[] { + const rows = parseRows(text); + const header = rows[0]; + if (!header) return []; + const index = new Map(header.map((cell, i) => [cell.trim().toLowerCase(), i])); + const nameAt = column(index, columnMap.name, ["project title", "name"]); + if (nameAt === undefined) { + throw new Error("CSV needs a name or Project Title column"); + } + const teamAt = column(index, columnMap.team, ["team name", "team"]); + const tableAt = column(index, columnMap.table, ["table", "table number"]); + const idAt = column(index, columnMap.externalId, ["submission url", "external id", "id"]); + + return rows.slice(1).flatMap((row) => { + const name = row[nameAt]?.trim(); + if (!name) return []; + const tableRaw = tableAt === undefined ? "" : (row[tableAt] ?? ""); + const tableNumber = tableRaw.trim() === "" ? null : Number(tableRaw); + return [ + { + externalId: (idAt === undefined ? "" : (row[idAt] ?? "")).trim() || name, + name, + teamName: teamAt === undefined ? null : row[teamAt]?.trim() || null, + tableNumber: tableNumber !== null && Number.isFinite(tableNumber) ? tableNumber : null, + }, + ]; + }); +} + +export type ExportedResult = { + project: string; + track: string; + placement: number | null; + score: number; +}; + +export function formatResultsCsv(rows: readonly ExportedResult[]): string { + const lines = ["project,track,placement,score"]; + for (const row of rows) { + lines.push( + [cell(row.project), cell(row.track), row.placement ?? "", row.score].join(","), + ); + } + return `${lines.join("\n")}\n`; +} + +export function formatResultsJson(rows: readonly ExportedResult[]): string { + return `${JSON.stringify(rows, null, 2)}\n`; +} + +export function parseColumnMap(text: string): Record { + const map: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const split = trimmed.indexOf(":"); + if (split === -1) continue; + map[trimmed.slice(0, split).trim()] = trimmed.slice(split + 1).trim(); + } + return map; +} + +function column( + index: Map, + mapped: string | undefined, + fallbacks: string[], +): number | undefined { + const names = mapped ? [mapped.toLowerCase(), ...fallbacks] : fallbacks; + for (const name of names) { + const at = index.get(name); + if (at !== undefined) return at; + } + return undefined; +} + +function cell(value: string): string { + if (/[",\n]/.test(value)) return `"${value.replaceAll('"', '""')}"`; + return value; +} + +function parseRows(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ""; + let quoted = false; + const source = text.replace(/^\uFEFF/, ""); + for (let i = 0; i < source.length; i += 1) { + const char = source[i]; + if (quoted) { + if (char === '"') { + if (source[i + 1] === '"') { + field += '"'; + i += 1; + } else quoted = false; + } else field += char; + continue; + } + if (char === '"') quoted = true; + else if (char === ",") { + row.push(field); + field = ""; + } else if (char === "\n") { + row.push(field); + rows.push(row); + row = []; + field = ""; + } else if (char !== "\r") field += char; + } + if (field.length > 0 || row.length > 0) { + row.push(field); + rows.push(row); + } + return rows.filter((item) => item.some((value) => value.trim() !== "")); +} diff --git a/judging/cli/src/main.ts b/judging/cli/src/main.ts new file mode 100644 index 00000000..5e9b0df8 --- /dev/null +++ b/judging/cli/src/main.ts @@ -0,0 +1,153 @@ +import { + appliedMigrations, + createDb, + migrate, + migrationFiles, + databaseUrl, + requireDatabaseUrl, + seedDemo, +} from "@panel/db"; +import { drainOutbox } from "@panel/server/outbox"; +import { exportResults, importProjects } from "./transfer"; + +const [command, arg] = process.argv.slice(2); + +async function main() { + if (command === "import" && arg === "projects") { + const positionals = positionalsAfter(4); + const file = positionals[0]; + const eventId = positionals[1]; + const mapFile = option("--map"); + if (!file || !eventId) { + console.log("usage: panel import projects [--map columns.yaml]"); + process.exit(1); + } + const count = await importProjects(file, eventId, mapFile); + console.log(count); + return; + } + + if (command === "export" && arg === "results") { + const format = option("--format") === "json" ? "json" : "csv"; + const positionals = positionalsAfter(4); + const eventId = positionals[0]; + const file = positionals[1] ?? (format === "json" ? "results.json" : "results.csv"); + if (!eventId) { + console.log("usage: panel export results [file] [--format csv|json]"); + process.exit(1); + } + const count = await exportResults(eventId, file, format); + console.log(count); + return; + } + + if (command === "migrate") { + const { pool } = createDb(requireDatabaseUrl()); + const fresh = await migrate(pool); + console.log(fresh.length > 0 ? fresh.join("\n") : "up to date"); + await pool.end(); + return; + } + + if (command === "seed" && arg === "demo") { + const { db, pool } = createDb(requireDatabaseUrl()); + await migrate(pool); + const seeded = await seedDemo(db); + console.log(seeded.eventId); + await pool.end(); + return; + } + + if (command === "outbox" && arg === "drain") { + const { db, pool } = createDb(requireDatabaseUrl()); + let total = 0; + try { + for (;;) { + const delivered = await drainOutbox(db); + total += delivered; + if (delivered === 0) break; + } + } finally { + await pool.end(); + } + console.log(total); + return; + } + + if (command === "doctor") { + const checks: { name: string; ok: boolean; detail: string }[] = []; + const url = databaseUrl(); + if (!url) { + checks.push({ + name: "postgres", + ok: false, + detail: "DATABASE_URL is not set", + }); + } else { + const { pool } = createDb(url); + try { + await pool.query("select 1"); + checks.push({ name: "postgres", ok: true, detail: "connected" }); + const files = await migrationFiles(); + const applied = await appliedMigrations(pool); + const pending = files.filter((file) => !applied.includes(file)); + checks.push({ + name: "migrations", + ok: pending.length === 0, + detail: pending.length > 0 ? `pending ${pending.join(", ")}` : "up to date", + }); + } catch (error) { + checks.push({ + name: "postgres", + ok: false, + detail: error instanceof Error ? error.message : "connection failed", + }); + } finally { + await pool.end(); + } + } + + const secret = process.env.PANEL_JWT_SECRET; + checks.push({ + name: "jwt", + ok: Boolean(secret), + detail: secret ? "PANEL_JWT_SECRET is set" : "PANEL_JWT_SECRET is not set", + }); + checks.push({ + name: "redis", + ok: true, + detail: process.env.REDIS_URL ? "REDIS_URL is set" : "not configured", + }); + + for (const check of checks) { + console.log(`${check.ok ? "ok" : "fail"} ${check.name} ${check.detail}`); + } + if (checks.some((check) => !check.ok)) process.exit(1); + return; + } + + console.log( + "usage: panel migrate | panel seed demo | panel doctor | panel outbox drain | panel import projects | panel export results [file]", + ); + process.exit(1); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); + +function option(name: string): string | undefined { + const index = process.argv.indexOf(name); + if (index === -1) return undefined; + return process.argv[index + 1]; +} + +function positionalsAfter(start: number): string[] { + const args = process.argv.slice(start); + return args.filter((value, index) => { + if (value.startsWith("--")) return false; + const previous = args[index - 1]; + return previous !== "--format" && previous !== "--map"; + }); +} diff --git a/judging/cli/src/transfer.ts b/judging/cli/src/transfer.ts new file mode 100644 index 00000000..8daa0c5c --- /dev/null +++ b/judging/cli/src/transfer.ts @@ -0,0 +1,67 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { and, eq } from "drizzle-orm"; +import { createDb, project, requireDatabaseUrl, result, resultRun, track } from "@panel/db"; +import { formatResultsCsv, formatResultsJson, parseColumnMap, parseProjectCsv } from "./csv"; + +export async function importProjects(file: string, eventId: string, mapFile?: string) { + const columnMap = mapFile ? parseColumnMap(await readFile(mapFile, "utf8")) : {}; + const rows = parseProjectCsv(await readFile(file, "utf8"), columnMap); + const { db, pool } = createDb(requireDatabaseUrl()); + try { + for (const row of rows) { + const [found] = await db + .select({ id: project.id }) + .from(project) + .where(and(eq(project.eventId, eventId), eq(project.externalId, row.externalId))); + if (found) { + await db + .update(project) + .set({ name: row.name, teamName: row.teamName, tableNumber: row.tableNumber }) + .where(eq(project.id, found.id)); + } else { + await db.insert(project).values({ + eventId, + externalId: row.externalId, + name: row.name, + teamName: row.teamName, + tableNumber: row.tableNumber, + }); + } + } + return rows.length; + } finally { + await pool.end(); + } +} + +export async function exportResults( + eventId: string, + file: string, + format: "csv" | "json" = "csv", +) { + const { db, pool } = createDb(requireDatabaseUrl()); + try { + const [run] = await db + .select({ id: resultRun.id }) + .from(resultRun) + .where(eq(resultRun.eventId, eventId)); + if (!run) throw new Error("No result run for that event"); + const rows = await db + .select({ + project: project.name, + track: track.slug, + placement: result.placement, + score: result.score, + }) + .from(result) + .innerJoin(project, eq(project.id, result.projectId)) + .innerJoin(track, eq(track.id, result.trackId)) + .where(eq(result.runId, run.id)); + const body = + format === "json" ? formatResultsJson(rows) : formatResultsCsv(rows); + await writeFile(file, body); + return rows.length; + } finally { + await pool.end(); + } +} diff --git a/judging/cli/tsconfig.json b/judging/cli/tsconfig.json new file mode 100644 index 00000000..f54bde2c --- /dev/null +++ b/judging/cli/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tooling/typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/judging/core/package.json b/judging/core/package.json index fd17a170..8f0f6bbf 100644 --- a/judging/core/package.json +++ b/judging/core/package.json @@ -7,8 +7,19 @@ "exports": { ".": "./src/index.ts" }, + "files": ["dist"], + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } + }, "scripts": { "lint": "eslint . --max-warnings 0", + "build": "tsc -p tsconfig.build.json && node scripts/emit-extensions.mjs", "typecheck": "tsc --noEmit", "test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts" }, diff --git a/judging/core/scripts/emit-extensions.mjs b/judging/core/scripts/emit-extensions.mjs new file mode 100644 index 00000000..4efdcb3b --- /dev/null +++ b/judging/core/scripts/emit-extensions.mjs @@ -0,0 +1,25 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const dist = join(dirname(fileURLToPath(import.meta.url)), "../dist"); + +await walk(dist); + +async function walk(dir) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(path); + continue; + } + if (!entry.name.endsWith(".js")) continue; + const source = await readFile(path, "utf8"); + const next = source.replaceAll( + /(from\s+["'])(\.\.?\/[^"']+)(["'])/g, + (match, open, spec, close) => + spec.endsWith(".js") ? match : `${open}${spec}.js${close}`, + ); + if (next !== source) await writeFile(path, next); + } +} diff --git a/judging/core/src/aggregate/rank.test.ts b/judging/core/src/aggregate/rank.test.ts index ce684acd..800c28d9 100644 --- a/judging/core/src/aggregate/rank.test.ts +++ b/judging/core/src/aggregate/rank.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { zNormalize } from "./zscore"; import { bayesianShrink, round2 } from "./bayes"; import { rank } from "./rank"; -import type { ProjectInput, RankConfig, VoteInput } from "./rank"; +import type { ProjectInput, RankComparison, RankConfig, VoteInput } from "./rank"; /** * The ranking the current event publishes: z-score per judge against the @@ -202,3 +202,43 @@ describe("rank", () => { expect(placed.map((row) => row.projectId)).toEqual(["A", "B", "C"]); }); }); + +describe("rank at pairwise weight 1", () => { + it("keeps the true order when every comparison agrees", () => { + const ids = ["a", "b", "c", "d", "e"]; + let seed = 11; + const random = () => { + seed = (seed * 1664525 + 1013904223) % 4294967296; + return seed / 4294967296; + }; + for (let trial = 0; trial < 20; trial += 1) { + const truth = [...ids].sort(() => random() - 0.5); + const better = new Map(truth.map((id, index) => [id, index])); + const comparisons: RankComparison[] = []; + for (let left = 0; left < ids.length; left += 1) { + for (let right = left + 1; right < ids.length; right += 1) { + const a = ids[left] ?? ""; + const b = ids[right] ?? ""; + const aWins = (better.get(a) ?? 0) < (better.get(b) ?? 0); + comparisons.push({ + judgeId: "j1", + judgeGroup: "main", + a, + b, + outcome: aWins ? ("a" as const) : ("b" as const), + }); + } + } + const rows = rank( + ids.map((id) => ({ id, trackIds: ["overall"] })), + [], + comparisons, + { ...config, pairwiseWeight: 1 }, + ); + const placed = rows + .filter((row) => row.placement !== null) + .sort((left, right) => (left.placement ?? 0) - (right.placement ?? 0)); + expect(placed.map((row) => row.projectId)).toEqual(truth); + } + }); +}); diff --git a/judging/core/src/aggregate/rank.ts b/judging/core/src/aggregate/rank.ts index 0b687d32..6f1fa9d8 100644 --- a/judging/core/src/aggregate/rank.ts +++ b/judging/core/src/aggregate/rank.ts @@ -249,6 +249,14 @@ function rankTrack( ); placeable.sort((a, b) => { if (a.score !== b.score) return b.score - a.score; + // Hundredths can tie two neighbours. The unrounded pairwise strength + // still has the order the comparisons produced. + if ( + config.pairwiseWeight > 0 && + a.pairwiseComponent !== b.pairwiseComponent + ) { + return b.pairwiseComponent - a.pairwiseComponent; + } return (order.get(a.projectId) ?? 0) - (order.get(b.projectId) ?? 0); }); placeable.forEach((row, index) => { diff --git a/judging/core/src/dispatch.sim.test.ts b/judging/core/src/dispatch.sim.test.ts new file mode 100644 index 00000000..fa2f6396 --- /dev/null +++ b/judging/core/src/dispatch.sim.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { pickNext } from "./dispatch"; +import type { Candidate, DispatchStrategy } from "./dispatch"; +import { DEFAULT_TIMERS } from "./timers"; +import { scoreUncertainty } from "./uncertainty"; + +/** + * Twenty judges, two hundred projects, three hours at the table. The same + * noise is available to both strategies; only the choice of who is seen + * again changes. The ten truly strongest projects should end up in an order + * closer to the truth when extra looks go to the unsettled ones. + */ +describe("uncertainty dispatch", () => { + it("ranks the true top 10 better than even coverage at the same number of looks", () => { + const projects = 200; + const judges = 20; + const hours = 3; + const budget = judges * Math.floor((hours * 3600) / DEFAULT_TIMERS.targetSeconds); + const truth = Array.from({ length: projects }, (_, index) => index); + const noise = truth.map((_, project) => + Array.from({ length: 40 }, (_, look) => + gaussian(mulberry32(project * 1000 + look + 1)) * noiseScale(project), + ), + ); + + const coverage = simulate("coverage", { projects, judges, budget, truth, noise }); + const uncertainty = simulate("uncertainty", { projects, judges, budget, truth, noise }); + const top = truth.map((_, index) => index).slice(-10); + + expect(kendall(top, uncertainty, truth)).toBeGreaterThan(kendall(top, coverage, truth)); + }); +}); + +function noiseScale(project: number): number { + return project >= 185 ? 18 : 3; +} + +function simulate( + strategy: DispatchStrategy, + input: { + projects: number; + judges: number; + budget: number; + truth: number[]; + noise: number[][]; + }, +): number[] { + const scores = Array.from({ length: input.projects }, () => [] as number[]); + const seen = Array.from({ length: input.judges }, () => new Set()); + const last: (number | null)[] = Array.from({ length: input.judges }, () => null); + let used = 0; + let guard = 0; + while (used < input.budget && guard < input.budget) { + for (let judge = 0; judge < input.judges && used < input.budget; judge += 1) { + const candidates: Candidate[] = []; + for (let project = 0; project < input.projects; project += 1) { + if (seen[judge]?.has(project)) continue; + candidates.push({ + id: String(project), + tableNumber: project, + coverage: scores[project]?.length ?? 0, + claimedAt: null, + uncertainty: scoreUncertainty(scores[project] ?? []), + }); + } + const pick = pickNext( + candidates, + { lastTable: last[judge] ?? null }, + { strategy, minLooksPerProject: 2 }, + () => 0, + ); + if (!pick) continue; + const project = Number(pick.id); + const look = scores[project]?.length ?? 0; + scores[project]?.push((input.truth[project] ?? 0) + (input.noise[project]?.[look] ?? 0)); + seen[judge]?.add(project); + last[judge] = project; + used += 1; + } + guard += 1; + } + return scores.map((row, index) => + row.length === 0 + ? (input.truth[index] ?? 0) + : row.reduce((sum, value) => sum + value, 0) / row.length, + ); +} + +function kendall(ids: number[], estimate: number[], truth: number[]): number { + let concordant = 0; + let discordant = 0; + for (let left = 0; left < ids.length; left += 1) { + for (let right = left + 1; right < ids.length; right += 1) { + const a = ids[left] ?? 0; + const b = ids[right] ?? 0; + const truthSign = Math.sign((truth[a] ?? 0) - (truth[b] ?? 0)); + const estimateSign = Math.sign((estimate[a] ?? 0) - (estimate[b] ?? 0)); + if (truthSign === 0 || estimateSign === 0) continue; + if (truthSign === estimateSign) concordant += 1; + else discordant += 1; + } + } + return (concordant - discordant) / (concordant + discordant); +} + +function gaussian(rng: () => number): number { + const u = Math.max(rng(), 1e-9); + const v = rng(); + return Math.sqrt(-2 * Math.log(u)) * Math.cos(Math.PI * 2 * v); +} + +function mulberry32(seed: number): () => number { + let state = seed; + return () => { + state = (state + 0x6d2b79f5) | 0; + let value = Math.imul(state ^ (state >>> 15), 1 | state); + value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value; + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +} diff --git a/judging/core/src/events.ts b/judging/core/src/events.ts index 6e9278ed..63c04d3e 100644 --- a/judging/core/src/events.ts +++ b/judging/core/src/events.ts @@ -8,12 +8,22 @@ export const LOG_KINDS = [ "project.upserted", "project.withdrawn", "project.table_assigned", + "project.qr_rotated", + "track.saved", + "prize.saved", + "zone.saved", + "rubric.saved", + "table.placed", + "webhook.saved", + "api_key.created", + "config.saved", "judge.upserted", "judge.approved", "judge.suspended", "visit.handed_out", "visit.arrived", "visit.voided", + "visit.skipped", "vote.cast", "comparison.cast", "results.computed", @@ -31,6 +41,7 @@ export const OUTBOX_TOPICS = [ "vote.cast", "phase.changed", "results.published", + "results.unpublished", "judge.recalled", ] as const; diff --git a/judging/core/src/index.ts b/judging/core/src/index.ts index 6acfb348..a65497e1 100644 --- a/judging/core/src/index.ts +++ b/judging/core/src/index.ts @@ -12,6 +12,8 @@ export type { } from "./aggregate/rank"; export type { Comparison } from "./comparison"; export { pickNext } from "./dispatch"; +export { assignTables } from "./tables"; +export type { Assignment, Seat, SeatedProject } from "./tables"; export type { Candidate, DispatchConfig, diff --git a/judging/core/src/standalone.test.ts b/judging/core/src/standalone.test.ts new file mode 100644 index 00000000..166726ab --- /dev/null +++ b/judging/core/src/standalone.test.ts @@ -0,0 +1,34 @@ +import { readdir, readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const root = join(dirname(fileURLToPath(import.meta.url)), "../.."); + +describe("standalone tree", () => { + it("does not mention the host club", async () => { + const hits: string[] = []; + await walk(root, hits); + expect(hits).toEqual([]); + }); +}); + +async function walk(dir: string, hits: string[]) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === "node_modules" || entry.name === ".next" || entry.name === "dist") { + continue; + } + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(path, hits); + continue; + } + if (!/\.(ts|tsx|md|sql|yml|json|tf)$/.test(entry.name)) continue; + const text = await readFile(path, "utf8"); + const needles = ["DS" + "GT", "Hack" + "lytics", "datascience" + "gt"]; + if (needles.some((needle) => text.toLowerCase().includes(needle.toLowerCase()))) { + hits.push(path); + } + } +} diff --git a/judging/core/src/tables.test.ts b/judging/core/src/tables.test.ts new file mode 100644 index 00000000..b02827c8 --- /dev/null +++ b/judging/core/src/tables.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { assignTables } from "./tables"; + +describe("assignTables", () => { + it("alternates tracks across table numbers", () => { + const seated = assignTables( + [ + { id: "a1", trackId: "sponsor" }, + { id: "a2", trackId: "sponsor" }, + { id: "b1", trackId: "general" }, + { id: "b2", trackId: "general" }, + ], + [ + { number: 1, zoneId: "hall" }, + { number: 2, zoneId: "hall" }, + { number: 3, zoneId: "hall" }, + { number: 4, zoneId: "hall" }, + ], + ); + expect(seated.map((row) => row.projectId)).toEqual(["a1", "b1", "a2", "b2"]); + expect(seated.map((row) => row.tableNumber)).toEqual([1, 2, 3, 4]); + }); + + it("leaves extra projects unseated when tables run out", () => { + const seated = assignTables( + [ + { id: "a", trackId: null }, + { id: "b", trackId: null }, + ], + [{ number: 9, zoneId: null }], + ); + expect(seated).toEqual([{ projectId: "a", tableNumber: 9, zoneId: null }]); + }); +}); diff --git a/judging/core/src/tables.ts b/judging/core/src/tables.ts new file mode 100644 index 00000000..af287f1d --- /dev/null +++ b/judging/core/src/tables.ts @@ -0,0 +1,52 @@ +export type Seat = { + number: number; + zoneId: string | null; +}; + +export type SeatedProject = { + id: string; + trackId: string | null; +}; + +export type Assignment = { + projectId: string; + tableNumber: number; + zoneId: string | null; +}; + +/** + * Seats projects so tracks take turns. Two sponsor projects do not land on + * neighbouring tables while another track is still waiting for a seat. + */ +export function assignTables( + projects: readonly SeatedProject[], + tables: readonly Seat[], +): Assignment[] { + const queues = new Map(); + for (const project of projects) { + const key = project.trackId ?? ""; + const queue = queues.get(key) ?? []; + queue.push(project); + queues.set(key, queue); + } + const lanes = [...queues.values()]; + const ordered: SeatedProject[] = []; + while (ordered.length < projects.length) { + for (const lane of lanes) { + const next = lane.shift(); + if (next) ordered.push(next); + } + } + const seats = [...tables].sort((a, b) => a.number - b.number); + return ordered.slice(0, seats.length).flatMap((project, index) => { + const seat = seats[index]; + if (!seat) return []; + return [ + { + projectId: project.id, + tableNumber: seat.number, + zoneId: seat.zoneId, + }, + ]; + }); +} diff --git a/judging/core/tsconfig.build.json b/judging/core/tsconfig.build.json new file mode 100644 index 00000000..2da00172 --- /dev/null +++ b/judging/core/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "node_modules", "dist"] +} diff --git a/judging/db/eslint.config.mjs b/judging/db/eslint.config.mjs new file mode 100644 index 00000000..827bfcb7 --- /dev/null +++ b/judging/db/eslint.config.mjs @@ -0,0 +1,24 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...config, + { + files: ["src/**/*.ts"], + ignores: ["src/env.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["@query", "@query/*"], + message: + "judging/ is a standalone product. Import from @panel/* only.", + }, + ], + }, + ], + }, + }, +]; diff --git a/judging/db/package.json b/judging/db/package.json new file mode 100644 index 00000000..e6e07b1b --- /dev/null +++ b/judging/db/package.json @@ -0,0 +1,25 @@ +{ + "name": "@panel/db", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@panel/core": "workspace:*", + "drizzle-orm": "0.45.3", + "pg": "8.23.0" + }, + "devDependencies": { + "@query/eslint-config": "workspace:*", + "@query/tsconfig": "workspace:*", + "@types/node": "22.20.4", + "@types/pg": "^8.23.1", + "typescript": "7.0.2" + } +} diff --git a/judging/db/src/client.ts b/judging/db/src/client.ts new file mode 100644 index 00000000..6824b41c --- /dev/null +++ b/judging/db/src/client.ts @@ -0,0 +1,16 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import * as schema from "./schema"; + +export function createDb(url: string) { + const pool = new Pool({ connectionString: url, max: 10 }); + pool.on("error", () => { + // The pool drops a dead client on its own. An unhandled "error" event + // would take the process down. + }); + const db = drizzle(pool, { schema }); + return { db, pool }; +} + +export type PanelDb = ReturnType["db"]; +export type PanelTx = Parameters[0]>[0]; diff --git a/judging/db/src/env.ts b/judging/db/src/env.ts new file mode 100644 index 00000000..8966aa59 --- /dev/null +++ b/judging/db/src/env.ts @@ -0,0 +1,11 @@ +export function databaseUrl(): string | null { + return process.env.DATABASE_URL ?? process.env.PANEL_DATABASE_URL ?? null; +} + +export function requireDatabaseUrl(): string { + const url = databaseUrl(); + if (!url) { + throw new Error("Set DATABASE_URL to the Postgres database"); + } + return url; +} diff --git a/judging/db/src/index.ts b/judging/db/src/index.ts new file mode 100644 index 00000000..4514dd03 --- /dev/null +++ b/judging/db/src/index.ts @@ -0,0 +1,6 @@ +export { createDb } from "./client"; +export type { PanelDb, PanelTx } from "./client"; +export { databaseUrl, requireDatabaseUrl } from "./env"; +export { appliedMigrations, migrate, migrationFiles } from "./migrate"; +export * from "./schema"; +export { seedDemo } from "./seed"; diff --git a/judging/db/src/migrate.ts b/judging/db/src/migrate.ts new file mode 100644 index 00000000..9a5e8c76 --- /dev/null +++ b/judging/db/src/migrate.ts @@ -0,0 +1,69 @@ +import { readdir, readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Pool } from "pg"; + +const migrationsDir = join(dirname(fileURLToPath(import.meta.url)), "../migrations"); + +const MIGRATION_TABLE = ` + create table if not exists panel_migration ( + id text primary key, + applied_at timestamptz not null default now() + ) +`; + +/** Applies committed SQL files in order. Each file runs in one transaction. */ +export async function migrate(pool: Pool): Promise { + const client = await pool.connect(); + try { + await client.query(MIGRATION_TABLE); + const applied = await client.query<{ id: string }>( + "select id from panel_migration", + ); + const done = new Set(applied.rows.map((row) => row.id)); + const files = await migrationFiles(); + const fresh: string[] = []; + + for (const file of files) { + if (done.has(file)) continue; + const body = await readFile(join(migrationsDir, file), "utf8"); + try { + await client.query("begin"); + await client.query(body); + await client.query("insert into panel_migration (id) values ($1)", [ + file, + ]); + await client.query("commit"); + } catch (error) { + await client.query("rollback"); + throw error; + } + fresh.push(file); + } + return fresh; + } finally { + client.release(); + } +} + +export async function appliedMigrations(pool: Pool): Promise { + const client = await pool.connect(); + try { + const exists = await client.query<{ present: boolean }>( + `select to_regclass('panel_migration') is not null as present`, + ); + const row = exists.rows[0]; + if (!row?.present) return []; + const applied = await client.query<{ id: string }>( + "select id from panel_migration order by id", + ); + return applied.rows.map((item) => item.id); + } finally { + client.release(); + } +} + +export async function migrationFiles(): Promise { + const files = await readdir(migrationsDir); + return files.filter((name) => name.endsWith(".sql")).sort(); +} diff --git a/judging/db/src/schema.ts b/judging/db/src/schema.ts new file mode 100644 index 00000000..e206ba77 --- /dev/null +++ b/judging/db/src/schema.ts @@ -0,0 +1,433 @@ +import { + boolean, + integer, + jsonb, + numeric, + pgEnum, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; + +export const eventPhase = pgEnum("event_phase", [ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", +]); + +export const trackKind = pgEnum("track_kind", ["main", "sponsor", "special"]); + +export const dispatchStrategy = pgEnum("dispatch_strategy", [ + "coverage", + "uncertainty", +]); + +export const judgeStatus = pgEnum("judge_status", [ + "invited", + "applied", + "approved", + "suspended", +]); + +export const comparisonOutcome = pgEnum("comparison_outcome", ["a", "b", "tie"]); + +export const membershipRole = pgEnum("membership_role", [ + "owner", + "admin", + "organizer", + "volunteer", +]); + +export const organization = pgTable("organization", { + id: uuid("id").defaultRandom().primaryKey(), + slug: text("slug").notNull().unique(), + name: text("name").notNull(), + branding: jsonb("branding").notNull().default({}), +}); + +export const user = pgTable("panel_user", { + id: uuid("id").defaultRandom().primaryKey(), + email: text("email").notNull().unique(), + name: text("name").notNull(), +}); + +export const event = pgTable( + "panel_event", + { + id: uuid("id").defaultRandom().primaryKey(), + orgId: uuid("org_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + slug: text("slug").notNull(), + name: text("name").notNull(), + startsAt: timestamp("starts_at", { withTimezone: true }), + endsAt: timestamp("ends_at", { withTimezone: true }), + phase: eventPhase("phase").notNull().default("setup"), + }, + (table) => [uniqueIndex("event_org_slug_idx").on(table.orgId, table.slug)], +); + +export const eventConfig = pgTable("event_config", { + eventId: uuid("event_id") + .primaryKey() + .references(() => event.id, { onDelete: "cascade" }), + targetSeconds: integer("target_seconds").notNull(), + hardLimitSeconds: integer("hard_limit_seconds").notNull(), + walkLimitSeconds: integer("walk_limit_seconds").notNull(), + submitGraceSeconds: integer("submit_grace_seconds").notNull(), + minLooksPerProject: integer("min_looks_per_project").notNull(), + dispatchStrategy: dispatchStrategy("dispatch_strategy").notNull(), + pairwiseEnabled: boolean("pairwise_enabled").notNull().default(false), + pairwiseWeight: numeric("pairwise_weight", { mode: "number" }).notNull().default(0), + bayesianC: numeric("bayesian_c", { mode: "number" }).notNull().default(2), + calibrationLooks: integer("calibration_looks").notNull().default(0), + calibrationWeight: numeric("calibration_weight", { mode: "number" }) + .notNull() + .default(1), + feedbackCardsEnabled: boolean("feedback_cards_enabled").notNull().default(false), + leaderboardPublic: boolean("leaderboard_public").notNull().default(false), + boardPollSeconds: integer("board_poll_seconds").notNull().default(5), +}); + +export const rubric = pgTable("rubric", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + name: text("name").notNull(), + isDefault: boolean("is_default").notNull().default(false), +}); + +export const criterion = pgTable( + "criterion", + { + id: uuid("id").defaultRandom().primaryKey(), + rubricId: uuid("rubric_id") + .notNull() + .references(() => rubric.id, { onDelete: "cascade" }), + position: integer("position").notNull(), + key: text("key").notNull(), + label: text("label").notNull(), + description: text("description"), + min: numeric("min", { mode: "number" }).notNull(), + max: numeric("max", { mode: "number" }).notNull(), + weight: numeric("weight", { mode: "number" }).notNull(), + anchors: jsonb("anchors").notNull().default([]), + }, + (table) => [uniqueIndex("criterion_rubric_key_idx").on(table.rubricId, table.key)], +); + +export const track = pgTable( + "track", + { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + slug: text("slug").notNull(), + name: text("name").notNull(), + kind: trackKind("kind").notNull(), + judgeGroup: text("judge_group").notNull(), + rubricId: uuid("rubric_id").references(() => rubric.id), + position: integer("position").notNull().default(0), + isActive: boolean("is_active").notNull().default(true), + }, + (table) => [uniqueIndex("track_event_slug_idx").on(table.eventId, table.slug)], +); + +export const prize = pgTable( + "prize", + { + id: uuid("id").defaultRandom().primaryKey(), + trackId: uuid("track_id") + .notNull() + .references(() => track.id, { onDelete: "cascade" }), + place: integer("place").notNull(), + title: text("title").notNull(), + amount: numeric("amount", { mode: "number" }), + description: text("description"), + }, + (table) => [uniqueIndex("prize_track_place_idx").on(table.trackId, table.place)], +); + +export const zone = pgTable("zone", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + name: text("name").notNull(), + position: integer("position").notNull().default(0), +}); + +/** Floor plan. The SQL name is floor_table because `table` is reserved. */ +export const floorTable = pgTable( + "floor_table", + { + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + number: integer("number").notNull(), + zoneId: uuid("zone_id").references(() => zone.id), + x: numeric("x", { mode: "number" }), + y: numeric("y", { mode: "number" }), + }, + (table) => [primaryKey({ columns: [table.eventId, table.number] })], +); + +export const project = pgTable("project", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + externalId: text("external_id"), + name: text("name").notNull(), + description: text("description"), + teamName: text("team_name"), + members: jsonb("members").notNull().default([]), + links: jsonb("links").notNull().default({}), + tableNumber: integer("table_number"), + zoneId: uuid("zone_id").references(() => zone.id), + qrToken: uuid("qr_token").notNull().unique().defaultRandom(), + feedbackToken: uuid("feedback_token").unique(), + arrivedFirstAt: timestamp("arrived_first_at", { withTimezone: true }), + withdrawnAt: timestamp("withdrawn_at", { withTimezone: true }), +}); + +export const projectTrack = pgTable( + "project_track", + { + projectId: uuid("project_id") + .notNull() + .references(() => project.id, { onDelete: "cascade" }), + trackId: uuid("track_id") + .notNull() + .references(() => track.id, { onDelete: "cascade" }), + }, + (table) => [primaryKey({ columns: [table.projectId, table.trackId] })], +); + +export const judge = pgTable( + "panel_judge", + { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + externalId: text("external_id"), + name: text("name").notNull(), + email: text("email").notNull(), + org: text("org"), + title: text("title"), + status: judgeStatus("status").notNull().default("invited"), + trackId: uuid("track_id").references(() => track.id), + zoneId: uuid("zone_id").references(() => zone.id), + isLead: boolean("is_lead").notNull().default(false), + }, + (table) => [uniqueIndex("judge_event_email_idx").on(table.eventId, table.email)], +); + +export const visit = pgTable( + "visit", + { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + judgeId: uuid("judge_id") + .notNull() + .references(() => judge.id, { onDelete: "cascade" }), + projectId: uuid("project_id") + .notNull() + .references(() => project.id, { onDelete: "cascade" }), + judgeGroup: text("judge_group").notNull(), + handedOutAt: timestamp("handed_out_at", { withTimezone: true }).notNull(), + arrivedAt: timestamp("arrived_at", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + voidedAt: timestamp("voided_at", { withTimezone: true }), + voidReason: text("void_reason"), + }, + (table) => [ + uniqueIndex("visit_judge_project_open_idx") + .on(table.judgeId, table.projectId) + .where(sql`voided_at is null`), + uniqueIndex("visit_one_open_idx") + .on(table.judgeId) + .where(sql`voided_at is null and completed_at is null`), + ], +); + +export const vote = pgTable("vote", { + id: uuid("id").defaultRandom().primaryKey(), + visitId: uuid("visit_id") + .notNull() + .unique() + .references(() => visit.id, { onDelete: "cascade" }), + judgeId: uuid("judge_id") + .notNull() + .references(() => judge.id, { onDelete: "cascade" }), + projectId: uuid("project_id") + .notNull() + .references(() => project.id, { onDelete: "cascade" }), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + total: numeric("total", { mode: "number" }).notNull(), + comment: text("comment"), + durationSeconds: integer("duration_seconds"), + isCalibration: boolean("is_calibration").notNull().default(false), +}); + +export const voteScore = pgTable( + "vote_score", + { + voteId: uuid("vote_id") + .notNull() + .references(() => vote.id, { onDelete: "cascade" }), + criterionId: uuid("criterion_id") + .notNull() + .references(() => criterion.id), + value: numeric("value", { mode: "number" }).notNull(), + }, + (table) => [primaryKey({ columns: [table.voteId, table.criterionId] })], +); + +export const comparison = pgTable("comparison", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + judgeId: uuid("judge_id") + .notNull() + .references(() => judge.id, { onDelete: "cascade" }), + judgeGroup: text("judge_group").notNull(), + aProjectId: uuid("a_project_id") + .notNull() + .references(() => project.id), + bProjectId: uuid("b_project_id") + .notNull() + .references(() => project.id), + outcome: comparisonOutcome("outcome").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const resultRun = pgTable("result_run", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + computedAt: timestamp("computed_at", { withTimezone: true }).notNull().defaultNow(), + computedBy: uuid("computed_by").references(() => user.id), + configSnapshot: jsonb("config_snapshot").notNull(), + publishedAt: timestamp("published_at", { withTimezone: true }), + notes: text("notes"), +}); + +export const result = pgTable( + "result", + { + runId: uuid("run_id") + .notNull() + .references(() => resultRun.id, { onDelete: "cascade" }), + projectId: uuid("project_id") + .notNull() + .references(() => project.id, { onDelete: "cascade" }), + trackId: uuid("track_id") + .notNull() + .references(() => track.id, { onDelete: "cascade" }), + placement: integer("placement"), + score: numeric("score", { mode: "number" }).notNull(), + rubricComponent: numeric("rubric_component", { mode: "number" }).notNull(), + pairwiseComponent: numeric("pairwise_component", { mode: "number" }).notNull(), + voteCount: integer("vote_count").notNull(), + comparisonCount: integer("comparison_count").notNull(), + flags: text("flags").array().notNull().default([]), + }, + (table) => [ + primaryKey({ columns: [table.runId, table.projectId, table.trackId] }), + ], +); + +export const eventLog = pgTable("event_log", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + kind: text("kind").notNull(), + actor: jsonb("actor").notNull().default({}), + subject: jsonb("subject").notNull().default({}), + payload: jsonb("payload").notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const outbox = pgTable("outbox", { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + topic: text("topic").notNull(), + payload: jsonb("payload").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + deliveredAt: timestamp("delivered_at", { withTimezone: true }), + attempts: integer("attempts").notNull().default(0), +}); + +export const webhook = pgTable("webhook", { + id: uuid("id").defaultRandom().primaryKey(), + orgId: uuid("org_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + url: text("url").notNull(), + secret: text("secret").notNull(), + topics: text("topics").array().notNull(), + isActive: boolean("is_active").notNull().default(true), +}); + +export const apiKey = pgTable("api_key", { + id: uuid("id").defaultRandom().primaryKey(), + orgId: uuid("org_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + hashedKey: text("hashed_key").notNull(), + scopes: text("scopes").array().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), +}); + +export const membership = pgTable( + "membership", + { + userId: uuid("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + orgId: uuid("org_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + role: membershipRole("role").notNull(), + }, + (table) => [primaryKey({ columns: [table.userId, table.orgId] })], +); + +export const judgeIdentity = pgTable("judge_identity", { + judgeId: uuid("judge_id") + .primaryKey() + .references(() => judge.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), +}); + +export const loginCode = pgTable("login_code", { + id: uuid("id").defaultRandom().primaryKey(), + email: text("email").notNull(), + codeHash: text("code_hash").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + consumedAt: timestamp("consumed_at", { withTimezone: true }), +}); diff --git a/judging/db/src/seed.ts b/judging/db/src/seed.ts new file mode 100644 index 00000000..a313315f --- /dev/null +++ b/judging/db/src/seed.ts @@ -0,0 +1,238 @@ +import { eq, sql } from "drizzle-orm"; +import { DEFAULT_TIMERS } from "@panel/core"; +import type { PanelDb, PanelTx } from "./client"; +import { + criterion, + event, + eventConfig, + floorTable, + judge, + membership, + organization, + prize, + project, + projectTrack, + rubric, + track, + user, + zone, +} from "./schema"; + +const CRITERIA = [ + ["creativity", "Creativity"], + ["impact", "Impact"], + ["scope", "Scope"], + ["clarity", "Clarity"], + ["soundness", "Soundness"], +] as const; + +/** + * One event a self-hoster can click through: 40 projects, 6 judges, three + * tracks, and a second rubric on the sponsor track. Re-running replaces the + * demo organization. + */ +export async function seedDemo(db: PanelDb): Promise<{ eventId: string }> { + return db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext('panel:seed:demo'))`); + return insertDemo(tx); + }); +} + +async function insertDemo(db: PanelTx): Promise<{ eventId: string }> { + const existing = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.slug, "demo")); + const previous = existing[0]; + if (previous) { + await db.delete(organization).where(eq(organization.id, previous.id)); + } + + const [org] = await db + .insert(organization) + .values({ + slug: "demo", + name: "Demo", + branding: { tagline: "A practice floor" }, + }) + .returning({ id: organization.id }); + if (!org) throw new Error("demo organization was not created"); + + await db.delete(user).where(eq(user.email, "organizer@demo.panel")); + const [organizer] = await db + .insert(user) + .values({ email: "organizer@demo.panel", name: "Demo organizer" }) + .returning({ id: user.id }); + if (!organizer) throw new Error("demo organizer was not created"); + + await db.insert(membership).values({ + userId: organizer.id, + orgId: org.id, + role: "owner", + }); + + const [demoEvent] = await db + .insert(event) + .values({ + orgId: org.id, + slug: "demo", + name: "Demo", + phase: "judging_live", + }) + .returning({ id: event.id }); + if (!demoEvent) throw new Error("demo event was not created"); + + await db.insert(eventConfig).values({ + eventId: demoEvent.id, + targetSeconds: DEFAULT_TIMERS.targetSeconds, + hardLimitSeconds: DEFAULT_TIMERS.hardLimitSeconds, + walkLimitSeconds: DEFAULT_TIMERS.walkLimitSeconds, + submitGraceSeconds: DEFAULT_TIMERS.submitGraceSeconds, + minLooksPerProject: 1, + dispatchStrategy: "coverage", + pairwiseEnabled: false, + pairwiseWeight: 0, + bayesianC: 2, + calibrationLooks: 0, + calibrationWeight: 1, + feedbackCardsEnabled: false, + leaderboardPublic: false, + boardPollSeconds: 5, + }); + + const [mainRubric] = await db + .insert(rubric) + .values({ eventId: demoEvent.id, name: "Main", isDefault: true }) + .returning({ id: rubric.id }); + const [sponsorRubric] = await db + .insert(rubric) + .values({ eventId: demoEvent.id, name: "Sponsor", isDefault: false }) + .returning({ id: rubric.id }); + if (!mainRubric || !sponsorRubric) throw new Error("demo rubrics were not created"); + + await db.insert(criterion).values( + CRITERIA.map(([key, label], position) => ({ + rubricId: mainRubric.id, + position, + key, + label, + min: 0, + max: 10, + weight: 1, + })), + ); + await db.insert(criterion).values([ + { + rubricId: sponsorRubric.id, + position: 0, + key: "fit", + label: "Fit", + min: 0, + max: 10, + weight: 1, + }, + { + rubricId: sponsorRubric.id, + position: 1, + key: "execution", + label: "Execution", + min: 0, + max: 10, + weight: 1, + }, + ]); + + const tracks = await db + .insert(track) + .values([ + { + eventId: demoEvent.id, + slug: "general", + name: "General", + kind: "main" as const, + judgeGroup: "main", + rubricId: mainRubric.id, + position: 0, + }, + { + eventId: demoEvent.id, + slug: "sponsor", + name: "Sponsor", + kind: "sponsor" as const, + judgeGroup: "sponsor", + rubricId: sponsorRubric.id, + position: 1, + }, + { + eventId: demoEvent.id, + slug: "beginner", + name: "Beginner", + kind: "special" as const, + judgeGroup: "beginner", + rubricId: mainRubric.id, + position: 2, + }, + ]) + .returning({ id: track.id, slug: track.slug }); + + const general = tracks.find((item) => item.slug === "general"); + const sponsor = tracks.find((item) => item.slug === "sponsor"); + const beginner = tracks.find((item) => item.slug === "beginner"); + if (!general || !sponsor || !beginner) throw new Error("demo tracks were not created"); + + await db.insert(prize).values([ + { trackId: general.id, place: 1, title: "First", amount: 1000 }, + { trackId: sponsor.id, place: 1, title: "Sponsor prize", amount: 500 }, + ]); + + const [hall] = await db + .insert(zone) + .values({ eventId: demoEvent.id, name: "Hall", position: 0 }) + .returning({ id: zone.id }); + if (!hall) throw new Error("demo zone was not created"); + + await db.insert(floorTable).values( + Array.from({ length: 40 }, (_, index) => ({ + eventId: demoEvent.id, + number: index + 1, + zoneId: hall.id, + })), + ); + + const projects = await db + .insert(project) + .values( + Array.from({ length: 40 }, (_, index) => ({ + eventId: demoEvent.id, + name: `Project ${index + 1}`, + teamName: `Team ${index + 1}`, + tableNumber: index + 1, + zoneId: hall.id, + })), + ) + .returning({ id: project.id, tableNumber: project.tableNumber }); + + await db.insert(projectTrack).values( + projects.flatMap((item) => { + const links = [{ projectId: item.id, trackId: general.id }]; + const number = item.tableNumber ?? 0; + if (number % 5 === 0) links.push({ projectId: item.id, trackId: sponsor.id }); + if (number % 4 === 0) links.push({ projectId: item.id, trackId: beginner.id }); + return links; + }), + ); + + const sponsorTrack = tracks.find((item) => item.slug === "sponsor"); + await db.insert(judge).values( + Array.from({ length: 6 }, (_, index) => ({ + eventId: demoEvent.id, + name: `Judge ${index + 1}`, + email: `judge${index + 1}@demo.panel`, + status: "approved" as const, + trackId: index === 5 ? sponsorTrack?.id : null, + zoneId: hall.id, + })), + ); + + return { eventId: demoEvent.id }; +} diff --git a/judging/db/tsconfig.json b/judging/db/tsconfig.json new file mode 100644 index 00000000..f54bde2c --- /dev/null +++ b/judging/db/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tooling/typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/judging/docs/adr/001-standalone.md b/judging/docs/adr/001-standalone.md index 6c417fb3..dd621080 100644 --- a/judging/docs/adr/001-standalone.md +++ b/judging/docs/adr/001-standalone.md @@ -6,9 +6,10 @@ Accepted ## Decision -Panel lives under `judging/` with its own packages, its own Postgres database, -and its own deploy. It does not share a schema or a module graph with the -application that hosts the first event. +Panel lives under `judging/` with its own packages and its own deploy. It does +not share a module graph with the application that hosts the first event. +ADR-003 puts the tables in that application's Postgres instead of a second +database. Internal imports are `@panel/*` only. A lint rule rejects imports from the host. The host is allowed to import `@panel/*`. That one-way edge is what @@ -19,4 +20,5 @@ rewrite. Judging welded into one event's tables cannot be given to the next event, or to anyone else, without carrying the rest of that application with it. -A separate database means the extraction has no data to untangle. +The panel tables are prefixed so they can share that database until an +extraction. diff --git a/judging/docs/adr/003-one-database.md b/judging/docs/adr/003-one-database.md new file mode 100644 index 00000000..90a8dc72 --- /dev/null +++ b/judging/docs/adr/003-one-database.md @@ -0,0 +1,26 @@ +# ADR-003: One database, one server + +## Status + +Accepted. Supersedes the separate-database sentence in ADR-001. + +## Decision + +Panel tables live in the same Postgres as the club app. The connection is +`DATABASE_URL`. `PANEL_DATABASE_URL` is read only when that is unset. The +tables that would collide with the club schema are `panel_user`, +`panel_event`, and `panel_judge`. + +There is one API process. Redis is optional. When `REDIS_URL` is unset or +Redis fails, that process keeps delivering live updates in memory. + +`judging/` stays in this monorepo. Splitting it out, and deleting the legacy +judge UI, wait until an event has run on panel. Sign-in mail waits on +`PANEL_SMTP_URL`. Neither connection is made yet. + +## Why + +The host is the existing app. A second database and a second API fleet were +the extraction plan, and they are not how this checkout runs. The replay of +the published score fixture still matches `@panel/core` `rank()` at pairwise +weight 0, so the scoring check does not need that event. diff --git a/judging/docs/api.md b/judging/docs/api.md new file mode 100644 index 00000000..dd35ed3e --- /dev/null +++ b/judging/docs/api.md @@ -0,0 +1,17 @@ +# API + +`GET /openapi.json` is the route list (OpenAPI 3.0.3). The same procedures are also mounted at `/trpc`. + +Send `Authorization: Bearer` with a JWT from `POST /v1/auth/verify`, or with an API key from `catalog.issueApiKey`. A key whose scopes include `import`, `export`, or `webhooks` acts as an organizer. A revoked key is rejected. + +These routes do not require a token: + +- `GET /healthz`, `GET /readyz`, `GET /metrics`, `GET /openapi.json` +- `POST /v1/auth/magic-link` and `POST /v1/auth/verify` +- `GET /v1/public/{orgSlug}/{eventSlug}` +- `GET /v1/live/{orgSlug}/{eventSlug}` +- `GET /v1/feedback/{token}` + +`GET /v1/results/{eventId}` requires a bearer token and returns 403 until results are published. Feedback returns 403 for an unknown token and before publish. + +Live clients connect to `/ws?channel=event:{eventId}:board`. The channels are `event:{id}:board`, `event:{id}:leaderboard`, and `judge:{id}`. diff --git a/judging/docs/deploy.md b/judging/docs/deploy.md new file mode 100644 index 00000000..5b28feed --- /dev/null +++ b/judging/docs/deploy.md @@ -0,0 +1,25 @@ +# Deploy + +Docker Compose is the deploy to start from. From the repository root: + +``` +docker compose -f judging/infra/docker/docker-compose.yml up --build +``` + +Then, against the published Postgres port: + +``` +DATABASE_URL=postgres://panel:panel@localhost:54329/panel +PANEL_JWT_SECRET=dev-only-change-me +pnpm --filter @panel/cli panel migrate +pnpm --filter @panel/cli panel seed demo +pnpm --filter @panel/cli panel doctor +``` + +`panel doctor` must report postgres, migrations, and jwt as ok. Set `DATABASE_URL` to the same Postgres the club app uses. `PANEL_DATABASE_URL` is only read when `DATABASE_URL` is unset. The stack is one API server. Leave `REDIS_URL` unset so live updates stay in that process. Clients that miss a socket message recover from `GET /v1/live`. + +The optional GCP module is `judging/infra/gcp`. It is not required to run the stack. The web image is `judging/infra/docker/Dockerfile.web`. Pass `NEXT_PUBLIC_PANEL_URL` at build time so the desk calls the API. + +Set `PANEL_SMTP_URL` to an SMTP URL when sign-in codes should be emailed. +Without it, `PANEL_DEV_AUTH=1` returns the code in the response. OIDC waits +on a provider; trusted JWT from the club site is the handoff that works now. diff --git a/judging/docs/rubric.md b/judging/docs/rubric.md new file mode 100644 index 00000000..70b42cc3 --- /dev/null +++ b/judging/docs/rubric.md @@ -0,0 +1,7 @@ +# Rubric + +A rubric is a named list of criteria. Each criterion has a key, a label, a minimum, a maximum, and a weight. The default rubric is what judges see. A track can point at a different rubric, which is how a sponsor prize asks different questions. + +Scores outside a criterion's range are rejected. The visit total is the weighted mean of the criteria. Comments are stored with the vote and shown on the team card only after results are published. + +Create one from the organizer console with the track form for a new prize group, or through `catalog.saveRubric` with the criteria in order. Anchors are the numbers a judge can tap; they are the integers from min to max. diff --git a/judging/docs/scoring.md b/judging/docs/scoring.md new file mode 100644 index 00000000..65d46488 --- /dev/null +++ b/judging/docs/scoring.md @@ -0,0 +1,9 @@ +# Scoring + +A project's rubric score is the weighted mean of its criterion values. Each judge's scores are then z-scored against that judge's own mean and spread and placed on the field's mean and spread, so a harsh judge and a lenient judge who ranked the same projects agree. + +Projects with few votes are shrunk toward the field average. The config value `bayesian_c` is how many average votes that prior is worth. At 2, two real votes and the prior weigh the same. + +Pairwise comparisons are a separate component. `pairwise_weight` is the share of the final score that comes from them. At 0 the ranking is the rubric score only, which matches a results run that never asked judges to compare tables. + +Publishing freezes one result run. A later run can be diffed against it before it is published. diff --git a/judging/infra/docker/Dockerfile b/judging/infra/docker/Dockerfile new file mode 100644 index 00000000..e40b129d --- /dev/null +++ b/judging/infra/docker/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-bookworm-slim + +WORKDIR /app +RUN corepack enable + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY tooling ./tooling +COPY judging ./judging +COPY packages ./packages +COPY sites ./sites + +RUN pnpm install --frozen-lockfile --filter @panel/server... + +EXPOSE 8787 +CMD ["pnpm", "--filter", "@panel/server", "start"] diff --git a/judging/infra/docker/Dockerfile.web b/judging/infra/docker/Dockerfile.web new file mode 100644 index 00000000..4dee1b6f --- /dev/null +++ b/judging/infra/docker/Dockerfile.web @@ -0,0 +1,30 @@ +FROM node:22-bookworm-slim AS build + +WORKDIR /app +RUN corepack enable + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY tooling ./tooling +COPY judging ./judging +COPY packages ./packages +COPY sites ./sites + +ARG NEXT_PUBLIC_PANEL_URL=http://localhost:8787 +ENV NEXT_PUBLIC_PANEL_URL=$NEXT_PUBLIC_PANEL_URL + +RUN pnpm install --frozen-lockfile --filter @panel/web... +RUN pnpm --filter @panel/web build + +FROM node:22-bookworm-slim + +WORKDIR /app +ENV NODE_ENV=production +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 + +COPY --from=build /app/judging/web/.next/standalone ./ +COPY --from=build /app/judging/web/.next/static ./judging/web/.next/static +COPY --from=build /app/judging/web/public ./judging/web/public + +EXPOSE 3000 +CMD ["node", "judging/web/server.js"] diff --git a/judging/infra/docker/docker-compose.yml b/judging/infra/docker/docker-compose.yml new file mode 100644 index 00000000..4da40215 --- /dev/null +++ b/judging/infra/docker/docker-compose.yml @@ -0,0 +1,30 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: panel + POSTGRES_PASSWORD: panel + POSTGRES_DB: panel + ports: + - "54329:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U panel"] + interval: 2s + timeout: 2s + retries: 20 + + server: + build: + context: ../../.. + dockerfile: judging/infra/docker/Dockerfile + environment: + PANEL_DATABASE_URL: postgres://panel:panel@postgres:5432/panel + DATABASE_URL: postgres://panel:panel@postgres:5432/panel + PANEL_JWT_SECRET: dev-only-change-me + PANEL_DEV_AUTH: "1" + PORT: "8787" + ports: + - "8787:8787" + depends_on: + postgres: + condition: service_healthy diff --git a/judging/infra/gcp/main.tf b/judging/infra/gcp/main.tf new file mode 100644 index 00000000..ad1f1989 --- /dev/null +++ b/judging/infra/gcp/main.tf @@ -0,0 +1,129 @@ +variable "project" { + type = string +} + +variable "region" { + type = string + default = "us-east1" +} + +variable "server_image" { + type = string +} + +variable "web_image" { + type = string +} + +variable "database_url_secret" { + type = string + description = "Secret Manager secret id holding DATABASE_URL, the shared Postgres database." +} + +resource "google_cloud_run_v2_service" "server" { + name = "panel-server" + location = var.region + project = var.project + + template { + containers { + image = var.server_image + ports { + container_port = 8787 + } + env { + name = "DATABASE_URL" + value_source { + secret_key_ref { + secret = var.database_url_secret + version = "latest" + } + } + } + } + } +} + +resource "google_cloud_run_v2_service" "web" { + name = "panel-web" + location = var.region + project = var.project + + template { + containers { + image = var.web_image + env { + name = "NEXT_PUBLIC_PANEL_URL" + value = google_cloud_run_v2_service.server.uri + } + } + } +} + +variable "alert_email" { + type = string + default = "" +} + +locals { + server_host = trimprefix(google_cloud_run_v2_service.server.uri, "https://") +} + +resource "google_monitoring_uptime_check_config" "readyz" { + display_name = "panel readyz" + timeout = "10s" + period = "60s" + project = var.project + + http_check { + path = "/readyz" + port = "443" + use_ssl = true + validate_ssl = true + } + + monitored_resource { + type = "uptime_url" + labels = { + project_id = var.project + host = local.server_host + } + } +} + +resource "google_monitoring_notification_channel" "email" { + count = var.alert_email == "" ? 0 : 1 + display_name = "panel alerts" + type = "email" + project = var.project + labels = { + email_address = var.alert_email + } +} + +resource "google_monitoring_alert_policy" "readyz" { + count = var.alert_email == "" ? 0 : 1 + display_name = "panel /readyz" + combiner = "OR" + project = var.project + + conditions { + display_name = "readyz check failed" + condition_threshold { + filter = "metric.type=\"monitoring.googleapis.com/uptime_check/check_passed\" AND resource.type=\"uptime_url\" AND metric.label.check_id=\"${google_monitoring_uptime_check_config.readyz.uptime_check_id}\"" + duration = "120s" + comparison = "COMPARISON_LT" + threshold_value = 1 + } + } + + notification_channels = [google_monitoring_notification_channel.email[0].id] +} + +output "server_uri" { + value = google_cloud_run_v2_service.server.uri +} + +output "web_uri" { + value = google_cloud_run_v2_service.web.uri +} diff --git a/judging/server/eslint.config.mjs b/judging/server/eslint.config.mjs new file mode 100644 index 00000000..d466eefb --- /dev/null +++ b/judging/server/eslint.config.mjs @@ -0,0 +1,28 @@ +import { config } from "@query/eslint-config/base"; + +/** @type {import("eslint").Linter.Config[]} */ +export default [ + ...config, + { + files: ["src/log.ts", "src/index.ts"], + rules: { "no-console": "off" }, + }, + { + files: ["src/**/*.ts"], + ignores: ["src/env.ts", "src/**/*.test.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["@query", "@query/*"], + message: + "judging/ is a standalone product. Import from @panel/* only.", + }, + ], + }, + ], + }, + }, +]; diff --git a/judging/server/package.json b/judging/server/package.json new file mode 100644 index 00000000..d260728b --- /dev/null +++ b/judging/server/package.json @@ -0,0 +1,38 @@ +{ + "name": "@panel/server", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./outbox": "./src/services/outbox.ts" + }, + "scripts": { + "start": "tsx src/index.ts", + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc --noEmit", + "test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts" + }, + "dependencies": { + "@hono/node-server": "^1.19.0", + "@panel/core": "workspace:*", + "@panel/db": "workspace:*", + "@trpc/server": "11.19.0", + "drizzle-orm": "0.45.3", + "hono": "^4.9.0", + "ioredis": "^5.8.2", + "jose": "^6.1.0", + "nodemailer": "^10.0.10", + "prom-client": "15.1.3", + "tsx": "^4.23.15", + "ws": "8.20.1", + "zod": "4.6.5" + }, + "devDependencies": { + "@query/eslint-config": "workspace:*", + "@query/tsconfig": "workspace:*", + "@types/node": "22.20.4", + "@types/ws": "^8.18.1", + "typescript": "7.0.2", + "vitest": "^5.0.1" + } +} diff --git a/judging/server/src/app.test.ts b/judging/server/src/app.test.ts new file mode 100644 index 00000000..9d445519 --- /dev/null +++ b/judging/server/src/app.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "./app"; +import { createMetrics } from "./metrics"; + +describe("health", () => { + it("answers /healthz without a database", async () => { + const app = createApp({ + db: {} as never, + metrics: createMetrics(), + jwtSecret: "test", + devAuth: false, + busMode: "memory", + }); + const response = await app.request("/healthz"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + }); +}); diff --git a/judging/server/src/app.ts b/judging/server/src/app.ts new file mode 100644 index 00000000..cdd03e78 --- /dev/null +++ b/judging/server/src/app.ts @@ -0,0 +1,391 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { TRPCError } from "@trpc/server"; +import { and, eq, isNull, sql } from "drizzle-orm"; +import { z } from "zod"; +import type { PanelDb } from "@panel/db"; +import { event, loginCode, membership, organization, user, visit } from "@panel/db"; +import { hashCode, newLoginCode, roleAtLeast, signActor, verifyActor } from "./auth"; +import { sendLoginCode } from "./mail"; +import type { Role } from "./auth"; +import type { Bus } from "./bus"; +import { InMemoryBus } from "./bus"; +import { feedbackCard, feedbackTokenForExternal, actorForApiKey } from "./services/catalog"; +import { organizerFloor } from "./services/floor"; +import { liveSnapshot } from "./services/live"; +import { inspectRun, publishedPlacements } from "./services/results"; +import type { Metrics } from "./metrics"; +import { appRouter } from "./router"; +import type { Context } from "./router"; + +const openApi = { + openapi: "3.0.3", + info: { + title: "Panel", + version: "0.0.0", + description: + "REST mirror of the tRPC procedures. Send Authorization: Bearer with a JWT or an API key, except for the public routes.", + }, + paths: { + "/healthz": { get: { summary: "Process is up." } }, + "/readyz": { get: { summary: "Postgres answers. The bus is this process unless Redis is set." } }, + "/metrics": { get: { summary: "Prometheus metrics." } }, + "/openapi.json": { get: { summary: "This document." } }, + "/v1/auth/magic-link": { post: { summary: "Email a sign-in code. Dev auth returns the code." } }, + "/v1/auth/verify": { post: { summary: "Exchange a code for a JWT." } }, + "/v1/judges/apply": { post: { summary: "Apply to judge this event with the signed-in email." } }, + "/v1/public/{orgSlug}/{eventSlug}": { get: { summary: "Event name, phase, and branding." } }, + "/v1/session/next": { post: { summary: "Hand the signed-in judge their next table." } }, + "/v1/session/arrive": { post: { summary: "Mark arrival by table number or QR token." } }, + "/v1/session/vote": { post: { summary: "Store the score for the open visit." } }, + "/v1/session/skip": { post: { summary: "Pass on the open table without a score." } }, + "/v1/session/progress": { get: { summary: "How many projects this judge has scored." } }, + "/v1/session/compare": { post: { summary: "Record which of the last two tables was better." } }, + "/v1/session/rubric": { get: { summary: "Criteria for the event's default rubric." } }, + "/v1/catalog/projects": { get: { summary: "Projects for table cards. Organizer." } }, + "/v1/catalog/logs": { get: { summary: "Recent event log rows. Organizer." } }, + "/v1/catalog/tables": { get: { summary: "Floor tables and coordinates. Organizer." } }, + "/v1/catalog/judges": { get: { summary: "Judges and their status. Organizer." } }, + "/v1/live/{orgSlug}/{eventSlug}": { get: { summary: "Board snapshot: coverage, or placements after publish." } }, + "/v1/floor/{eventId}": { get: { summary: "Organizer floor: looks per table, and idle, walking, or overtime judges." } }, + "/v1/results/run/{runId}": { get: { summary: "One run, with rubric and pairwise components. Organizer." } }, + "/v1/results/{eventId}": { get: { summary: "Published placements. 403 before publish." } }, + "/v1/feedback/{token}": { get: { summary: "A team's card. 403 for an unknown token or before publish." } }, + }, +}; + +export function createApp(options: { + db: PanelDb; + metrics: Metrics; + jwtSecret: string; + jwksUrl?: string; + devAuth: boolean; + smtpUrl?: string; + busMode: "memory" | "redis"; + bus?: Bus; +}) { + const bus = options.bus ?? new InMemoryBus(); + const app = new Hono(); + app.use( + "*", + cors({ + origin: "*", + allowHeaders: ["Authorization", "Content-Type"], + allowMethods: ["GET", "POST", "OPTIONS"], + }), + ); + + app.get("/healthz", (c) => c.json({ ok: true })); + + app.get("/readyz", async (c) => { + try { + await options.db.execute(sql`select 1`); + return c.json({ ok: true, bus: options.busMode }); + } catch (error) { + const message = error instanceof Error ? error.message : "database"; + return c.json({ ok: false, message }, 503); + } + }); + + app.get("/metrics", async (c) => { + const [open] = await options.db + .select({ n: sql`count(distinct ${visit.judgeId})::int` }) + .from(visit) + .where(and(isNull(visit.completedAt), isNull(visit.voidedAt))); + options.metrics.liveJudges.set(open?.n ?? 0); + c.header("content-type", options.metrics.register.contentType); + return c.body(await options.metrics.register.metrics()); + }); + + app.get("/openapi.json", (c) => c.json(openApi)); + + app.post("/v1/auth/magic-link", async (c) => { + const body = z.object({ email: z.string().email() }).parse(await c.req.json()); + if (!options.devAuth && !options.smtpUrl) { + return c.json({ ok: false, message: "SMTP is not configured" }, 503); + } + const code = newLoginCode(); + const email = body.email.toLowerCase(); + await options.db.insert(loginCode).values({ + email, + codeHash: hashCode(code), + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + }); + if (options.smtpUrl) { + await sendLoginCode(options.smtpUrl, email, code); + return c.json({ ok: true }); + } + return c.json({ ok: true, code }); + }); + + app.post("/v1/auth/verify", async (c) => { + const body = z + .object({ + email: z.string().email(), + code: z.string().length(6), + org: z.string().optional(), + }) + .parse(await c.req.json()); + const email = body.email.toLowerCase(); + const [row] = await options.db + .select() + .from(loginCode) + .where( + and( + eq(loginCode.email, email), + eq(loginCode.codeHash, hashCode(body.code)), + isNull(loginCode.consumedAt), + ), + ); + if (!row || row.expiresAt.getTime() < Date.now()) { + return c.json({ ok: false, message: "Code is not valid" }, 401); + } + await options.db + .update(loginCode) + .set({ consumedAt: new Date() }) + .where(eq(loginCode.id, row.id)); + + const [person] = await options.db + .select() + .from(user) + .where(eq(user.email, email)); + let role: Role | null = null; + let orgSlug: string | null = body.org ?? null; + if (person) { + const rows = await options.db + .select({ role: membership.role, slug: organization.slug }) + .from(membership) + .innerJoin(organization, eq(organization.id, membership.orgId)) + .where(eq(membership.userId, person.id)); + const chosen = orgSlug + ? rows.find((item) => item.slug === orgSlug) + : rows.length === 1 + ? rows[0] + : undefined; + if (chosen) { + role = chosen.role; + orgSlug = chosen.slug; + } + } + + if (!options.jwtSecret) { + return c.json({ ok: false, message: "PANEL_JWT_SECRET is not set" }, 503); + } + const token = await signActor( + { + sub: person?.id ?? email, + email, + name: person?.name ?? email, + org: orgSlug, + role, + judgeExternalId: null, + }, + options.jwtSecret, + 60 * 60 * 12, + ); + return c.json({ ok: true, token }); + }); + + app.post("/v1/judges/apply", async (c) => { + const body = z + .object({ eventId: z.string().uuid(), name: z.string().min(1) }) + .parse(await c.req.json()); + return rest((caller) => caller.judge.applyToEvent(body))(c); + }); + + app.get("/v1/public/:orgSlug/:eventSlug", async (c) => { + const [row] = await options.db + .select({ + eventId: event.id, + name: event.name, + phase: event.phase, + branding: organization.branding, + orgName: organization.name, + }) + .from(event) + .innerJoin(organization, eq(organization.id, event.orgId)) + .where( + and( + eq(organization.slug, c.req.param("orgSlug")), + eq(event.slug, c.req.param("eventSlug")), + ), + ); + if (!row) return c.json({ ok: false }, 404); + return c.json(row); + }); + + const contextFor = async (c: { req: { header: (name: string) => string | undefined } }): Promise => { + const header = c.req.header("authorization"); + let actor: Context["actor"] = null; + if (header?.startsWith("Bearer ")) { + const token = header.slice("Bearer ".length); + try { + actor = await verifyActor(token, { + secret: options.jwtSecret, + jwksUrl: options.jwksUrl, + }); + } catch { + actor = await actorForApiKey(options.db, token); + } + } + return { db: options.db, actor, now: new Date(), bus, metrics: options.metrics }; + }; + + const rest = (run: (caller: ReturnType) => Promise) => { + return async (c: Parameters[0] & { json: (body: unknown, status?: number) => Response }) => { + try { + return c.json(await run(appRouter.createCaller(await contextFor(c)))); + } catch (error) { + const message = error instanceof TRPCError ? error.message : "Request failed"; + const status = error instanceof TRPCError && error.code === "UNAUTHORIZED" ? 401 : 400; + return c.json({ message }, status); + } + }; + }; + + app.post("/v1/session/next", async (c) => { + const body = z.object({ eventId: z.string().uuid() }).parse(await c.req.json()); + return rest((caller) => caller.session.next(body))(c); + }); + app.post("/v1/session/arrive", async (c) => { + const body = z + .object({ + eventId: z.string().uuid(), + qrToken: z.string().uuid().optional(), + tableNumber: z.number().int().optional(), + }) + .parse(await c.req.json()); + return rest((caller) => caller.session.arrive(body))(c); + }); + app.post("/v1/session/vote", async (c) => { + const body = z + .object({ + eventId: z.string().uuid(), + visitId: z.string().uuid(), + comment: z.string().nullable().optional(), + scores: z.array(z.object({ criterionId: z.string().uuid(), value: z.number() })), + }) + .parse(await c.req.json()); + return rest((caller) => caller.session.vote({ ...body, comment: body.comment ?? null }))(c); + }); + app.get("/v1/session/rubric", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.session.rubric({ eventId }))(c); + }); + app.post("/v1/session/compare", async (c) => { + const body = z + .object({ eventId: z.string().uuid(), outcome: z.enum(["a", "b", "tie"]) }) + .parse(await c.req.json()); + return rest((caller) => caller.session.compare(body))(c); + }); + app.post("/v1/session/skip", async (c) => { + const body = z + .object({ eventId: z.string().uuid(), visitId: z.string().uuid() }) + .parse(await c.req.json()); + return rest((caller) => caller.session.skip(body))(c); + }); + app.get("/v1/session/progress", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.session.progress({ eventId }))(c); + }); + + app.get("/v1/catalog/projects", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.catalog.projects({ eventId }))(c); + }); + app.get("/v1/catalog/logs", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.catalog.logs({ eventId }))(c); + }); + app.get("/v1/catalog/tables", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.catalog.tables({ eventId }))(c); + }); + app.get("/v1/catalog/judges", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + return rest((caller) => caller.catalog.judges({ eventId }))(c); + }); + + app.get("/v1/floor/:eventId", async (c) => { + const ctx = await contextFor(c); + if (!ctx.actor || !roleAtLeast(ctx.actor.role, "organizer")) { + return c.json({ message: "Unauthorized" }, 401); + } + const eventId = z.string().uuid().parse(c.req.param("eventId")); + const floor = await organizerFloor(options.db, eventId, ctx.now); + if (!floor) return c.json({ message: "Not found" }, 404); + return c.json(floor); + }); + + app.get("/v1/live/:orgSlug/:eventSlug", async (c) => { + const snapshot = await liveSnapshot( + options.db, + c.req.param("orgSlug"), + c.req.param("eventSlug"), + ); + if (!snapshot) return c.json({ ok: false }, 404); + return c.json(snapshot); + }); + + app.get("/v1/results/run/:runId", async (c) => { + const header = c.req.header("authorization"); + if (!header?.startsWith("Bearer ")) return c.json({ message: "Unauthorized" }, 401); + try { + await verifyActor(header.slice("Bearer ".length), { + secret: options.jwtSecret, + jwksUrl: options.jwksUrl, + }); + } catch { + return c.json({ message: "Unauthorized" }, 401); + } + const runId = z.string().uuid().parse(c.req.param("runId")); + return c.json(await inspectRun(options.db, runId)); + }); + + app.get("/v1/results/:eventId", async (c) => { + const header = c.req.header("authorization"); + if (!header?.startsWith("Bearer ")) return c.json({ message: "Unauthorized" }, 401); + try { + await verifyActor(header.slice("Bearer ".length), { + secret: options.jwtSecret, + jwksUrl: options.jwksUrl, + }); + } catch { + return c.json({ message: "Unauthorized" }, 401); + } + const eventId = z.string().uuid().parse(c.req.param("eventId")); + const placements = await publishedPlacements(options.db, eventId); + if (!placements) return c.json({ message: "Not found" }, 404); + if (!placements.published) return c.json({ message: "Forbidden" }, 403); + return c.json(placements); + }); + + app.get("/v1/feedback/external/:eventId/:externalId", async (c) => { + const ctx = await contextFor(c); + if (!ctx.actor || !roleAtLeast(ctx.actor.role, "organizer")) { + return c.json({ message: "Unauthorized" }, 401); + } + const eventId = z.string().uuid().parse(c.req.param("eventId")); + const token = await feedbackTokenForExternal(options.db, eventId, c.req.param("externalId")); + if (!token) return c.json({ message: "Forbidden" }, 403); + return c.json({ token }); + }); + + app.get("/v1/feedback/:token", async (c) => { + const card = await feedbackCard(options.db, c.req.param("token")); + if (card.status === "forbidden") return c.json({ message: "Forbidden" }, 403); + return c.json(card); + }); + + app.all("/trpc/*", (c) => + fetchRequestHandler({ + endpoint: "/trpc", + req: c.req.raw, + router: appRouter, + createContext: () => contextFor(c), + }), + ); + + return app; +} diff --git a/judging/server/src/auth.test.ts b/judging/server/src/auth.test.ts new file mode 100644 index 00000000..33babd47 --- /dev/null +++ b/judging/server/src/auth.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { hashApiKey, roleForScopes } from "./auth"; + +describe("api keys", () => { + it("hashes a token the same way every time", () => { + expect(hashApiKey("panel_live_key")).toBe(hashApiKey("panel_live_key")); + expect(hashApiKey("panel_live_key")).not.toBe(hashApiKey("panel_live_other")); + }); + + it("uses the strongest scope as the role", () => { + expect(roleForScopes(["volunteer", "organizer"])).toBe("organizer"); + expect(roleForScopes(["admin"])).toBe("admin"); + expect(roleForScopes(["import"])).toBe("organizer"); + expect(roleForScopes(["read"])).toBe(null); + }); +}); diff --git a/judging/server/src/auth.ts b/judging/server/src/auth.ts new file mode 100644 index 00000000..8a51e635 --- /dev/null +++ b/judging/server/src/auth.ts @@ -0,0 +1,100 @@ +import { createHash, randomInt } from "node:crypto"; +import { SignJWT, createRemoteJWKSet, jwtVerify } from "jose"; + +export const ROLES = ["volunteer", "organizer", "admin", "owner"] as const; +export type Role = (typeof ROLES)[number]; + +export type Actor = { + sub: string; + email: string; + name: string; + org: string | null; + role: Role | null; + judgeExternalId: string | null; +}; + +const ROLE_RANK: Record = { + volunteer: 0, + organizer: 1, + admin: 2, + owner: 3, +}; + +export function roleAtLeast(actual: Role | null, minimum: Role): boolean { + if (!actual) return false; + return ROLE_RANK[actual] >= ROLE_RANK[minimum]; +} + +export function hashCode(code: string): string { + return createHash("sha256").update(code).digest("hex"); +} + +export function newLoginCode(): string { + return String(randomInt(0, 1_000_000)).padStart(6, "0"); +} + +export async function signActor( + actor: Actor, + secret: string, + ttlSeconds: number, +): Promise { + return new SignJWT({ + email: actor.email, + name: actor.name, + org: actor.org, + role: actor.role, + judge_external_id: actor.judgeExternalId, + }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(actor.sub) + .setIssuedAt() + .setExpirationTime(`${ttlSeconds}s`) + .sign(new TextEncoder().encode(secret)); +} + +export async function verifyActor( + token: string, + options: { secret?: string; jwksUrl?: string }, +): Promise { + const verified = options.jwksUrl + ? await jwtVerify(token, createRemoteJWKSet(new URL(options.jwksUrl))) + : await jwtVerify(token, new TextEncoder().encode(options.secret ?? "")); + const payload = verified.payload; + const email = payload.email; + const name = payload.name; + if (typeof email !== "string" || typeof payload.sub !== "string") { + throw new Error("Token is missing sub or email"); + } + const role = ROLES.find((item) => item === payload.role) ?? null; + return { + sub: payload.sub, + email, + name: typeof name === "string" ? name : email, + org: typeof payload.org === "string" ? payload.org : null, + role, + judgeExternalId: + typeof payload.judge_external_id === "string" + ? payload.judge_external_id + : null, + }; +} + +export function hashApiKey(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +/** The strongest role named in the key's scopes. Import and export are organizer work. */ +export function roleForScopes(scopes: readonly string[]): Role | null { + if (scopes.includes("owner")) return "owner"; + if (scopes.includes("admin")) return "admin"; + if ( + scopes.includes("organizer") || + scopes.includes("import") || + scopes.includes("export") || + scopes.includes("webhooks") + ) { + return "organizer"; + } + if (scopes.includes("volunteer")) return "volunteer"; + return null; +} diff --git a/judging/server/src/bus.test.ts b/judging/server/src/bus.test.ts new file mode 100644 index 00000000..b35b71f4 --- /dev/null +++ b/judging/server/src/bus.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { FallbackBus, InMemoryBus } from "./bus"; +import type { Bus } from "./bus"; + +describe("InMemoryBus", () => { + it("delivers a message only to subscribers of that channel", () => { + const bus = new InMemoryBus(); + const seen: unknown[] = []; + const stop = bus.subscribe("event:1:board", (message) => seen.push(message)); + bus.publish("event:1:board", { kind: "vote.cast" }); + bus.publish("event:2:board", { kind: "other" }); + stop(); + bus.publish("event:1:board", { kind: "again" }); + expect(seen).toEqual([{ kind: "vote.cast" }]); + }); +}); + +class DownableBus implements Bus { + readonly seen: unknown[] = []; + private readonly down: (() => void)[] = []; + + publish(_channel: string, message: unknown) { + this.seen.push(message); + } + + subscribe() { + return () => undefined; + } + + onDown(listener: () => void) { + this.down.push(listener); + } + + stop() { + for (const listener of this.down) listener(); + } +} + +describe("FallbackBus", () => { + it("keeps delivering on this process after Redis reports down", () => { + const redis = new DownableBus(); + const bus = new FallbackBus(redis); + const seen: unknown[] = []; + bus.subscribe("event:1:board", (message) => seen.push(message)); + bus.publish("event:1:board", { kind: "before" }); + redis.stop(); + bus.publish("event:1:board", { kind: "after" }); + expect(redis.seen).toEqual([{ kind: "before" }]); + expect(seen).toEqual([{ kind: "after" }]); + }); +}); diff --git a/judging/server/src/bus.ts b/judging/server/src/bus.ts new file mode 100644 index 00000000..1cc6fe2b --- /dev/null +++ b/judging/server/src/bus.ts @@ -0,0 +1,131 @@ +import { Redis } from "ioredis"; +import { log } from "./log"; + +type Listener = (message: unknown) => void; + +export type Bus = { + publish(channel: string, message: unknown): void; + subscribe(channel: string, listener: Listener): () => void; + onDown(listener: () => void): void; +}; + +/** In-process fan-out. Used when REDIS_URL is unset. */ +export class InMemoryBus implements Bus { + private readonly channels = new Map>(); + + publish(channel: string, message: unknown) { + for (const listener of this.channels.get(channel) ?? []) listener(message); + } + + subscribe(channel: string, listener: Listener) { + const listeners = this.channels.get(channel) ?? new Set(); + listeners.add(listener); + this.channels.set(channel, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) this.channels.delete(channel); + }; + } + + onDown() { + // This process is the bus. + } +} + +export function createRedisBus(url: string): Bus { + const options = { maxRetriesPerRequest: null }; + const pub = new Redis(url, options); + const sub = new Redis(url, options); + const listeners = new Map>(); + const downListeners = new Set<() => void>(); + let down = false; + const fail = () => { + if (down) return; + down = true; + for (const listener of downListeners) listener(); + }; + pub.on("error", fail); + sub.on("error", fail); + sub.on("message", (channel: string, raw: string) => { + const message = JSON.parse(raw) as unknown; + for (const listener of listeners.get(channel) ?? []) listener(message); + }); + return { + publish(channel, message) { + void pub.publish(channel, JSON.stringify(message)); + }, + subscribe(channel, listener) { + const set = listeners.get(channel) ?? new Set(); + const first = set.size === 0; + set.add(listener); + listeners.set(channel, set); + if (first) void sub.subscribe(channel); + return () => { + set.delete(listener); + if (set.size === 0) void sub.unsubscribe(channel); + }; + }, + onDown(listener) { + downListeners.add(listener); + }, + }; +} + +/** Redis when REDIS_URL is set, otherwise the in-process bus. */ +export function createBus(redisUrl: string | undefined): Bus { + const url = redisUrl?.trim(); + if (!url) return new InMemoryBus(); + return new FallbackBus(createRedisBus(url)); +} + +/** + * Uses Redis until the connection errors, then this process fans out in memory. + * Clients still reload state from the live snapshot, so a second server does + * not need Redis for correctness. + */ +export class FallbackBus implements Bus { + private readonly memory = new InMemoryBus(); + private redis: Bus | null; + private failed = false; + private readonly subs: { channel: string; listener: Listener; unsubscribe: () => void }[] = []; + + constructor(redis: Bus) { + this.redis = redis; + redis.onDown(() => this.fail()); + } + + private fail() { + if (this.failed) return; + this.failed = true; + for (const sub of this.subs) { + sub.unsubscribe(); + sub.unsubscribe = this.memory.subscribe(sub.channel, sub.listener); + } + this.redis = null; + log({ + level: "warn", + message: "Redis is unavailable. This process is using the in-process bus.", + }); + } + + publish(channel: string, message: unknown) { + const target = this.failed ? this.memory : this.redis; + target?.publish(channel, message); + } + + subscribe(channel: string, listener: Listener) { + const target = this.failed ? this.memory : this.redis; + const unsubscribe = target?.subscribe(channel, listener) ?? (() => undefined); + const sub = { channel, listener, unsubscribe }; + this.subs.push(sub); + return () => { + sub.unsubscribe(); + const index = this.subs.indexOf(sub); + if (index >= 0) this.subs.splice(index, 1); + }; + } + + onDown() { + // Callers keep their sockets. This process still delivers messages. + } +} diff --git a/judging/server/src/cross-server.check.ts b/judging/server/src/cross-server.check.ts new file mode 100644 index 00000000..85c4120c --- /dev/null +++ b/judging/server/src/cross-server.check.ts @@ -0,0 +1,111 @@ +import { WebSocket } from "ws"; +import { eq } from "drizzle-orm"; +import { createDb, event, judge, organization } from "@panel/db"; +import { signActor } from "./auth"; +import { log } from "./log"; + +const databaseUrl = process.env.DATABASE_URL ?? process.env.PANEL_DATABASE_URL; +const secret = process.env.PANEL_JWT_SECRET; +const portA = process.env.PORT_A ?? "8787"; +const portB = process.env.PORT_B ?? portA; +if (!databaseUrl || !secret) { + throw new Error("DATABASE_URL and PANEL_JWT_SECRET are required"); +} + +const { db, pool } = createDb(databaseUrl); +const [demo] = await db + .select({ eventId: event.id, org: organization.slug }) + .from(event) + .innerJoin(organization, eq(organization.id, event.orgId)) + .where(eq(organization.slug, "demo")); +if (!demo) throw new Error("demo event missing"); + +const judges = await db.select().from(judge).where(eq(judge.eventId, demo.eventId)); +await pool.end(); + +const baseA = `http://127.0.0.1:${portA}`; +const socket = new WebSocket( + `ws://127.0.0.1:${portB}/ws?channel=event:${demo.eventId}:board`, +); +await new Promise((resolve, reject) => { + socket.once("open", () => resolve()); + socket.once("error", reject); +}); + +let accept = false; +const heard = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("no vote on the other server within 1s")), 15000); + socket.on("message", (data) => { + const text = String(data); + if (!accept || !text.includes("vote.cast")) return; + clearTimeout(timer); + resolve(text); + }); +}); + +let voted = false; +for (const row of judges) { + const token = await signActor( + { + sub: row.id, + email: row.email, + name: row.name, + org: demo.org, + role: null, + judgeExternalId: null, + }, + secret, + 600, + ); + const headers = { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }; + const next = await fetch(`${baseA}/v1/session/next`, { + method: "POST", + headers, + body: JSON.stringify({ eventId: demo.eventId }), + }); + const visit = (await next.json()) as { + done?: boolean; + visitId?: string; + tableNumber?: number | null; + message?: string; + }; + if (!next.ok || visit.done || !visit.visitId) continue; + await fetch(`${baseA}/v1/session/arrive`, { + method: "POST", + headers, + body: JSON.stringify({ eventId: demo.eventId, tableNumber: visit.tableNumber }), + }); + const rubricResponse = await fetch( + `${baseA}/v1/session/rubric?eventId=${demo.eventId}`, + { headers }, + ); + const rubric = (await rubricResponse.json()) as { id: string; min: number }[]; + if (!Array.isArray(rubric) || rubric.length === 0) continue; + accept = true; + const vote = await fetch(`${baseA}/v1/session/vote`, { + method: "POST", + headers, + body: JSON.stringify({ + eventId: demo.eventId, + visitId: visit.visitId, + comment: null, + scores: rubric.map((item) => ({ criterionId: item.id, value: item.min })), + }), + }); + if (!vote.ok) continue; + voted = true; + break; +} + +if (!voted) throw new Error("no judge could cast a vote"); +const message = await Promise.race([ + heard, + new Promise((_, reject) => { + setTimeout(() => reject(new Error("no vote on the other server within 1s")), 1000); + }), +]); +log({ message }); +socket.close(); diff --git a/judging/server/src/env.ts b/judging/server/src/env.ts new file mode 100644 index 00000000..4240ad98 --- /dev/null +++ b/judging/server/src/env.ts @@ -0,0 +1,11 @@ +export function serverEnv() { + return { + databaseUrl: process.env.DATABASE_URL ?? process.env.PANEL_DATABASE_URL ?? "", + jwtSecret: process.env.PANEL_JWT_SECRET ?? "", + jwksUrl: process.env.PANEL_JWKS_URL, + devAuth: process.env.PANEL_DEV_AUTH === "1", + smtpUrl: process.env.PANEL_SMTP_URL, + port: Number(process.env.PORT ?? 8787), + redisUrl: process.env.REDIS_URL, + }; +} diff --git a/judging/server/src/hub.ts b/judging/server/src/hub.ts new file mode 100644 index 00000000..3c97c38a --- /dev/null +++ b/judging/server/src/hub.ts @@ -0,0 +1,31 @@ +import type { Server } from "node:http"; +import { WebSocketServer } from "ws"; +import type { Bus } from "./bus"; +import type { Metrics } from "./metrics"; + +/** Clients subscribe with `?channel=event:{id}:board`. The bus is the only fan-out. */ +export function attachHub(server: Server, bus: Bus, metrics: Metrics) { + const sockets = new WebSocketServer({ server, path: "/ws" }); + sockets.on("connection", (socket, request) => { + const channel = new URL(request.url ?? "/", "http://localhost").searchParams.get( + "channel", + ); + metrics.wsConnections.inc(); + if (!channel) { + socket.close(); + metrics.wsConnections.dec(); + return; + } + const unsubscribe = bus.subscribe(channel, (message) => { + if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message)); + }); + socket.on("close", () => { + unsubscribe(); + metrics.wsConnections.dec(); + }); + }); + bus.onDown(() => { + for (const client of sockets.clients) client.close(); + }); + return sockets; +} diff --git a/judging/server/src/index.ts b/judging/server/src/index.ts new file mode 100644 index 00000000..b5ad4d8b --- /dev/null +++ b/judging/server/src/index.ts @@ -0,0 +1,73 @@ +import { serve } from "@hono/node-server"; +import type { Server } from "node:http"; +import { sql } from "drizzle-orm"; +import { boardChannel, judgeChannel } from "@panel/core"; +import { createDb } from "@panel/db"; +import { createBus } from "./bus"; +import { attachHub } from "./hub"; +import { createApp } from "./app"; +import { serverEnv } from "./env"; +import { log } from "./log"; +import { createMetrics } from "./metrics"; +import { drainOutbox } from "./services/outbox"; +import { loadOpenVisits, visitsOverTarget } from "./services/overtime"; + +const env = serverEnv(); +if (!env.databaseUrl) { + log({ level: "error", message: "DATABASE_URL is not set" }); + process.exit(1); +} + +const { db } = createDb(env.databaseUrl); +const metrics = createMetrics(); +const bus = createBus(env.redisUrl); +const app = createApp({ + db, + metrics, + jwtSecret: env.jwtSecret, + jwksUrl: env.jwksUrl, + devAuth: env.devAuth, + smtpUrl: env.smtpUrl, + busMode: env.redisUrl ? "redis" : "memory", + bus, +}); + +const notedOvertime = new Set(); +const timer = setInterval(() => { + drainOutbox(db) + .then(async () => { + const pending = await db.execute<{ count: string }>( + sql`select count(*)::text as count from outbox where delivered_at is null`, + ); + const count = Number(pending.rows[0]?.count ?? 0); + metrics.outboxPending.set(Number.isFinite(count) ? count : 0); + }) + .catch((error: unknown) => { + log({ + level: "error", + message: error instanceof Error ? error.message : "outbox", + }); + }); + loadOpenVisits(db) + .then((rows) => { + for (const row of visitsOverTarget(rows, new Date())) { + if (notedOvertime.has(row.visitId)) continue; + notedOvertime.add(row.visitId); + const message = { kind: "judge.overtime", eventId: row.eventId, visitId: row.visitId }; + bus.publish(judgeChannel(row.judgeId), message); + bus.publish(boardChannel(row.eventId), message); + } + }) + .catch((error: unknown) => { + log({ + level: "error", + message: error instanceof Error ? error.message : "overtime", + }); + }); +}, 2000); +timer.unref(); + +const server = serve({ fetch: app.fetch, port: env.port }, () => { + log({ level: "info", message: "listening", port: env.port }); +}); +attachHub(server as Server, bus, metrics); diff --git a/judging/server/src/log.ts b/judging/server/src/log.ts new file mode 100644 index 00000000..7f80e178 --- /dev/null +++ b/judging/server/src/log.ts @@ -0,0 +1,3 @@ +export function log(fields: Record) { + console.log(JSON.stringify({ time: new Date().toISOString(), ...fields })); +} diff --git a/judging/server/src/mail.ts b/judging/server/src/mail.ts new file mode 100644 index 00000000..89d84954 --- /dev/null +++ b/judging/server/src/mail.ts @@ -0,0 +1,12 @@ +import nodemailer from "nodemailer"; + +/** Sends the sign-in code when PANEL_SMTP_URL is set. */ +export async function sendLoginCode(smtpUrl: string, to: string, code: string) { + const transport = nodemailer.createTransport(smtpUrl); + await transport.sendMail({ + from: process.env.PANEL_SMTP_FROM ?? "panel@localhost", + to, + subject: "Your panel sign-in code", + text: `Your sign-in code is ${code}. It expires in 10 minutes.`, + }); +} diff --git a/judging/server/src/metrics.ts b/judging/server/src/metrics.ts new file mode 100644 index 00000000..5089489a --- /dev/null +++ b/judging/server/src/metrics.ts @@ -0,0 +1,40 @@ +import { Counter, Gauge, Histogram, Registry } from "prom-client"; + +export function createMetrics() { + const register = new Registry(); + const dispatchSeconds = new Histogram({ + name: "panel_dispatch_seconds", + help: "Time to choose and record the next table", + registers: [register], + }); + const votes = new Counter({ + name: "panel_votes_total", + help: "Votes stored", + registers: [register], + }); + const outboxPending = new Gauge({ + name: "panel_outbox_pending", + help: "Outbox rows not yet delivered", + registers: [register], + }); + const wsConnections = new Gauge({ + name: "panel_ws_connections", + help: "Open websocket clients", + registers: [register], + }); + const liveJudges = new Gauge({ + name: "panel_live_judges", + help: "Judges with a visit that is still open", + registers: [register], + }); + const coverage = new Histogram({ + name: "panel_project_looks", + help: "How many scores a project had when the latest one landed", + buckets: [1, 2, 3, 4, 5, 8, 13], + labelNames: ["event_id"], + registers: [register], + }); + return { register, dispatchSeconds, votes, outboxPending, wsConnections, liveJudges, coverage }; +} + +export type Metrics = ReturnType; diff --git a/judging/server/src/router.ts b/judging/server/src/router.ts new file mode 100644 index 00000000..8e7806bc --- /dev/null +++ b/judging/server/src/router.ts @@ -0,0 +1,652 @@ +import { initTRPC, TRPCError } from "@trpc/server"; +import { and, eq, sql } from "drizzle-orm"; +import { z } from "zod"; +import { assertTransition, boardChannel, judgeChannel, leaderboardChannel } from "@panel/core"; +import type { PanelDb } from "@panel/db"; +import { event, eventLog, judge, criterion, rubric, visit, vote } from "@panel/db"; +import type { Actor, Role } from "./auth"; +import { roleAtLeast } from "./auth"; +import type { Bus } from "./bus"; +import type { Metrics } from "./metrics"; +import { log } from "./log"; +import { arrive } from "./services/arrive"; +import { dispatchNext, judgeProgress, skipVisit } from "./services/dispatch"; +import { + assignJudge, + applyAsJudge, + recallJudge, + setJudgeStatus, + upsertJudge, + upsertProject, + voidVisit, + withdrawProject, + rotateQr, + assignEventTables, +} from "./services/records"; +import { + computeResults, + diffPlacements, + inspectRun, + placementsForRun, + publishedPlacements, + publishResults, + unpublishResults, +} from "./services/results"; +import { castComparison } from "./services/compare"; +import { + issueApiKey, + listProjects, + listLogs, + listJudges, + listTables, + listTracks, + placeTable, + savePrize, + saveRubric, + saveTrack, + saveWebhook, + saveZone, + saveConfig, +} from "./services/catalog"; +import { castVote } from "./services/vote"; + +export type Context = { + db: PanelDb; + actor: Actor | null; + now: Date; + bus: Bus; + metrics: Metrics; +}; + +function tell( + ctx: Context, + eventId: string, + judgeId: string | null, + kind: string, + visitId?: string, +) { + log({ + level: "info", + message: kind, + event_id: eventId, + judge_id: judgeId, + visit_id: visitId ?? null, + }); + const message = { kind, eventId }; + ctx.bus.publish(boardChannel(eventId), message); + ctx.bus.publish(leaderboardChannel(eventId), message); + if (judgeId) ctx.bus.publish(judgeChannel(judgeId), message); +} + +const t = initTRPC.context().create(); + +function asTrpc(error: unknown): never { + const message = error instanceof Error ? error.message : "Request failed"; + const code = message.includes("not allowed") + ? "PRECONDITION_FAILED" + : message.includes("not found") || message.includes("not approved") + ? "NOT_FOUND" + : message.includes("hard limit") + ? "CONFLICT" + : "BAD_REQUEST"; + throw new TRPCError({ code, message }); +} + +const authed = t.procedure.use(({ ctx, next }) => { + if (!ctx.actor) throw new TRPCError({ code: "UNAUTHORIZED" }); + return next({ ctx: { ...ctx, actor: ctx.actor } }); +}); + +function atLeast(role: Role) { + return authed.use(({ ctx, next }) => { + if (!roleAtLeast(ctx.actor.role, role)) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + return next(); + }); +} + +async function requireJudge(ctx: Context, eventId: string) { + if (!ctx.actor) throw new TRPCError({ code: "UNAUTHORIZED" }); + const rows = await ctx.db + .select() + .from(judge) + .where(eq(judge.eventId, eventId)); + const match = rows.find( + (row) => + (ctx.actor?.judgeExternalId && + row.externalId === ctx.actor.judgeExternalId) || + row.email.toLowerCase() === ctx.actor?.email.toLowerCase(), + ); + if (!match || match.status !== "approved") { + throw new TRPCError({ code: "FORBIDDEN", message: "This judge is not approved" }); + } + return match; +} + +const uuid = z.string().uuid(); + +export const appRouter = t.router({ + session: t.router({ + next: authed + .input(z.object({ eventId: uuid })) + .mutation(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + const stop = ctx.metrics.dispatchSeconds.startTimer(); + try { + const outcome = await dispatchNext(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + now: ctx.now, + }); + tell( + ctx, + input.eventId, + row.id, + "visit.handed_out", + outcome.done ? undefined : outcome.visitId, + ); + return outcome; + } catch (error) { + asTrpc(error); + } finally { + stop(); + } + }), + arrive: authed + .input( + z.object({ + eventId: uuid, + qrToken: uuid.optional(), + tableNumber: z.number().int().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + const outcome = await arrive(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + qrToken: input.qrToken, + tableNumber: input.tableNumber, + now: ctx.now, + }); + tell(ctx, input.eventId, row.id, "visit.arrived", outcome.visitId); + return outcome; + } catch (error) { + asTrpc(error); + } + }), + vote: authed + .input( + z.object({ + eventId: uuid, + visitId: uuid, + comment: z.string().nullable().default(null), + scores: z.array( + z.object({ criterionId: uuid, value: z.number() }), + ), + }), + ) + .mutation(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + const outcome = await castVote(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + visitId: input.visitId, + scores: input.scores, + comment: input.comment, + now: ctx.now, + }); + tell(ctx, input.eventId, row.id, "vote.cast", input.visitId); + ctx.metrics.votes.inc(); + const [current] = await ctx.db + .select({ projectId: visit.projectId }) + .from(visit) + .where(eq(visit.id, input.visitId)); + if (current) { + const [looks] = await ctx.db + .select({ n: sql`count(*)::int` }) + .from(vote) + .where( + and(eq(vote.eventId, input.eventId), eq(vote.projectId, current.projectId)), + ); + ctx.metrics.coverage.observe({ event_id: input.eventId }, looks?.n ?? 1); + } + return outcome; + } catch (error) { + asTrpc(error); + } + }), + rubric: authed + .input(z.object({ eventId: uuid })) + .query(async ({ ctx, input }) => { + await requireJudge(ctx, input.eventId); + const [rub] = await ctx.db + .select({ id: rubric.id }) + .from(rubric) + .where(and(eq(rubric.eventId, input.eventId), eq(rubric.isDefault, true))); + if (!rub) return []; + return ctx.db + .select({ + id: criterion.id, + label: criterion.label, + min: criterion.min, + max: criterion.max, + anchors: criterion.anchors, + }) + .from(criterion) + .where(eq(criterion.rubricId, rub.id)); + }), + compare: authed + .input(z.object({ eventId: uuid, outcome: z.enum(["a", "b", "tie"]) })) + .mutation(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + const outcome = await castComparison(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + outcome: input.outcome, + now: ctx.now, + }); + tell(ctx, input.eventId, row.id, "comparison.cast"); + return outcome; + } catch (error) { + asTrpc(error); + } + }), + skip: authed + .input(z.object({ eventId: uuid, visitId: uuid })) + .mutation(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + return await skipVisit(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + visitId: input.visitId, + now: ctx.now, + }); + } catch (error) { + asTrpc(error); + } + }), + progress: authed + .input(z.object({ eventId: uuid })) + .query(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + return await judgeProgress(ctx.db, { eventId: input.eventId, judgeId: row.id }); + } catch (error) { + asTrpc(error); + } + }), + }), + event: t.router({ + setPhase: atLeast("organizer") + .input(z.object({ eventId: uuid, phase: z.enum([ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", + ]) })) + .mutation(async ({ ctx, input }) => { + const [current] = await ctx.db + .select({ phase: event.phase, id: event.id }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!current) throw new TRPCError({ code: "NOT_FOUND" }); + try { + assertTransition(current.phase, input.phase); + } catch (error) { + asTrpc(error); + } + await ctx.db.transaction(async (tx) => { + await tx + .update(event) + .set({ phase: input.phase }) + .where(eq(event.id, input.eventId)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "phase.changed", + actor: { email: ctx.actor.email }, + subject: { eventId: input.eventId }, + payload: { from: current.phase, to: input.phase }, + }); + }); + tell(ctx, input.eventId, null, "phase.changed"); + return { phase: input.phase }; + }), + }), + project: t.router({ + upsert: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + externalId: z.string().min(1), + name: z.string().min(1), + teamName: z.string().nullable().default(null), + tableNumber: z.number().int().nullable().default(null), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await upsertProject(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + withdraw: atLeast("organizer") + .input(z.object({ eventId: uuid, projectId: uuid })) + .mutation(async ({ ctx, input }) => { + try { + await withdrawProject(ctx.db, { ...input, now: ctx.now, actorEmail: ctx.actor.email }); + return { ok: true }; + } catch (error) { + asTrpc(error); + } + }), + rotateQr: atLeast("organizer") + .input(z.object({ eventId: uuid, projectId: uuid })) + .mutation(async ({ ctx, input }) => { + try { + return await rotateQr(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + assignTables: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .mutation(async ({ ctx, input }) => { + return assignEventTables(ctx.db, { + eventId: input.eventId, + actorEmail: ctx.actor.email, + }); + }), + }), + judge: t.router({ + applyToEvent: authed + .input(z.object({ eventId: uuid, name: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + try { + return await applyAsJudge(ctx.db, { + eventId: input.eventId, + email: ctx.actor.email, + name: input.name, + }); + } catch (error) { + asTrpc(error); + } + }), + upsert: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + email: z.string().email(), + name: z.string().min(1), + externalId: z.string().nullable().default(null), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await upsertJudge(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + assign: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + judgeId: uuid, + trackId: uuid.nullable(), + zoneId: uuid.nullable(), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await assignJudge(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + setStatus: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + judgeId: uuid, + status: z.enum(["approved", "suspended"]), + }), + ) + .mutation(async ({ ctx, input }) => { + await setJudgeStatus(ctx.db, { ...input, actorEmail: ctx.actor.email }); + return { ok: true }; + }), + recall: atLeast("organizer") + .input(z.object({ eventId: uuid, judgeId: uuid })) + .mutation(async ({ ctx, input }) => { + await recallJudge(ctx.db, { ...input, actorEmail: ctx.actor.email }); + tell(ctx, input.eventId, input.judgeId, "judge.recalled"); + return { ok: true }; + }), + voidVisit: atLeast("organizer") + .input(z.object({ eventId: uuid, visitId: uuid, reason: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + try { + const voided = await voidVisit(ctx.db, { ...input, now: ctx.now, actorEmail: ctx.actor.email }); + tell(ctx, input.eventId, voided.judgeId, "visit.voided", input.visitId); + return { ok: true }; + } catch (error) { + asTrpc(error); + } + }), + }), + results: t.router({ + compute: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .mutation(async ({ ctx, input }) => { + try { + return await computeResults(ctx.db, { + eventId: input.eventId, + actorEmail: ctx.actor.email, + now: ctx.now, + }); + } catch (error) { + asTrpc(error); + } + }), + publish: atLeast("organizer") + .input(z.object({ eventId: uuid, runId: uuid })) + .mutation(async ({ ctx, input }) => { + try { + const outcome = await publishResults(ctx.db, { + ...input, + actorEmail: ctx.actor.email, + now: ctx.now, + }); + tell(ctx, input.eventId, null, "results.published"); + return outcome; + } catch (error) { + asTrpc(error); + } + }), + inspect: atLeast("organizer") + .input(z.object({ runId: uuid })) + .query(({ ctx, input }) => inspectRun(ctx.db, input.runId)), + diff: atLeast("organizer") + .input(z.object({ runA: uuid, runB: uuid })) + .query(async ({ ctx, input }) => { + const [left, right] = await Promise.all([ + placementsForRun(ctx.db, input.runA), + placementsForRun(ctx.db, input.runB), + ]); + return diffPlacements(left, right); + }), + unpublish: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .mutation(async ({ ctx, input }) => { + try { + const outcome = await unpublishResults(ctx.db, { + eventId: input.eventId, + actorEmail: ctx.actor.email, + }); + tell(ctx, input.eventId, null, "results.unpublished"); + return outcome; + } catch (error) { + asTrpc(error); + } + }), + export: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => publishedPlacements(ctx.db, input.eventId)), + }), + catalog: t.router({ + tracks: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => listTracks(ctx.db, input.eventId)), + projects: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => listProjects(ctx.db, input.eventId)), + logs: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => listLogs(ctx.db, input.eventId)), + tables: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => listTables(ctx.db, input.eventId)), + judges: atLeast("organizer") + .input(z.object({ eventId: uuid })) + .query(({ ctx, input }) => listJudges(ctx.db, input.eventId)), + saveTrack: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + slug: z.string().min(1), + name: z.string().min(1), + kind: z.enum(["main", "sponsor", "special"]), + judgeGroup: z.string().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await saveTrack(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + savePrize: atLeast("organizer") + .input( + z.object({ + trackId: uuid, + place: z.number().int(), + title: z.string().min(1), + amount: z.number().nullable().default(null), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await savePrize(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + saveZone: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + name: z.string().min(1), + position: z.number().int().default(0), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await saveZone(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + placeTable: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + number: z.number().int(), + zoneId: uuid.nullable().default(null), + x: z.number().nullable().default(null), + y: z.number().nullable().default(null), + }), + ) + .mutation(async ({ ctx, input }) => { + await placeTable(ctx.db, { ...input, actorEmail: ctx.actor.email }); + return { ok: true }; + }), + saveRubric: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + name: z.string().min(1), + isDefault: z.boolean().default(false), + criteria: z.array( + z.object({ + key: z.string().min(1), + label: z.string().min(1), + min: z.number(), + max: z.number(), + weight: z.number(), + }), + ), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await saveRubric(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + saveWebhook: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + url: z.string().url(), + secret: z.string().min(8), + topics: z.array(z.string()).min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await saveWebhook(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + issueApiKey: atLeast("admin") + .input(z.object({ eventId: uuid, scopes: z.array(z.string()).min(1) })) + .mutation(async ({ ctx, input }) => { + try { + return await issueApiKey(ctx.db, { ...input, actorEmail: ctx.actor.email }); + } catch (error) { + asTrpc(error); + } + }), + saveConfig: atLeast("organizer") + .input( + z.object({ + eventId: uuid, + hardLimitSeconds: z.number().int().positive(), + dispatchStrategy: z.enum(["coverage", "uncertainty"]), + pairwiseEnabled: z.boolean(), + pairwiseWeight: z.number().min(0).max(1), + leaderboardPublic: z.boolean(), + boardPollSeconds: z.number().int().positive(), + }), + ) + .mutation(async ({ ctx, input }) => { + await saveConfig(ctx.db, { ...input, actorEmail: ctx.actor.email }); + return { ok: true }; + }), + }), +}); + +export type AppRouter = typeof appRouter; diff --git a/judging/server/src/services/arrive.ts b/judging/server/src/services/arrive.ts new file mode 100644 index 00000000..bd213f61 --- /dev/null +++ b/judging/server/src/services/arrive.ts @@ -0,0 +1,76 @@ +import { and, eq, isNull } from "drizzle-orm"; +import type { PanelDb } from "@panel/db"; +import { event, eventLog, outbox, project, visit } from "@panel/db"; +import { phaseAllows } from "@panel/core"; + +export async function arrive( + db: PanelDb, + input: { + eventId: string; + judgeId: string; + qrToken?: string; + tableNumber?: number; + now: Date; + }, +): Promise<{ visitId: string; projectId: string }> { + return db.transaction(async (tx) => { + const [evt] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + if (!phaseAllows(evt.phase, "dispatch")) { + throw new Error(`dispatch is not allowed while the event is ${evt.phase}`); + } + + const [row] = await tx + .select() + .from(visit) + .where( + and( + eq(visit.judgeId, input.judgeId), + eq(visit.eventId, input.eventId), + isNull(visit.completedAt), + isNull(visit.voidedAt), + ), + ); + if (!row) throw new Error("No table is in hand"); + + const [current] = await tx + .select() + .from(project) + .where(eq(project.id, row.projectId)); + if (!current) throw new Error("Project not found"); + + const qrOk = input.qrToken !== undefined && input.qrToken === current.qrToken; + const tableOk = + input.tableNumber !== undefined && input.tableNumber === current.tableNumber; + if (!qrOk && !tableOk) throw new Error("That is not the table you were sent to"); + + if (row.arrivedAt) return { visitId: row.id, projectId: row.projectId }; + + await tx + .update(visit) + .set({ arrivedAt: input.now }) + .where(eq(visit.id, row.id)); + if (!current.arrivedFirstAt) { + await tx + .update(project) + .set({ arrivedFirstAt: input.now }) + .where(eq(project.id, current.id)); + } + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "visit.arrived", + actor: { judgeId: input.judgeId }, + subject: { visitId: row.id, projectId: row.projectId }, + payload: {}, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "visit.arrived", + payload: { visitId: row.id, judgeId: input.judgeId }, + }); + return { visitId: row.id, projectId: row.projectId }; + }); +} diff --git a/judging/server/src/services/catalog.ts b/judging/server/src/services/catalog.ts new file mode 100644 index 00000000..9bef9d5d --- /dev/null +++ b/judging/server/src/services/catalog.ts @@ -0,0 +1,454 @@ +import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; +import type { PanelDb } from "@panel/db"; +import { + apiKey, + criterion, + event, + eventConfig, + eventLog, + floorTable, + judge, + organization, + prize, + project, + projectTrack, + result, + resultRun, + rubric, + track, + vote, + voteScore, + webhook, + zone, +} from "@panel/db"; +import { randomBytes } from "node:crypto"; +import type { Actor } from "../auth"; +import { hashApiKey, roleForScopes } from "../auth"; +import { feedbackStatus, median } from "./feedback"; + +async function placementsForProject(db: PanelDb, projectId: string) { + const links = await db + .select({ trackId: track.id, track: track.name }) + .from(projectTrack) + .innerJoin(track, eq(track.id, projectTrack.trackId)) + .where(eq(projectTrack.projectId, projectId)); + const placed = await db + .select({ trackId: result.trackId, placement: result.placement }) + .from(result) + .innerJoin(resultRun, eq(resultRun.id, result.runId)) + .where(and(eq(result.projectId, projectId), isNotNull(resultRun.publishedAt))); + const placeOf = new Map(placed.map((row) => [row.trackId, row.placement])); + return links.map((link) => ({ + track: link.track, + placement: placeOf.get(link.trackId) ?? null, + })); +} + +/** The published card token for a project the club already knows by id. */ +export async function feedbackTokenForExternal(db: PanelDb, eventId: string, externalId: string) { + const [row] = await db + .select({ token: project.feedbackToken, phase: event.phase }) + .from(project) + .innerJoin(event, eq(event.id, project.eventId)) + .where(and(eq(project.eventId, eventId), eq(project.externalId, externalId))); + if (!row?.token || row.phase !== "published") return null; + return row.token; +} + +export async function feedbackCard(db: PanelDb, token: string) { + const [row] = await db + .select({ + projectId: project.id, + name: project.name, + eventId: project.eventId, + phase: event.phase, + stored: project.feedbackToken, + }) + .from(project) + .innerJoin(event, eq(event.id, project.eventId)) + .where(eq(project.feedbackToken, token)); + if ( + feedbackStatus({ + published: row?.phase === "published", + tokenMatches: row?.stored === token, + }) === "forbidden" || + !row + ) { + return { status: "forbidden" as const }; + } + + const scores = await db + .select({ + projectId: vote.projectId, + criterionId: voteScore.criterionId, + label: criterion.label, + value: voteScore.value, + }) + .from(voteScore) + .innerJoin(vote, eq(vote.id, voteScore.voteId)) + .innerJoin(criterion, eq(criterion.id, voteScore.criterionId)) + .where(eq(vote.eventId, row.eventId)); + + const byCriterion = new Map(); + for (const score of scores) { + const bucket = byCriterion.get(score.criterionId) ?? { + label: score.label, + mine: [], + all: [], + }; + bucket.all.push(score.value); + if (score.projectId === row.projectId) bucket.mine.push(score.value); + byCriterion.set(score.criterionId, bucket); + } + + return { + status: "ok" as const, + name: row.name, + criteria: [...byCriterion.values()].map((bucket) => ({ + label: bucket.label, + mean: + bucket.mine.length === 0 + ? 0 + : bucket.mine.reduce((sum, value) => sum + value, 0) / bucket.mine.length, + median: median(bucket.all), + })), + places: await placementsForProject(db, row.projectId), + comments: ( + await db + .select({ comment: vote.comment }) + .from(vote) + .where(and(eq(vote.projectId, row.projectId), isNotNull(vote.comment))) + ) + .map((item) => item.comment) + .filter((comment): comment is string => Boolean(comment)), + }; +} + +export async function saveTrack( + db: PanelDb, + input: { + eventId: string; + slug: string; + name: string; + kind: "main" | "sponsor" | "special"; + judgeGroup: string; + actorEmail: string; + }, +) { + const [created] = await db + .insert(track) + .values(input) + .returning({ id: track.id }); + if (!created) throw new Error("Track was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "track.saved", + actor: { email: input.actorEmail }, + subject: { trackId: created.id }, + payload: { slug: input.slug }, + }); + return created; +} + +export async function listTracks(db: PanelDb, eventId: string) { + return db.select().from(track).where(eq(track.eventId, eventId)); +} + +export async function savePrize( + db: PanelDb, + input: { + trackId: string; + place: number; + title: string; + amount: number | null; + actorEmail: string; + }, +) { + const [created] = await db + .insert(prize) + .values({ + trackId: input.trackId, + place: input.place, + title: input.title, + amount: input.amount, + }) + .returning({ id: prize.id }); + if (!created) throw new Error("Prize was not stored"); + const [owner] = await db + .select({ eventId: track.eventId }) + .from(track) + .where(eq(track.id, input.trackId)); + if (owner) { + await db.insert(eventLog).values({ + eventId: owner.eventId, + kind: "prize.saved", + actor: { email: input.actorEmail }, + subject: { prizeId: created.id, trackId: input.trackId }, + payload: { title: input.title, place: input.place }, + }); + } + return created; +} + +export async function saveZone( + db: PanelDb, + input: { eventId: string; name: string; position: number; actorEmail: string }, +) { + const [created] = await db + .insert(zone) + .values({ eventId: input.eventId, name: input.name, position: input.position }) + .returning({ id: zone.id }); + if (!created) throw new Error("Zone was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "zone.saved", + actor: { email: input.actorEmail }, + subject: { zoneId: created.id }, + payload: { name: input.name }, + }); + return created; +} + +export async function placeTable( + db: PanelDb, + input: { + eventId: string; + number: number; + zoneId: string | null; + x: number | null; + y: number | null; + actorEmail: string; + }, +) { + await db + .insert(floorTable) + .values(input) + .onConflictDoUpdate({ + target: [floorTable.eventId, floorTable.number], + set: { zoneId: input.zoneId, x: input.x, y: input.y }, + }); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "table.placed", + actor: { email: input.actorEmail }, + subject: { number: input.number }, + payload: { x: input.x, y: input.y }, + }); +} + +export async function saveRubric( + db: PanelDb, + input: { + eventId: string; + name: string; + isDefault: boolean; + criteria: { key: string; label: string; min: number; max: number; weight: number }[]; + actorEmail: string; + }, +) { + const [created] = await db + .insert(rubric) + .values({ + eventId: input.eventId, + name: input.name, + isDefault: input.isDefault, + }) + .returning({ id: rubric.id }); + if (!created) throw new Error("Rubric was not stored"); + if (input.criteria.length > 0) { + await db.insert(criterion).values( + input.criteria.map((item, position) => ({ + rubricId: created.id, + position, + key: item.key, + label: item.label, + min: item.min, + max: item.max, + weight: item.weight, + })), + ); + } + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "rubric.saved", + actor: { email: input.actorEmail }, + subject: { rubricId: created.id }, + payload: {}, + }); + return created; +} + +export async function saveWebhook( + db: PanelDb, + input: { + eventId: string; + url: string; + secret: string; + topics: string[]; + actorEmail: string; + }, +) { + const [evt] = await db + .select({ orgId: event.orgId }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + const [created] = await db + .insert(webhook) + .values({ + orgId: evt.orgId, + url: input.url, + secret: input.secret, + topics: input.topics, + }) + .returning({ id: webhook.id }); + if (!created) throw new Error("Webhook was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "webhook.saved", + actor: { email: input.actorEmail }, + subject: { webhookId: created.id }, + payload: { url: input.url }, + }); + return created; +} + +export async function issueApiKey( + db: PanelDb, + input: { eventId: string; scopes: string[]; actorEmail: string }, +) { + const [evt] = await db + .select({ orgId: organization.id }) + .from(event) + .innerJoin(organization, eq(organization.id, event.orgId)) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + const token = randomBytes(24).toString("hex"); + const [created] = await db + .insert(apiKey) + .values({ + orgId: evt.orgId, + hashedKey: hashApiKey(token), + scopes: input.scopes, + }) + .returning({ id: apiKey.id }); + if (!created) throw new Error("API key was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "api_key.created", + actor: { email: input.actorEmail }, + subject: { apiKeyId: created.id }, + payload: {}, + }); + return { id: created.id, token }; +} + +export async function actorForApiKey(db: PanelDb, token: string): Promise { + const [row] = await db + .select() + .from(apiKey) + .where(and(eq(apiKey.hashedKey, hashApiKey(token)), isNull(apiKey.revokedAt))); + if (!row) return null; + const granted = roleForScopes(row.scopes); + if (!granted) return null; + const [org] = await db + .select({ slug: organization.slug }) + .from(organization) + .where(eq(organization.id, row.orgId)); + if (!org) return null; + return { + sub: row.id, + email: `key:${row.id}`, + name: "API key", + org: org.slug, + role: granted, + judgeExternalId: null, + }; +} + +export async function listTables(db: PanelDb, eventId: string) { + return db + .select({ + number: floorTable.number, + zoneId: floorTable.zoneId, + x: floorTable.x, + y: floorTable.y, + }) + .from(floorTable) + .where(eq(floorTable.eventId, eventId)); +} + +export async function listJudges(db: PanelDb, eventId: string) { + return db + .select({ + id: judge.id, + name: judge.name, + email: judge.email, + status: judge.status, + }) + .from(judge) + .where(eq(judge.eventId, eventId)); +} + +export async function listProjects(db: PanelDb, eventId: string) { + return db + .select({ + id: project.id, + name: project.name, + tableNumber: project.tableNumber, + zoneId: project.zoneId, + zoneName: zone.name, + }) + .from(project) + .leftJoin(zone, eq(zone.id, project.zoneId)) + .where(eq(project.eventId, eventId)); +} + +export async function listLogs(db: PanelDb, eventId: string) { + return db + .select({ + id: eventLog.id, + kind: eventLog.kind, + createdAt: eventLog.createdAt, + }) + .from(eventLog) + .where(eq(eventLog.eventId, eventId)) + .orderBy(desc(eventLog.createdAt)) + .limit(40); +} + +export async function saveConfig( + db: PanelDb, + input: { + eventId: string; + hardLimitSeconds: number; + dispatchStrategy: "coverage" | "uncertainty"; + pairwiseEnabled: boolean; + pairwiseWeight: number; + leaderboardPublic: boolean; + boardPollSeconds: number; + actorEmail: string; + }, +) { + await db + .update(eventConfig) + .set({ + hardLimitSeconds: input.hardLimitSeconds, + dispatchStrategy: input.dispatchStrategy, + pairwiseEnabled: input.pairwiseEnabled, + pairwiseWeight: input.pairwiseWeight, + leaderboardPublic: input.leaderboardPublic, + boardPollSeconds: input.boardPollSeconds, + }) + .where(eq(eventConfig.eventId, input.eventId)); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "config.saved", + actor: { email: input.actorEmail }, + subject: { eventId: input.eventId }, + payload: { + dispatchStrategy: input.dispatchStrategy, + hardLimitSeconds: input.hardLimitSeconds, + }, + }); +} diff --git a/judging/server/src/services/compare.ts b/judging/server/src/services/compare.ts new file mode 100644 index 00000000..953314ce --- /dev/null +++ b/judging/server/src/services/compare.ts @@ -0,0 +1,63 @@ +import { and, desc, eq, isNull } from "drizzle-orm"; +import { phaseAllows } from "@panel/core"; +import type { PanelDb } from "@panel/db"; +import { comparison, event, eventLog, visit } from "@panel/db"; + +/** The judge's previous table against the one they are at. */ +export async function castComparison( + db: PanelDb, + input: { + eventId: string; + judgeId: string; + outcome: "a" | "b" | "tie"; + now: Date; + }, +) { + const [evt] = await db + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + if (!phaseAllows(evt.phase, "vote")) { + throw new Error(`vote is not allowed while the event is ${evt.phase}`); + } + + const visits = await db + .select() + .from(visit) + .where( + and( + eq(visit.eventId, input.eventId), + eq(visit.judgeId, input.judgeId), + isNull(visit.voidedAt), + ), + ) + .orderBy(desc(visit.handedOutAt)); + const current = visits.find((row) => !row.completedAt); + const earlier = visits.find((row) => row.completedAt); + if (!current) throw new Error("No table is in hand"); + if (!earlier) throw new Error("Compare after a finished table"); + + const [row] = await db + .insert(comparison) + .values({ + eventId: input.eventId, + judgeId: input.judgeId, + judgeGroup: current.judgeGroup, + aProjectId: earlier.projectId, + bProjectId: current.projectId, + outcome: input.outcome, + createdAt: input.now, + }) + .returning({ id: comparison.id }); + if (!row) throw new Error("Comparison was not stored"); + + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "comparison.cast", + actor: { judgeId: input.judgeId }, + subject: { comparisonId: row.id }, + payload: { outcome: input.outcome }, + }); + return { id: row.id }; +} diff --git a/judging/server/src/services/dispatch.concurrent.test.ts b/judging/server/src/services/dispatch.concurrent.test.ts new file mode 100644 index 00000000..71eb9e52 --- /dev/null +++ b/judging/server/src/services/dispatch.concurrent.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { dispatchNext } from "./dispatch"; +import { createDb, databaseUrl, migrate } from "@panel/db"; + +const url = databaseUrl(); + +describe.skipIf(!url)("concurrent dispatch", () => { + it("does not hand two judges the same project", async () => { + const { db, pool } = createDb(url as string); + try { + await migrate(pool); + const { seedDemo, judge, project } = await import("@panel/db"); + const { eq } = await import("drizzle-orm"); + const { eventId } = await seedDemo(db); + const judges = await db.select().from(judge).where(eq(judge.eventId, eventId)); + const first = judges[0]; + const second = judges[1]; + if (!first || !second) throw new Error("demo judges missing"); + const now = new Date(); + const [a, b] = await Promise.all([ + dispatchNext(db, { eventId, judgeId: first.id, now }), + dispatchNext(db, { eventId, judgeId: second.id, now }), + ]); + expect(a.done).toBe(false); + expect(b.done).toBe(false); + if (!a.done && !b.done) expect(a.projectId).not.toBe(b.projectId); + const rows = await db.select().from(project).where(eq(project.eventId, eventId)); + expect(rows.length).toBeGreaterThan(1); + } finally { + await pool.end(); + } + }); +}); diff --git a/judging/server/src/services/dispatch.ts b/judging/server/src/services/dispatch.ts new file mode 100644 index 00000000..9210b081 --- /dev/null +++ b/judging/server/src/services/dispatch.ts @@ -0,0 +1,413 @@ +import { randomInt } from "node:crypto"; +import { and, eq, isNull, sql } from "drizzle-orm"; +import { isLive, pickNext } from "@panel/core"; +import type { TimerConfig } from "@panel/core"; +import type { PanelDb, PanelTx } from "@panel/db"; +import { + event, + eventConfig, + eventLog, + judge, + outbox, + project, + projectTrack, + track, + visit, + vote, +} from "@panel/db"; +import { isDeliveredTopic, logsForHandout } from "./logs"; + +export type DispatchOutcome = + | { done: true } + | { + done: false; + reused: boolean; + visitId: string; + projectId: string; + projectName: string; + tableNumber: number | null; + zoneId: string | null; + handedOutAt: Date; + arrivedAt: Date | null; + }; + +type VisitRow = typeof visit.$inferSelect; +type ProjectRow = typeof project.$inferSelect; + +export async function dispatchNext( + db: PanelDb, + input: { eventId: string; judgeId: string; now: Date }, +): Promise { + return db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${`dispatch:${input.eventId}`}))`, + ); + + const phase = await loadPhase(tx, input.eventId); + if (phase !== "judging_live") { + throw new Error(`dispatch is not allowed while the event is ${phase}`); + } + const config = await loadConfig(tx, input.eventId); + const judgeRow = await loadJudge(tx, input.judgeId, input.eventId); + if (judgeRow.status !== "approved") { + throw new Error("This judge is not approved"); + } + + const projects = await loadPool(tx, input.eventId, judgeRow.trackId); + const poolIds = new Set(projects.map((item) => item.id)); + const mine = await tx + .select() + .from(visit) + .where( + and(eq(visit.judgeId, input.judgeId), eq(visit.eventId, input.eventId)), + ); + + const lapsed = mine.filter( + (row) => + !row.completedAt && + !row.voidedAt && + (row.handedOutAt || row.arrivedAt) && + (!isLive(clock(row), input.now, config) || !poolIds.has(row.projectId)), + ); + if (lapsed.length > 0) { + for (const row of lapsed) { + await tx + .update(visit) + .set({ + voidedAt: input.now, + voidReason: poolIds.has(row.projectId) ? "lapsed" : "withdrawn", + }) + .where(eq(visit.id, row.id)); + } + } + + const open = mine.find( + (row) => + !lapsed.includes(row) && + !row.completedAt && + !row.voidedAt && + isLive(clock(row), input.now, config), + ); + if (open) { + const current = projects.find((item) => item.id === open.projectId); + return { + done: false as const, + reused: true, + visitId: open.id, + projectId: open.projectId, + projectName: current?.name ?? "", + tableNumber: current?.tableNumber ?? null, + zoneId: current?.zoneId ?? null, + handedOutAt: open.handedOutAt, + arrivedAt: open.arrivedAt, + }; + } + + const seen = new Set( + mine.filter((row) => !row.voidedAt).map((row) => row.projectId), + ); + for (const row of lapsed) seen.add(row.projectId); + const unseen = projects.filter((item) => !seen.has(item.id)); + const myGroup = await groupOf(tx, judgeRow.trackId); + if (unseen.length === 0) { + await record( + tx, + input, + lapsed.map((row) => row.id), + null, + ); + return { done: true as const }; + } + + const picked = await choose( + tx, + input, + unseen, + mine, + projects, + myGroup, + config, + ); + const choice = picked ? unseen.find((item) => item.id === picked.id) : undefined; + if (!choice) { + await record( + tx, + input, + lapsed.map((row) => row.id), + null, + ); + return { done: true as const }; + } + + const [created] = await tx + .insert(visit) + .values({ + eventId: input.eventId, + judgeId: input.judgeId, + projectId: choice.id, + judgeGroup: myGroup, + handedOutAt: input.now, + }) + .returning(); + if (!created) throw new Error("visit was not created"); + + await record(tx, input, lapsed.map((row) => row.id), { + visitId: created.id, + projectId: choice.id, + }); + + return { + done: false as const, + reused: false, + visitId: created.id, + projectId: choice.id, + projectName: choice.name, + tableNumber: choice.tableNumber, + zoneId: choice.zoneId, + handedOutAt: created.handedOutAt, + arrivedAt: null, + }; + }); +} + +async function record( + tx: PanelTx, + input: { eventId: string; judgeId: string }, + voidedVisitIds: readonly string[], + handout: { visitId: string; projectId: string } | null, +) { + const drafts = handout + ? logsForHandout(voidedVisitIds) + : voidedVisitIds.map((visitId) => ({ + kind: "visit.voided" as const, + subject: { visitId } as Record, + payload: {} as Record, + })); + if (handout) { + const handed = drafts[drafts.length - 1]; + if (handed) handed.subject = handout; + } + if (drafts.length === 0) return; + + await tx.insert(eventLog).values( + drafts.map((draft) => ({ + eventId: input.eventId, + kind: draft.kind, + actor: { judgeId: input.judgeId }, + subject: draft.subject, + payload: draft.payload, + })), + ); + const topics = drafts.filter((draft) => isDeliveredTopic(draft.kind)); + if (topics.length === 0) return; + await tx.insert(outbox).values( + topics.map((draft) => ({ + eventId: input.eventId, + topic: draft.kind, + payload: { ...draft.subject, judgeId: input.judgeId }, + })), + ); +} + +function clock(row: VisitRow) { + return { + handedOutAt: row.handedOutAt, + arrivedAt: row.arrivedAt, + completedAt: row.completedAt ?? row.voidedAt, + }; +} + +async function loadPhase(tx: PanelDb | PanelTx, eventId: string) { + const [row] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, eventId)); + if (!row) throw new Error("Event not found"); + return row.phase; +} + +async function loadConfig(tx: PanelTx, eventId: string): Promise { + const [row] = await tx + .select() + .from(eventConfig) + .where(eq(eventConfig.eventId, eventId)); + if (!row) throw new Error("Event config not found"); + return { + targetSeconds: row.targetSeconds, + hardLimitSeconds: row.hardLimitSeconds, + walkLimitSeconds: row.walkLimitSeconds, + submitGraceSeconds: row.submitGraceSeconds, + strategy: row.dispatchStrategy, + minLooksPerProject: row.minLooksPerProject, + }; +} + +async function loadJudge(tx: PanelDb | PanelTx, judgeId: string, eventId: string) { + const [row] = await tx + .select() + .from(judge) + .where(and(eq(judge.id, judgeId), eq(judge.eventId, eventId))); + if (!row) throw new Error("Judge not found"); + return row; +} + +async function loadPool(tx: PanelDb | PanelTx, eventId: string, trackId: string | null) { + const projects = await tx + .select() + .from(project) + .where(and(eq(project.eventId, eventId), isNull(project.withdrawnAt))); + if (!trackId) return projects; + const links = await tx + .select({ projectId: projectTrack.projectId }) + .from(projectTrack) + .where(eq(projectTrack.trackId, trackId)); + const allowed = new Set(links.map((link) => link.projectId)); + return projects.filter((item) => allowed.has(item.id)); +} + +async function groupOf(tx: PanelTx, trackId: string | null) { + if (!trackId) return "main"; + const [row] = await tx + .select({ judgeGroup: track.judgeGroup }) + .from(track) + .where(eq(track.id, trackId)); + return row?.judgeGroup ?? "main"; +} + +async function choose( + tx: PanelTx, + input: { eventId: string; judgeId: string; now: Date }, + unseen: ProjectRow[], + mine: VisitRow[], + projects: ProjectRow[], + myGroup: string, + config: TimerConfig & { + strategy: "coverage" | "uncertainty"; + minLooksPerProject: number; + }, +) { + const judges = await tx + .select({ id: judge.id, trackId: judge.trackId }) + .from(judge) + .where(eq(judge.eventId, input.eventId)); + const tracks = await tx + .select({ id: track.id, judgeGroup: track.judgeGroup }) + .from(track) + .where(eq(track.eventId, input.eventId)); + const groupByJudge = new Map( + judges.map((item) => [ + item.id, + tracks.find((entry) => entry.id === item.trackId)?.judgeGroup ?? "main", + ]), + ); + + const coverage = new Map(); + const bump = (projectId: string) => + coverage.set(projectId, (coverage.get(projectId) ?? 0) + 1); + + const votes = await tx + .select({ judgeId: vote.judgeId, projectId: vote.projectId }) + .from(vote) + .where(eq(vote.eventId, input.eventId)); + for (const row of votes) { + if (groupByJudge.get(row.judgeId) === myGroup) bump(row.projectId); + } + + const open = await tx + .select() + .from(visit) + .where(and(eq(visit.eventId, input.eventId), isNull(visit.completedAt), isNull(visit.voidedAt))); + const claimedAt = new Map(); + for (const row of open) { + if (row.judgeId === input.judgeId) continue; + if (!isLive(clock(row), input.now, config)) continue; + if (groupByJudge.get(row.judgeId) !== myGroup) continue; + bump(row.projectId); + const at = (row.arrivedAt ?? row.handedOutAt).getTime(); + claimedAt.set(row.projectId, Math.max(claimedAt.get(row.projectId) ?? 0, at)); + } + + const byId = new Map(projects.map((item) => [item.id, item])); + const last = [...mine].sort( + (a, b) => b.handedOutAt.getTime() - a.handedOutAt.getTime(), + )[0]; + const lastProject = last ? byId.get(last.projectId) : undefined; + + return pickNext( + unseen.map((item) => ({ + id: item.id, + tableNumber: item.tableNumber ?? 0, + zoneId: item.zoneId, + coverage: coverage.get(item.id) ?? 0, + claimedAt: claimedAt.get(item.id) ?? null, + })), + { + lastTable: lastProject?.tableNumber ?? null, + zoneId: lastProject?.zoneId ?? null, + }, + { + strategy: config.strategy, + minLooksPerProject: config.minLooksPerProject, + }, + (n) => randomInt(0, n), + ); +} + +/** Pass on the open table without a score. It stays seen, so this judge is not sent back. */ +export async function skipVisit( + db: PanelDb, + input: { eventId: string; judgeId: string; visitId: string; now: Date }, +) { + return db.transaction(async (tx) => { + const phase = await loadPhase(tx, input.eventId); + if (phase !== "judging_live") { + throw new Error(`skip is not allowed while the event is ${phase}`); + } + const [row] = await tx + .select() + .from(visit) + .where( + and( + eq(visit.id, input.visitId), + eq(visit.judgeId, input.judgeId), + eq(visit.eventId, input.eventId), + ), + ); + if (!row || row.voidedAt || row.completedAt) { + throw new Error("This visit cannot be skipped"); + } + await tx.update(visit).set({ completedAt: input.now }).where(eq(visit.id, row.id)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "visit.skipped", + actor: { judgeId: input.judgeId }, + subject: { visitId: row.id, projectId: row.projectId }, + payload: {}, + }); + return { projectId: row.projectId }; + }); +} + +export async function judgeProgress( + db: PanelDb, + input: { eventId: string; judgeId: string }, +) { + const person = await loadJudge(db, input.judgeId, input.eventId); + if (person.status !== "approved") throw new Error("This judge is not approved"); + const pool = await loadPool(db, input.eventId, person.trackId); + const [scored] = await db + .select({ n: sql`count(*)::int` }) + .from(vote) + .where(and(eq(vote.judgeId, input.judgeId), eq(vote.eventId, input.eventId))); + const total = pool.length; + const completed = scored?.n ?? 0; + return { + judgeId: input.judgeId, + total, + completed, + percentage: total > 0 ? Math.round((completed / total) * 100) : 0, + }; +} diff --git a/judging/server/src/services/feedback.test.ts b/judging/server/src/services/feedback.test.ts new file mode 100644 index 00000000..6f897277 --- /dev/null +++ b/judging/server/src/services/feedback.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { feedbackStatus, median } from "./feedback"; + +describe("feedbackStatus", () => { + it("refuses before publish and for the wrong token", () => { + expect(feedbackStatus({ published: false, tokenMatches: true })).toBe("forbidden"); + expect(feedbackStatus({ published: true, tokenMatches: false })).toBe("forbidden"); + }); + + it("allows the project's token after publish", () => { + expect(feedbackStatus({ published: true, tokenMatches: true })).toBe("ok"); + }); + + it("puts the event median between the middle scores", () => { + expect(median([1, 3, 9])).toBe(3); + expect(median([1, 2, 8, 9])).toBe(5); + }); +}); diff --git a/judging/server/src/services/feedback.ts b/judging/server/src/services/feedback.ts new file mode 100644 index 00000000..d12107fc --- /dev/null +++ b/judging/server/src/services/feedback.ts @@ -0,0 +1,17 @@ +export function feedbackStatus(input: { + published: boolean; + tokenMatches: boolean; +}): "ok" | "forbidden" { + if (!input.published || !input.tokenMatches) return "forbidden"; + return "ok"; +} + +export function median(values: readonly number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const upper = sorted[mid] ?? 0; + if (sorted.length % 2 === 1) return upper; + const lower = sorted[mid - 1] ?? upper; + return (lower + upper) / 2; +} diff --git a/judging/server/src/services/floor.test.ts b/judging/server/src/services/floor.test.ts new file mode 100644 index 00000000..1da5647e --- /dev/null +++ b/judging/server/src/services/floor.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_TIMERS } from "@panel/core"; +import { judgeFloorState } from "./floor"; + +const arrived = new Date("2027-02-28T13:00:00Z"); + +describe("judgeFloorState", () => { + it("marks a judge with no table as idle", () => { + expect(judgeFloorState(null, arrived, DEFAULT_TIMERS)).toEqual({ + state: "idle", + overtime: false, + }); + }); + + it("marks a judge who has not arrived as walking", () => { + expect(judgeFloorState({ arrivedAt: null }, arrived, DEFAULT_TIMERS).state).toBe("walking"); + }); + + it("marks overtime only after the target time at the table", () => { + const early = judgeFloorState({ arrivedAt: arrived }, new Date(arrived.getTime() + 1000), DEFAULT_TIMERS); + const late = judgeFloorState( + { arrivedAt: arrived }, + new Date(arrived.getTime() + DEFAULT_TIMERS.targetSeconds * 1000 + 1000), + DEFAULT_TIMERS, + ); + expect(early).toEqual({ state: "at table", overtime: false }); + expect(late).toEqual({ state: "at table", overtime: true }); + }); +}); diff --git a/judging/server/src/services/floor.ts b/judging/server/src/services/floor.ts new file mode 100644 index 00000000..0358e071 --- /dev/null +++ b/judging/server/src/services/floor.ts @@ -0,0 +1,79 @@ +import { and, eq, isNull, sql } from "drizzle-orm"; +import { isOverTarget } from "@panel/core"; +import type { TimerConfig } from "@panel/core"; +import type { PanelDb } from "@panel/db"; +import { eventConfig, judge, project, visit, vote } from "@panel/db"; + +export type FloorState = "idle" | "walking" | "at table"; + +export function judgeFloorState( + open: { arrivedAt: Date | null } | null, + now: Date, + config: TimerConfig, +): { state: FloorState; overtime: boolean } { + if (!open) return { state: "idle", overtime: false }; + if (!open.arrivedAt) return { state: "walking", overtime: false }; + return { + state: "at table", + overtime: isOverTarget(open.arrivedAt, now, config), + }; +} + +export async function organizerFloor(db: PanelDb, eventId: string, now: Date) { + const [config] = await db + .select({ + targetSeconds: eventConfig.targetSeconds, + hardLimitSeconds: eventConfig.hardLimitSeconds, + walkLimitSeconds: eventConfig.walkLimitSeconds, + submitGraceSeconds: eventConfig.submitGraceSeconds, + }) + .from(eventConfig) + .where(eq(eventConfig.eventId, eventId)); + if (!config) return null; + + const projects = await db + .select({ id: project.id, tableNumber: project.tableNumber }) + .from(project) + .where(and(eq(project.eventId, eventId), isNull(project.withdrawnAt))); + const looks = await db + .select({ projectId: vote.projectId, count: sql`count(*)::int` }) + .from(vote) + .where(eq(vote.eventId, eventId)) + .groupBy(vote.projectId); + const lookOf = new Map(looks.map((item) => [item.projectId, item.count])); + + const open = await db + .select({ + judgeId: visit.judgeId, + arrivedAt: visit.arrivedAt, + tableNumber: project.tableNumber, + }) + .from(visit) + .innerJoin(project, eq(project.id, visit.projectId)) + .where(and(eq(visit.eventId, eventId), isNull(visit.completedAt), isNull(visit.voidedAt))); + const openOf = new Map(open.map((item) => [item.judgeId, item])); + + const people = await db + .select({ id: judge.id, name: judge.name }) + .from(judge) + .where(and(eq(judge.eventId, eventId), eq(judge.status, "approved"))); + + return { + tables: projects + .map((item) => ({ + tableNumber: item.tableNumber, + looks: lookOf.get(item.id) ?? 0, + })) + .sort((a, b) => (a.tableNumber ?? 0) - (b.tableNumber ?? 0)), + judges: people.map((person) => { + const current = openOf.get(person.id) ?? null; + const state = judgeFloorState(current, now, config); + return { + name: person.name, + state: state.state, + overtime: state.overtime, + tableNumber: current?.tableNumber ?? null, + }; + }), + }; +} diff --git a/judging/server/src/services/flow.integration.test.ts b/judging/server/src/services/flow.integration.test.ts new file mode 100644 index 00000000..91f3a024 --- /dev/null +++ b/judging/server/src/services/flow.integration.test.ts @@ -0,0 +1,110 @@ +import { and, eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { + createDb, + criterion, + databaseUrl, + event, + eventLog, + migrate, + project, + rubric, + seedDemo, +} from "@panel/db"; +import { arrive } from "./arrive"; +import { dispatchNext } from "./dispatch"; +import { feedbackCard } from "./catalog"; +import { computeResults, publishResults } from "./results"; +import { castVote } from "./vote"; + +const url = databaseUrl(); + +describe.skipIf(!url)("a full visit", () => { + it("scores a table, publishes, and opens that project's feedback", async () => { + const { db, pool } = createDb(url as string); + try { + await migrate(pool); + const { eventId } = await seedDemo(db); + const { judge } = await import("@panel/db"); + const [person] = await db.select().from(judge).where(eq(judge.eventId, eventId)); + if (!person) throw new Error("demo judge missing"); + const now = new Date("2027-02-28T13:00:00Z"); + + const handed = await dispatchNext(db, { eventId, judgeId: person.id, now }); + expect(handed.done).toBe(false); + if (handed.done) return; + + await arrive(db, { + eventId, + judgeId: person.id, + tableNumber: handed.tableNumber ?? undefined, + now: new Date(now.getTime() + 30_000), + }); + + const [rub] = await db + .select({ id: rubric.id }) + .from(rubric) + .where(and(eq(rubric.eventId, eventId), eq(rubric.isDefault, true))); + if (!rub) throw new Error("demo rubric missing"); + const criteria = await db + .select() + .from(criterion) + .where(eq(criterion.rubricId, rub.id)); + + await castVote(db, { + eventId, + judgeId: person.id, + visitId: handed.visitId, + scores: criteria.map((item) => ({ criterionId: item.id, value: 8 })), + comment: null, + now: new Date(now.getTime() + 60_000), + }); + + await db.update(event).set({ phase: "judging_closed" }).where(eq(event.id, eventId)); + const run = await computeResults(db, { + eventId, + actorEmail: "organizer@demo.panel", + now: new Date(now.getTime() + 120_000), + }); + await publishResults(db, { + eventId, + runId: run.runId, + actorEmail: "organizer@demo.panel", + now: new Date(now.getTime() + 130_000), + }); + + const [scored] = await db + .select({ token: project.feedbackToken }) + .from(project) + .where(eq(project.id, handed.projectId)); + if (!scored?.token) throw new Error("feedback token was not minted"); + + const card = await feedbackCard(db, scored.token); + expect(card.status).toBe("ok"); + if (card.status === "ok") { + expect(card.criteria.length).toBeGreaterThan(0); + expect(card.criteria[0]?.mean).toBe(8); + } + expect((await feedbackCard(db, "00000000-0000-0000-0000-000000000000")).status).toBe( + "forbidden", + ); + + const kinds = [ + "visit.handed_out", + "visit.arrived", + "vote.cast", + "results.computed", + "results.published", + ] as const; + for (const kind of kinds) { + const rows = await db + .select({ id: eventLog.id }) + .from(eventLog) + .where(and(eq(eventLog.eventId, eventId), eq(eventLog.kind, kind))); + expect(rows, kind).toHaveLength(1); + } + } finally { + await pool.end(); + } + }); +}); diff --git a/judging/server/src/services/live.ts b/judging/server/src/services/live.ts new file mode 100644 index 00000000..750c7481 --- /dev/null +++ b/judging/server/src/services/live.ts @@ -0,0 +1,122 @@ +import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"; +import type { PanelDb } from "@panel/db"; +import { event, eventConfig, judge, organization, prize, project, result, resultRun, track, visit, vote } from "@panel/db"; + +export async function liveSnapshot(db: PanelDb, orgSlug: string, eventSlug: string) { + const [row] = await db + .select({ + eventId: event.id, + phase: event.phase, + name: event.name, + leaderboardPublic: eventConfig.leaderboardPublic, + pollSeconds: eventConfig.boardPollSeconds, + }) + .from(event) + .innerJoin(organization, eq(organization.id, event.orgId)) + .innerJoin(eventConfig, eq(eventConfig.eventId, event.id)) + .where(and(eq(organization.slug, orgSlug), eq(event.slug, eventSlug))); + if (!row) return null; + + const prizes = await db + .select({ + title: prize.title, + place: prize.place, + amount: prize.amount, + track: track.name, + }) + .from(prize) + .innerJoin(track, eq(track.id, prize.trackId)) + .where(eq(track.eventId, row.eventId)) + .orderBy(track.position, prize.place); + + const published = row.phase === "published"; + const showFloor = published || row.leaderboardPublic; + if (!showFloor) { + return { + eventId: row.eventId, + name: row.name, + phase: row.phase, + pollSeconds: row.pollSeconds, + projects: [], + placements: [], + prizes, + }; + } + + const projects = await db + .select({ + id: project.id, + name: project.name, + tableNumber: project.tableNumber, + }) + .from(project) + .where(and(eq(project.eventId, row.eventId), isNull(project.withdrawnAt))); + + const looks = await db + .select({ + projectId: vote.projectId, + count: sql`count(*)::int`, + }) + .from(vote) + .where(eq(vote.eventId, row.eventId)) + .groupBy(vote.projectId); + const lookOf = new Map(looks.map((item) => [item.projectId, item.count])); + + const open = await db + .select({ judgeId: visit.judgeId, projectId: visit.projectId, arrivedAt: visit.arrivedAt }) + .from(visit) + .where( + and(eq(visit.eventId, row.eventId), isNull(visit.completedAt), isNull(visit.voidedAt)), + ); + const judges = await db + .select({ id: judge.id, name: judge.name }) + .from(judge) + .where(eq(judge.eventId, row.eventId)); + const nameOf = new Map(judges.map((item) => [item.id, item.name])); + + let placements: { + projectId: string; + projectName: string; + trackId: string; + placement: number | null; + }[] = []; + if (published) { + const [run] = await db + .select({ id: resultRun.id }) + .from(resultRun) + .where(and(eq(resultRun.eventId, row.eventId), isNotNull(resultRun.publishedAt))); + if (run) { + placements = await db + .select({ + projectId: result.projectId, + projectName: project.name, + trackId: result.trackId, + placement: result.placement, + }) + .from(result) + .innerJoin(project, eq(project.id, result.projectId)) + .where(eq(result.runId, run.id)); + } + } + + return { + eventId: row.eventId, + name: row.name, + phase: row.phase, + pollSeconds: row.pollSeconds, + projects: projects.map((item) => ({ + id: item.id, + name: published ? item.name : null, + tableNumber: item.tableNumber, + looks: lookOf.get(item.id) ?? 0, + })), + floor: open.map((item) => ({ + judgeId: item.judgeId, + judgeName: nameOf.get(item.judgeId) ?? "", + projectId: item.projectId, + arrived: item.arrivedAt !== null, + })), + placements, + prizes, + }; +} diff --git a/judging/server/src/services/logs.test.ts b/judging/server/src/services/logs.test.ts new file mode 100644 index 00000000..2be074f6 --- /dev/null +++ b/judging/server/src/services/logs.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { logsForHandout } from "./logs"; + +describe("logsForHandout", () => { + it("writes exactly one row when nothing has lapsed", () => { + expect(logsForHandout([]).map((row) => row.kind)).toEqual(["visit.handed_out"]); + }); + + it("writes one void row per lapsed visit, then the hand-out", () => { + expect(logsForHandout(["a", "b"]).map((row) => row.kind)).toEqual([ + "visit.voided", + "visit.voided", + "visit.handed_out", + ]); + }); +}); diff --git a/judging/server/src/services/logs.ts b/judging/server/src/services/logs.ts new file mode 100644 index 00000000..7c288047 --- /dev/null +++ b/judging/server/src/services/logs.ts @@ -0,0 +1,28 @@ +import { isOutboxTopic } from "@panel/core"; +import type { LogKind } from "@panel/core"; + +export type LogDraft = { + kind: LogKind; + subject: Record; + payload: Record; +}; + +/** One log row per void, then one for the hand-out. A reload writes none. */ +export function logsForHandout(voidedVisitIds: readonly string[]): LogDraft[] { + return [ + ...voidedVisitIds.map((visitId) => ({ + kind: "visit.voided" as const, + subject: { visitId }, + payload: {}, + })), + { + kind: "visit.handed_out" as const, + subject: {}, + payload: {}, + }, + ]; +} + +export function isDeliveredTopic(kind: LogKind): boolean { + return isOutboxTopic(kind); +} diff --git a/judging/server/src/services/outbox.ts b/judging/server/src/services/outbox.ts new file mode 100644 index 00000000..0844039b --- /dev/null +++ b/judging/server/src/services/outbox.ts @@ -0,0 +1,52 @@ +import { createHmac } from "node:crypto"; +import { eq, isNull } from "drizzle-orm"; +import type { PanelDb } from "@panel/db"; +import { outbox, webhook } from "@panel/db"; + +/** Delivers pending outbox rows. A topic with no active webhook is delivered. */ +export async function drainOutbox(db: PanelDb, now = new Date()): Promise { + const pending = await db + .select() + .from(outbox) + .where(isNull(outbox.deliveredAt)) + .limit(50); + if (pending.length === 0) return 0; + + const hooks = await db.select().from(webhook).where(eq(webhook.isActive, true)); + let delivered = 0; + + for (const row of pending) { + const targets = hooks.filter((hook) => hook.topics?.includes(row.topic)); + let ok = true; + for (const hook of targets) { + const body = JSON.stringify(row.payload); + const signature = createHmac("sha256", hook.secret).update(body).digest("hex"); + try { + const response = await fetch(hook.url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-panel-signature": signature, + }, + body, + }); + if (!response.ok) ok = false; + } catch { + ok = false; + } + } + if (!ok) { + await db + .update(outbox) + .set({ attempts: row.attempts + 1 }) + .where(eq(outbox.id, row.id)); + continue; + } + await db + .update(outbox) + .set({ deliveredAt: now, attempts: row.attempts + 1 }) + .where(eq(outbox.id, row.id)); + delivered += 1; + } + return delivered; +} diff --git a/judging/server/src/services/overtime.test.ts b/judging/server/src/services/overtime.test.ts new file mode 100644 index 00000000..f39cfaef --- /dev/null +++ b/judging/server/src/services/overtime.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_TIMERS } from "@panel/core"; +import { visitsOverTarget } from "./overtime"; + +const arrived = new Date("2027-02-28T13:00:00Z"); + +describe("visitsOverTarget", () => { + it("includes a judge who has stayed past the target", () => { + const rows = visitsOverTarget( + [ + { + visitId: "late", + eventId: "event", + judgeId: "judge", + arrivedAt: arrived, + config: DEFAULT_TIMERS, + }, + { + visitId: "early", + eventId: "event", + judgeId: "other", + arrivedAt: new Date(arrived.getTime() + 10_000), + config: DEFAULT_TIMERS, + }, + ], + new Date(arrived.getTime() + DEFAULT_TIMERS.targetSeconds * 1000 + 1000), + ); + expect(rows.map((row) => row.visitId)).toEqual(["late"]); + }); +}); diff --git a/judging/server/src/services/overtime.ts b/judging/server/src/services/overtime.ts new file mode 100644 index 00000000..dad634b7 --- /dev/null +++ b/judging/server/src/services/overtime.ts @@ -0,0 +1,49 @@ +import { and, eq, isNotNull, isNull } from "drizzle-orm"; +import { isOverTarget } from "@panel/core"; +import type { TimerConfig } from "@panel/core"; +import type { PanelDb } from "@panel/db"; +import { eventConfig, visit } from "@panel/db"; + +export type OpenVisit = { + visitId: string; + eventId: string; + judgeId: string; + arrivedAt: Date | null; + config: TimerConfig; +}; + +/** Visits where the judge has arrived and is past the target time. */ +export function visitsOverTarget(rows: readonly OpenVisit[], now: Date): OpenVisit[] { + return rows.filter( + (row) => row.arrivedAt !== null && isOverTarget(row.arrivedAt, now, row.config), + ); +} + +export async function loadOpenVisits(db: PanelDb): Promise { + const rows = await db + .select({ + visitId: visit.id, + eventId: visit.eventId, + judgeId: visit.judgeId, + arrivedAt: visit.arrivedAt, + targetSeconds: eventConfig.targetSeconds, + hardLimitSeconds: eventConfig.hardLimitSeconds, + walkLimitSeconds: eventConfig.walkLimitSeconds, + submitGraceSeconds: eventConfig.submitGraceSeconds, + }) + .from(visit) + .innerJoin(eventConfig, eq(eventConfig.eventId, visit.eventId)) + .where(and(isNull(visit.completedAt), isNull(visit.voidedAt), isNotNull(visit.arrivedAt))); + return rows.map((row) => ({ + visitId: row.visitId, + eventId: row.eventId, + judgeId: row.judgeId, + arrivedAt: row.arrivedAt, + config: { + targetSeconds: row.targetSeconds, + hardLimitSeconds: row.hardLimitSeconds, + walkLimitSeconds: row.walkLimitSeconds, + submitGraceSeconds: row.submitGraceSeconds, + }, + })); +} diff --git a/judging/server/src/services/records.ts b/judging/server/src/services/records.ts new file mode 100644 index 00000000..39f865cd --- /dev/null +++ b/judging/server/src/services/records.ts @@ -0,0 +1,358 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, isNull } from "drizzle-orm"; +import { assignTables, phaseAllows } from "@panel/core"; +import type { PanelDb } from "@panel/db"; +import { event, eventLog, floorTable, judge, outbox, project, projectTrack, visit } from "@panel/db"; + +async function phaseOf(db: PanelDb, eventId: string) { + const [row] = await db + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, eventId)); + if (!row) throw new Error("Event not found"); + return row.phase; +} + +export async function upsertProject( + db: PanelDb, + input: { + eventId: string; + externalId: string; + name: string; + teamName: string | null; + tableNumber: number | null; + actorEmail: string; + }, +) { + const phase = await phaseOf(db, input.eventId); + if (phase === "archived" || phase === "published") { + throw new Error(`submit is not allowed while the event is ${phase}`); + } + const [existing] = await db + .select({ id: project.id }) + .from(project) + .where(and(eq(project.eventId, input.eventId), eq(project.externalId, input.externalId))); + + if (existing) { + await db + .update(project) + .set({ + name: input.name, + teamName: input.teamName, + tableNumber: input.tableNumber, + }) + .where(eq(project.id, existing.id)); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "project.upserted", + actor: { email: input.actorEmail }, + subject: { projectId: existing.id }, + payload: { externalId: input.externalId }, + }); + return { id: existing.id }; + } + + const [created] = await db + .insert(project) + .values({ + eventId: input.eventId, + externalId: input.externalId, + name: input.name, + teamName: input.teamName, + tableNumber: input.tableNumber, + }) + .returning({ id: project.id }); + if (!created) throw new Error("Project was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "project.upserted", + actor: { email: input.actorEmail }, + subject: { projectId: created.id }, + payload: { externalId: input.externalId }, + }); + return { id: created.id }; +} + +export async function withdrawProject( + db: PanelDb, + input: { eventId: string; projectId: string; now: Date; actorEmail: string }, +) { + const phase = await phaseOf(db, input.eventId); + if (!phaseAllows(phase, "submit") && phase !== "judging_live" && phase !== "submissions_closed") { + throw new Error(`submit is not allowed while the event is ${phase}`); + } + await db + .update(project) + .set({ withdrawnAt: input.now }) + .where(and(eq(project.id, input.projectId), eq(project.eventId, input.eventId))); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "project.withdrawn", + actor: { email: input.actorEmail }, + subject: { projectId: input.projectId }, + payload: {}, + }); +} + +export async function upsertJudge( + db: PanelDb, + input: { + eventId: string; + email: string; + name: string; + externalId: string | null; + actorEmail: string; + }, +) { + const phase = await phaseOf(db, input.eventId); + if (!phaseAllows(phase, "apply") && phase !== "judging_live") { + throw new Error(`apply is not allowed while the event is ${phase}`); + } + const email = input.email.toLowerCase(); + const [existing] = await db + .select({ id: judge.id }) + .from(judge) + .where(and(eq(judge.eventId, input.eventId), eq(judge.email, email))); + if (existing) { + await db + .update(judge) + .set({ name: input.name, externalId: input.externalId }) + .where(eq(judge.id, existing.id)); + return { id: existing.id }; + } + const [created] = await db + .insert(judge) + .values({ + eventId: input.eventId, + email, + name: input.name, + externalId: input.externalId, + status: "invited", + }) + .returning({ id: judge.id }); + if (!created) throw new Error("Judge was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "judge.upserted", + actor: { email: input.actorEmail }, + subject: { judgeId: created.id }, + payload: {}, + }); + return { id: created.id }; +} + +export async function applyAsJudge( + db: PanelDb, + input: { eventId: string; email: string; name: string }, +) { + const phase = await phaseOf(db, input.eventId); + if (!phaseAllows(phase, "apply")) { + throw new Error(`apply is not allowed while the event is ${phase}`); + } + const email = input.email.toLowerCase(); + const [existing] = await db + .select({ id: judge.id, status: judge.status }) + .from(judge) + .where(and(eq(judge.eventId, input.eventId), eq(judge.email, email))); + if (existing) { + if (existing.status === "invited") { + await db.update(judge).set({ status: "applied", name: input.name }).where(eq(judge.id, existing.id)); + } + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "judge.upserted", + actor: { email }, + subject: { judgeId: existing.id }, + payload: { status: existing.status === "invited" ? "applied" : existing.status }, + }); + return { id: existing.id, status: existing.status === "invited" ? "applied" : existing.status }; + } + const [created] = await db + .insert(judge) + .values({ + eventId: input.eventId, + email, + name: input.name, + status: "applied", + }) + .returning({ id: judge.id }); + if (!created) throw new Error("Judge was not stored"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "judge.upserted", + actor: { email }, + subject: { judgeId: created.id }, + payload: { status: "applied" }, + }); + return { id: created.id, status: "applied" as const }; +} + +export async function setJudgeStatus( + db: PanelDb, + input: { + eventId: string; + judgeId: string; + status: "approved" | "suspended"; + actorEmail: string; + }, +) { + await db + .update(judge) + .set({ status: input.status }) + .where(and(eq(judge.id, input.judgeId), eq(judge.eventId, input.eventId))); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: input.status === "approved" ? "judge.approved" : "judge.suspended", + actor: { email: input.actorEmail }, + subject: { judgeId: input.judgeId }, + payload: { status: input.status }, + }); +} + +export async function voidVisit( + db: PanelDb, + input: { eventId: string; visitId: string; now: Date; actorEmail: string; reason: string }, +) { + return db.transaction(async (tx) => { + const [row] = await tx + .select({ id: visit.id, judgeId: visit.judgeId }) + .from(visit) + .where(and(eq(visit.id, input.visitId), eq(visit.eventId, input.eventId), isNull(visit.voidedAt))); + if (!row) throw new Error("Visit not found"); + await tx + .update(visit) + .set({ voidedAt: input.now, voidReason: input.reason }) + .where(eq(visit.id, row.id)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "visit.voided", + actor: { email: input.actorEmail }, + subject: { visitId: row.id }, + payload: { reason: input.reason }, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "visit.voided", + payload: { visitId: row.id, judgeId: row.judgeId }, + }); + return { judgeId: row.judgeId }; + }); +} + +export async function recallJudge( + db: PanelDb, + input: { eventId: string; judgeId: string; actorEmail: string }, +) { + await db.transaction(async (tx) => { + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "judge.recalled", + actor: { email: input.actorEmail }, + subject: { judgeId: input.judgeId }, + payload: {}, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "judge.recalled", + payload: { judgeId: input.judgeId }, + }); + }); +} + +export async function assignJudge( + db: PanelDb, + input: { + eventId: string; + judgeId: string; + trackId: string | null; + zoneId: string | null; + actorEmail: string; + }, +) { + const [row] = await db + .update(judge) + .set({ trackId: input.trackId, zoneId: input.zoneId }) + .where(and(eq(judge.id, input.judgeId), eq(judge.eventId, input.eventId))) + .returning({ id: judge.id }); + if (!row) throw new Error("Judge not found"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "judge.upserted", + actor: { email: input.actorEmail }, + subject: { judgeId: input.judgeId }, + payload: { trackId: input.trackId, zoneId: input.zoneId }, + }); + return { id: row.id }; +} + +export async function assignProjectTrack( + db: PanelDb, + input: { projectId: string; trackId: string }, +) { + await db + .insert(projectTrack) + .values(input) + .onConflictDoNothing(); +} + +export async function rotateQr( + db: PanelDb, + input: { eventId: string; projectId: string; actorEmail: string }, +) { + const token = randomUUID(); + const [row] = await db + .update(project) + .set({ qrToken: token }) + .where(and(eq(project.id, input.projectId), eq(project.eventId, input.eventId))) + .returning({ qrToken: project.qrToken }); + if (!row) throw new Error("Project not found"); + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "project.qr_rotated", + actor: { email: input.actorEmail }, + subject: { projectId: input.projectId }, + payload: {}, + }); + return { qrToken: row.qrToken }; +} + +export async function assignEventTables( + db: PanelDb, + input: { eventId: string; actorEmail: string }, +) { + const projects = await db + .select({ id: project.id }) + .from(project) + .where(and(eq(project.eventId, input.eventId), isNull(project.withdrawnAt))); + const links = await db + .select({ projectId: projectTrack.projectId, trackId: projectTrack.trackId }) + .from(projectTrack) + .innerJoin(project, eq(project.id, projectTrack.projectId)) + .where(eq(project.eventId, input.eventId)); + const trackOf = new Map(); + for (const link of links) { + if (!trackOf.has(link.projectId)) trackOf.set(link.projectId, link.trackId); + } + const tables = await db + .select({ number: floorTable.number, zoneId: floorTable.zoneId }) + .from(floorTable) + .where(eq(floorTable.eventId, input.eventId)); + const seated = assignTables( + projects.map((row) => ({ id: row.id, trackId: trackOf.get(row.id) ?? null })), + tables.map((row) => ({ number: row.number, zoneId: row.zoneId })), + ); + for (const seat of seated) { + await db + .update(project) + .set({ tableNumber: seat.tableNumber, zoneId: seat.zoneId }) + .where(eq(project.id, seat.projectId)); + } + await db.insert(eventLog).values({ + eventId: input.eventId, + kind: "project.table_assigned", + actor: { email: input.actorEmail }, + subject: { eventId: input.eventId }, + payload: { assigned: seated.length }, + }); + return { assigned: seated.length }; +} diff --git a/judging/server/src/services/redis.integration.test.ts b/judging/server/src/services/redis.integration.test.ts new file mode 100644 index 00000000..561545b7 --- /dev/null +++ b/judging/server/src/services/redis.integration.test.ts @@ -0,0 +1,41 @@ +import { Redis } from "ioredis"; +import { describe, expect, it } from "vitest"; +import { createRedisBus } from "../bus"; + +const url = process.env.REDIS_URL; + +async function redisAnswers(address: string): Promise { + const client = new Redis(address, { + maxRetriesPerRequest: 0, + connectTimeout: 500, + lazyConnect: true, + retryStrategy: () => null, + }); + client.on("error", () => undefined); + try { + await client.connect(); + return (await client.ping()) === "PONG"; + } catch { + return false; + } finally { + client.disconnect(); + } +} + +describe.skipIf(!url)("redis bus", () => { + it("delivers a message from one connection to another", async (ctx) => { + if (!(await redisAnswers(url as string))) ctx.skip(); + const left = createRedisBus(url as string); + const right = createRedisBus(url as string); + const seen = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("no redis message")), 4000); + right.subscribe("event:test:board", (message) => { + clearTimeout(timer); + resolve(message); + }); + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + left.publish("event:test:board", { kind: "vote.cast" }); + await expect(seen).resolves.toEqual({ kind: "vote.cast" }); + }); +}); diff --git a/judging/server/src/services/results.test.ts b/judging/server/src/services/results.test.ts new file mode 100644 index 00000000..552c88d4 --- /dev/null +++ b/judging/server/src/services/results.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { diffPlacements } from "./results"; + +const quiet = { rubricComponent: 8, pairwiseComponent: 0 }; + +describe("diffPlacements", () => { + it("reports a place move with both blend components", () => { + const left = [ + { projectId: "a", trackId: "t", placement: 1, score: 9, ...quiet }, + { projectId: "b", trackId: "t", placement: 2, score: 8, ...quiet }, + ]; + const right = [ + { projectId: "a", trackId: "t", placement: 2, score: 7, rubricComponent: 7, pairwiseComponent: 1 }, + { projectId: "b", trackId: "t", placement: 1, score: 9, ...quiet }, + ]; + expect(diffPlacements(left, right)).toEqual([ + { + projectId: "a", + trackId: "t", + from: 1, + to: 2, + score: 7, + rubricFrom: 8, + rubricTo: 7, + pairwiseFrom: 0, + pairwiseTo: 1, + }, + { + projectId: "b", + trackId: "t", + from: 2, + to: 1, + score: 9, + rubricFrom: 8, + rubricTo: 8, + pairwiseFrom: 0, + pairwiseTo: 0, + }, + ]); + }); + + it("reports a pairwise change when the place stays put", () => { + const left = [{ projectId: "a", trackId: "t", placement: 1, score: 8, ...quiet }]; + const right = [ + { projectId: "a", trackId: "t", placement: 1, score: 8.4, rubricComponent: 8, pairwiseComponent: 2 }, + ]; + expect(diffPlacements(left, right)).toEqual([ + { + projectId: "a", + trackId: "t", + from: 1, + to: 1, + score: 8.4, + rubricFrom: 8, + rubricTo: 8, + pairwiseFrom: 0, + pairwiseTo: 2, + }, + ]); + }); +}); diff --git a/judging/server/src/services/results.ts b/judging/server/src/services/results.ts new file mode 100644 index 00000000..486c5d32 --- /dev/null +++ b/judging/server/src/services/results.ts @@ -0,0 +1,328 @@ +import { and, desc, eq, isNotNull, isNull, sql } from "drizzle-orm"; +import { phaseAllows, rank } from "@panel/core"; +import type { PanelDb } from "@panel/db"; +import { + comparison, + event, + eventConfig, + eventLog, + judge, + outbox, + project, + projectTrack, + result, + resultRun, + track, + vote, +} from "@panel/db"; + +export type Placement = { + projectId: string; + trackId: string; + placement: number | null; + score: number; + rubricComponent: number; + pairwiseComponent: number; +}; + +/** Projects whose place or blend components changed between two runs. */ +export function diffPlacements(left: readonly Placement[], right: readonly Placement[]) { + const later = new Map(right.map((row) => [`${row.trackId}:${row.projectId}`, row])); + return left.flatMap((row) => { + const other = later.get(`${row.trackId}:${row.projectId}`); + const to = other?.placement ?? null; + const score = other?.score ?? row.score; + const rubricTo = other?.rubricComponent ?? row.rubricComponent; + const pairwiseTo = other?.pairwiseComponent ?? row.pairwiseComponent; + if ( + row.placement === to && + row.score === score && + row.rubricComponent === rubricTo && + row.pairwiseComponent === pairwiseTo + ) { + return []; + } + return [ + { + projectId: row.projectId, + trackId: row.trackId, + from: row.placement, + to, + score, + rubricFrom: row.rubricComponent, + rubricTo, + pairwiseFrom: row.pairwiseComponent, + pairwiseTo, + }, + ]; + }); +} + +export async function computeResults( + db: PanelDb, + input: { eventId: string; actorEmail: string; now: Date }, +) { + return db.transaction(async (tx) => { + const [evt] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + const [config] = await tx + .select() + .from(eventConfig) + .where(eq(eventConfig.eventId, input.eventId)); + if (!evt || !config) throw new Error("Event not found"); + if (!phaseAllows(evt.phase, "compute")) { + throw new Error(`compute is not allowed while the event is ${evt.phase}`); + } + + const projects = await tx + .select() + .from(project) + .where(and(eq(project.eventId, input.eventId), isNull(project.withdrawnAt))); + const links = await tx + .select({ projectId: projectTrack.projectId, trackId: projectTrack.trackId }) + .from(projectTrack) + .innerJoin(project, eq(project.id, projectTrack.projectId)) + .where(eq(project.eventId, input.eventId)); + const tracks = await tx + .select({ id: track.id, judgeGroup: track.judgeGroup }) + .from(track) + .where(eq(track.eventId, input.eventId)); + const judges = await tx + .select({ id: judge.id, trackId: judge.trackId }) + .from(judge) + .where(eq(judge.eventId, input.eventId)); + const groupOf = new Map( + judges.map((row) => [ + row.id, + tracks.find((item) => item.id === row.trackId)?.judgeGroup ?? "main", + ]), + ); + const votes = await tx + .select() + .from(vote) + .where(eq(vote.eventId, input.eventId)); + const comparisons = await tx + .select() + .from(comparison) + .where(eq(comparison.eventId, input.eventId)); + + const byProject = new Map(); + for (const link of links) { + const list = byProject.get(link.projectId) ?? []; + list.push(link.trackId); + byProject.set(link.projectId, list); + } + + const ranked = rank( + projects.map((row) => ({ + id: row.id, + trackIds: byProject.get(row.id) ?? [], + })), + votes.map((row) => ({ + judgeId: row.judgeId, + projectId: row.projectId, + judgeGroup: groupOf.get(row.judgeId) ?? "main", + total: row.total, + isCalibration: row.isCalibration, + })), + comparisons.map((row) => ({ + judgeId: row.judgeId, + judgeGroup: row.judgeGroup, + a: row.aProjectId, + b: row.bProjectId, + outcome: row.outcome, + })), + { + bayesianC: config.bayesianC, + pairwiseWeight: config.pairwiseEnabled ? config.pairwiseWeight : 0, + calibrationWeight: config.calibrationWeight, + }, + ); + + const [run] = await tx + .insert(resultRun) + .values({ + eventId: input.eventId, + computedAt: input.now, + configSnapshot: { + bayesianC: config.bayesianC, + pairwiseWeight: config.pairwiseWeight, + pairwiseEnabled: config.pairwiseEnabled, + calibrationWeight: config.calibrationWeight, + }, + }) + .returning({ id: resultRun.id }); + if (!run) throw new Error("Result run was not stored"); + + const placed = ranked.filter((row) => row.placement !== null); + if (placed.length > 0) { + await tx.insert(result).values( + placed.map((row) => ({ + runId: run.id, + projectId: row.projectId, + trackId: row.trackId, + placement: row.placement, + score: row.score, + rubricComponent: row.rubricComponent, + pairwiseComponent: row.pairwiseComponent, + voteCount: row.voteCount, + comparisonCount: row.comparisonCount, + flags: [], + })), + ); + } + + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "results.computed", + actor: { email: input.actorEmail }, + subject: { runId: run.id }, + payload: { rows: placed.length }, + }); + + return { runId: run.id, rows: placed.length }; + }); +} + +export async function publishResults( + db: PanelDb, + input: { eventId: string; runId: string; actorEmail: string; now: Date }, +) { + return db.transaction(async (tx) => { + const [evt] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + if (!phaseAllows(evt.phase, "publish")) { + throw new Error(`publish is not allowed while the event is ${evt.phase}`); + } + const [run] = await tx + .select({ id: resultRun.id }) + .from(resultRun) + .where(and(eq(resultRun.id, input.runId), eq(resultRun.eventId, input.eventId))); + if (!run) throw new Error("Result run not found"); + + await tx + .update(resultRun) + .set({ publishedAt: input.now }) + .where(eq(resultRun.id, run.id)); + await tx.update(event).set({ phase: "published" }).where(eq(event.id, input.eventId)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "results.published", + actor: { email: input.actorEmail }, + subject: { runId: run.id }, + payload: { from: evt.phase, to: "published" }, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "results.published", + payload: { runId: run.id }, + }); + await tx.execute(sql` + update project + set feedback_token = gen_random_uuid() + where event_id = ${input.eventId} and feedback_token is null + `); + return { runId: run.id }; + }); +} + +export async function unpublishResults( + db: PanelDb, + input: { eventId: string; actorEmail: string }, +) { + return db.transaction(async (tx) => { + const [evt] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + if (!evt) throw new Error("Event not found"); + if (evt.phase !== "published") { + throw new Error(`unpublish is not allowed while the event is ${evt.phase}`); + } + const [run] = await tx + .select({ id: resultRun.id }) + .from(resultRun) + .where(and(eq(resultRun.eventId, input.eventId), isNotNull(resultRun.publishedAt))) + .orderBy(desc(resultRun.publishedAt)) + .limit(1); + if (!run) throw new Error("Nothing is published"); + await tx.update(resultRun).set({ publishedAt: null }).where(eq(resultRun.id, run.id)); + await tx.update(event).set({ phase: "judging_closed" }).where(eq(event.id, input.eventId)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "results.unpublished", + actor: { email: input.actorEmail }, + subject: { runId: run.id }, + payload: { from: "published", to: "judging_closed" }, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "results.unpublished", + payload: { runId: run.id }, + }); + return { runId: run.id }; + }); +} + +export async function placementsForRun(db: PanelDb, runId: string): Promise { + const rows = await db + .select({ + projectId: result.projectId, + trackId: result.trackId, + placement: result.placement, + score: result.score, + rubricComponent: result.rubricComponent, + pairwiseComponent: result.pairwiseComponent, + }) + .from(result) + .where(eq(result.runId, runId)) + .orderBy(desc(result.score)); + return rows; +} + +export async function inspectRun(db: PanelDb, runId: string) { + return db + .select({ + projectId: result.projectId, + projectName: project.name, + trackId: result.trackId, + placement: result.placement, + score: result.score, + rubricComponent: result.rubricComponent, + pairwiseComponent: result.pairwiseComponent, + voteCount: result.voteCount, + comparisonCount: result.comparisonCount, + }) + .from(result) + .innerJoin(project, eq(project.id, result.projectId)) + .where(eq(result.runId, runId)) + .orderBy(desc(result.score)); +} + +export async function publishedPlacements(db: PanelDb, eventId: string) { + const [evt] = await db + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, eventId)); + if (!evt) return null; + if (evt.phase !== "published") return { published: false as const, rows: [] }; + const rows = await db + .select({ + externalId: project.externalId, + placement: result.placement, + score: result.score, + track: track.slug, + voteCount: result.voteCount, + }) + .from(result) + .innerJoin(resultRun, eq(resultRun.id, result.runId)) + .innerJoin(project, eq(project.id, result.projectId)) + .innerJoin(track, eq(track.id, result.trackId)) + .where(and(eq(resultRun.eventId, eventId), isNotNull(resultRun.publishedAt))); + return { published: true as const, rows }; +} diff --git a/judging/server/src/services/vote.test.ts b/judging/server/src/services/vote.test.ts new file mode 100644 index 00000000..739f5334 --- /dev/null +++ b/judging/server/src/services/vote.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_TIMERS } from "@panel/core"; +import { assessVote } from "./vote"; + +const criteria = [{ id: "creativity", min: 0, max: 10, weight: 1 }]; +const arrived = new Date("2027-02-28T13:00:00Z"); +const at = (seconds: number) => new Date(arrived.getTime() + seconds * 1000); + +describe("assessVote", () => { + it("refuses a vote outside judging_live", () => { + const decision = assessVote({ + phase: "setup", + arrivedAt: arrived, + now: at(10), + config: DEFAULT_TIMERS, + criteria, + scores: [{ criterionId: "creativity", value: 5 }], + }); + expect(decision.ok).toBe(false); + if (!decision.ok) expect(decision.message).toMatch(/setup/); + }); + + it("names the configured hard limit when the score is late", () => { + const config = { ...DEFAULT_TIMERS, hardLimitSeconds: 90, submitGraceSeconds: 0 }; + const decision = assessVote({ + phase: "judging_live", + arrivedAt: arrived, + now: at(91), + config, + criteria, + scores: [{ criterionId: "creativity", value: 5 }], + }); + expect(decision.ok).toBe(false); + if (!decision.ok) expect(decision.message).toContain("90s"); + }); + + it("accepts a score inside the window and derives the total", () => { + const decision = assessVote({ + phase: "judging_live", + arrivedAt: arrived, + now: at(10), + config: DEFAULT_TIMERS, + criteria, + scores: [{ criterionId: "creativity", value: 8 }], + }); + expect(decision).toEqual({ ok: true, total: 8 }); + }); +}); diff --git a/judging/server/src/services/vote.ts b/judging/server/src/services/vote.ts new file mode 100644 index 00000000..3d0dfd89 --- /dev/null +++ b/judging/server/src/services/vote.ts @@ -0,0 +1,175 @@ +import { and, eq } from "drizzle-orm"; +import { isPastCutoff, phaseAllows, validateScores, weightedTotal } from "@panel/core"; +import type { Criterion, Phase, ScoreInput, TimerConfig } from "@panel/core"; +import type { PanelDb } from "@panel/db"; +import { + criterion, + event, + eventConfig, + eventLog, + outbox, + rubric, + track, + visit, + vote, + voteScore, +} from "@panel/db"; + +export type VoteDecision = + | { ok: true; total: number } + | { ok: false; message: string }; + +/** + * Whether this score can be stored. The cutoff message names the configured + * hard limit so the phone can show the number the event actually used. + */ +export function assessVote(input: { + phase: Phase; + arrivedAt: Date | null; + now: Date; + config: TimerConfig; + criteria: readonly Criterion[]; + scores: readonly ScoreInput[]; +}): VoteDecision { + if (!phaseAllows(input.phase, "vote")) { + return { + ok: false, + message: `vote is not allowed while the event is ${input.phase}`, + }; + } + if (!input.arrivedAt) { + return { ok: false, message: "Arrive at the table before scoring" }; + } + if (isPastCutoff(input.arrivedAt, input.now, input.config)) { + return { + ok: false, + message: `Vote is past the ${input.config.hardLimitSeconds}s hard limit`, + }; + } + const check = validateScores(input.criteria, input.scores); + if (!check.ok) return { ok: false, message: check.reason }; + return { ok: true, total: weightedTotal(input.criteria, input.scores) }; +} + +export async function castVote( + db: PanelDb, + input: { + eventId: string; + judgeId: string; + visitId: string; + scores: readonly ScoreInput[]; + comment: string | null; + now: Date; + }, +): Promise<{ total: number }> { + return db.transaction(async (tx) => { + const [evt] = await tx + .select({ phase: event.phase }) + .from(event) + .where(eq(event.id, input.eventId)); + const [config] = await tx + .select() + .from(eventConfig) + .where(eq(eventConfig.eventId, input.eventId)); + const [row] = await tx + .select() + .from(visit) + .where(and(eq(visit.id, input.visitId), eq(visit.judgeId, input.judgeId))); + if (!evt || !config || !row) throw new Error("Visit not found"); + if (row.eventId !== input.eventId) throw new Error("Visit not found"); + if (row.voidedAt || row.completedAt) throw new Error("This visit is closed"); + + const criteria = await loadCriteria(tx, input.eventId, row.judgeGroup); + const decision = assessVote({ + phase: evt.phase, + arrivedAt: row.arrivedAt, + now: input.now, + config: { + targetSeconds: config.targetSeconds, + hardLimitSeconds: config.hardLimitSeconds, + walkLimitSeconds: config.walkLimitSeconds, + submitGraceSeconds: config.submitGraceSeconds, + }, + criteria, + scores: input.scores, + }); + if (!decision.ok) throw new Error(decision.message); + + const duration = + row.arrivedAt === null + ? null + : Math.round((input.now.getTime() - row.arrivedAt.getTime()) / 1000); + + const [stored] = await tx + .insert(vote) + .values({ + visitId: row.id, + judgeId: input.judgeId, + projectId: row.projectId, + eventId: input.eventId, + total: decision.total, + comment: input.comment, + durationSeconds: duration, + isCalibration: false, + }) + .returning({ id: vote.id }); + if (!stored) throw new Error("Vote was not stored"); + + if (input.scores.length > 0) { + await tx.insert(voteScore).values( + input.scores.map((score) => ({ + voteId: stored.id, + criterionId: score.criterionId, + value: score.value, + })), + ); + } + await tx + .update(visit) + .set({ completedAt: input.now }) + .where(eq(visit.id, row.id)); + await tx.insert(eventLog).values({ + eventId: input.eventId, + kind: "vote.cast", + actor: { judgeId: input.judgeId }, + subject: { visitId: row.id, projectId: row.projectId }, + payload: { total: decision.total }, + }); + await tx.insert(outbox).values({ + eventId: input.eventId, + topic: "vote.cast", + payload: { visitId: row.id, judgeId: input.judgeId, total: decision.total }, + }); + return { total: decision.total }; + }); +} + +async function loadCriteria( + tx: Parameters[0]>[0], + eventId: string, + judgeGroup: string, +): Promise { + const [groupTrack] = await tx + .select({ rubricId: track.rubricId }) + .from(track) + .where(and(eq(track.eventId, eventId), eq(track.judgeGroup, judgeGroup))); + const rubricId = groupTrack?.rubricId + ? groupTrack.rubricId + : ( + await tx + .select({ id: rubric.id }) + .from(rubric) + .where(and(eq(rubric.eventId, eventId), eq(rubric.isDefault, true))) + )[0]?.id; + if (!rubricId) return []; + const rows = await tx + .select() + .from(criterion) + .where(eq(criterion.rubricId, rubricId)); + return rows.map((row) => ({ + id: row.id, + min: row.min, + max: row.max, + weight: row.weight, + })); +} diff --git a/judging/server/tsconfig.json b/judging/server/tsconfig.json new file mode 100644 index 00000000..f54bde2c --- /dev/null +++ b/judging/server/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tooling/typescript/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/judging/server/vitest.config.ts b/judging/server/vitest.config.ts new file mode 100644 index 00000000..f62398d7 --- /dev/null +++ b/judging/server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + fileParallelism: false, + }, +}); diff --git a/judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx b/judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx new file mode 100644 index 00000000..1e88cc3d --- /dev/null +++ b/judging/web/app/e/[orgSlug]/[eventSlug]/board.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { apiBase } from "../../../../lib/api"; + +type Snapshot = { + eventId: string; + phase: string; + pollSeconds: number; + projects: { id: string; tableNumber: number | null; looks: number }[]; + placements: { projectId: string; projectName?: string; placement: number | null }[]; + prizes: { title: string; place: number; track: string; amount: number | null }[]; +}; + +export function LiveBoard({ orgSlug, eventSlug }: { orgSlug: string; eventSlug: string }) { + const [snapshot, setSnapshot] = useState(null); + const [live, setLive] = useState(false); + + useEffect(() => { + let stop = false; + let timer: ReturnType | undefined; + let socket: WebSocket | undefined; + + const pull = async () => { + const response = await fetch(`${apiBase}/v1/live/${orgSlug}/${eventSlug}`); + if (!response.ok || stop) return; + const next = (await response.json()) as Snapshot; + setSnapshot(next); + if (!socket) { + socket = new WebSocket( + `${apiBase.replace(/^http/, "ws")}/ws?channel=event:${next.eventId}:board`, + ); + socket.onopen = () => setLive(true); + socket.onclose = () => { + setLive(false); + socket = undefined; + }; + socket.onmessage = () => { + void pull(); + }; + } + clearTimeout(timer); + timer = setTimeout(() => void pull(), (next.pollSeconds || 5) * 1000); + }; + + void pull(); + return () => { + stop = true; + clearTimeout(timer); + socket?.close(); + }; + }, [orgSlug, eventSlug]); + + if (!snapshot) return

Loading the floor.

; + return ( +
+

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

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

Prizes

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

{card.name}

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

{comment}

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

{event.orgName}

+

{event.name}

+

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

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

{event.orgName}

+

{event.name}

+

{event.branding.tagline}

+

Phase: {event.phase}

+ + {!token ? ( +
{ + submitEvent.preventDefault(); + try { + if (!code) { + const issued = (await call("/v1/auth/magic-link", { email })) as { code?: string }; + setCode(issued.code ?? ""); + setMessage(issued.code ? "Code filled in for this demo." : "Check your email."); + return; + } + const verified = (await call("/v1/auth/verify", { email, code, org: orgSlug })) as { + token: string; + }; + setToken(verified.token); + const rows = (await fetch( + `${apiBase}/v1/session/rubric?eventId=${event.eventId}`, + { headers: { authorization: `Bearer ${verified.token}` } }, + ).then((response) => response.json())) as CriterionRow[]; + setCriteria(rows); + await loadProgress(verified.token); + const sent = await flushHeld((path, payload) => + fetch(`${apiBase}${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${verified.token}`, + }, + body: JSON.stringify(payload), + }).then((response) => { + if (!response.ok) throw new Error("still offline"); + }), + ); + setMessage(sent > 0 ? `Sent ${sent} score${sent === 1 ? "" : "s"} held on this phone.` : ""); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Sign-in failed"); + } + }} + > + + + +
+ ) : ( +
+ + + {progress ?

{progress}

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

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

+ {elapsed ?

{elapsed}

: null} +
+ )} + {message ?

{message}

: null} +
+ ); +} + +function readValue(event: { target: EventTarget | null }) { + const target = event.target; + if (target && "value" in target) return String(target.value); + return ""; +} + +const field: CSSProperties = { + display: "block", + width: "100%", + margin: "0.25rem 0 0.75rem", + padding: "0.75rem", + fontSize: "1.1rem", +}; + +const button: CSSProperties = { + display: "block", + width: "100%", + margin: "0.5rem 0", + padding: "0.9rem", + color: "white", + background: "#1c1917", + border: 0, + fontSize: "1rem", +}; diff --git a/judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx b/judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx new file mode 100644 index 00000000..f1db4caa --- /dev/null +++ b/judging/web/app/j/[orgSlug]/[eventSlug]/page.tsx @@ -0,0 +1,16 @@ +import { JudgeDesk } from "./desk"; +import { loadEvent } from "../../../../lib/api"; + +export default async function JudgePage({ + params, + searchParams, +}: { + params: Promise<{ orgSlug: string; eventSlug: string }>; + searchParams: Promise<{ ticket?: string }>; +}) { + const { orgSlug, eventSlug } = await params; + const { ticket } = await searchParams; + const event = await loadEvent(orgSlug, eventSlug); + if (!event) return
This event is not available.
; + return ; +} diff --git a/judging/web/app/layout.tsx b/judging/web/app/layout.tsx new file mode 100644 index 00000000..0737340c --- /dev/null +++ b/judging/web/app/layout.tsx @@ -0,0 +1,30 @@ +import type { Metadata, Viewport } from "next"; + +export const metadata: Metadata = { + title: "Panel", + description: "Hackathon judging", + manifest: "/manifest.webmanifest", +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + themeColor: "#111111", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx b/judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx new file mode 100644 index 00000000..cda7ce18 --- /dev/null +++ b/judging/web/app/o/[orgSlug]/[eventSlug]/console.tsx @@ -0,0 +1,408 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { apiBase } from "../../../../lib/api"; +import type { PublicEvent } from "../../../../lib/api"; +import { Editors } from "./editors"; + +const phases = [ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", +] as const; + +export function Console({ event, ticket }: { event: PublicEvent; ticket?: string }) { + const [token, setToken] = useState(ticket ?? ""); + const [message, setMessage] = useState(event.phase); + const [floor, setFloor] = useState<{ + tables: { tableNumber: number | null; looks: number }[]; + judges: { name: string; state: string; overtime: boolean; tableNumber: number | null }[]; + } | null>(null); + const [cards, setCards] = useState< + { id: string; name: string; tableNumber: number | null; zoneName: string | null }[] + >([]); + const [logs, setLogs] = useState<{ id: string; kind: string }[]>([]); + const [runId, setRunId] = useState(""); + const [inspected, setInspected] = useState< + { + projectId: string; + projectName: string; + trackId: string; + placement: number | null; + score: number; + rubricComponent: number; + pairwiseComponent: number; + }[] + >([]); + const [trackName, setTrackName] = useState(""); + const [judgeId, setJudgeId] = useState(""); + + async function post(path: string, body: unknown) { + const response = await fetch(`${apiBase}/trpc/${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + const payload = (await response.json()) as { + error?: { message?: string }; + result?: { data?: { runId?: string; assigned?: number } }; + }; + if (!response.ok || payload.error) { + throw new Error(payload.error?.message ?? "Request failed"); + } + return payload.result?.data; + } + + useEffect(() => { + if (!token) return; + let stop = false; + const pull = async () => { + const response = await fetch(`${apiBase}/v1/floor/${event.eventId}`, { + headers: { authorization: `Bearer ${token}` }, + }); + if (!response.ok || stop) return; + setFloor( + (await response.json()) as { + tables: { tableNumber: number | null; looks: number }[]; + judges: { name: string; state: string; overtime: boolean; tableNumber: number | null }[]; + }, + ); + }; + void pull(); + const timer = setInterval(() => void pull(), 5000); + return () => { + stop = true; + clearInterval(timer); + }; + }, [token, event.eventId]); + + return ( +
+

{event.name}

+

Current phase: {message}

+ {floor ? ( +
+

Floor

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

{zoneLabel}

: null} +

{card.tableNumber}

+

{card.name}

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

Rubric

+ + + + +

Zone and floor

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

Prize

+ + + + +

Judges

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

Withdraw a project

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

Panel

+

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

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

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

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

{event.orgName}

-

{event.name}

-

{event.branding.tagline}

-

Phase: {event.phase}

- - {!token ? ( -
{ - submitEvent.preventDefault(); - try { - if (!code) { - const issued = (await call("/v1/auth/magic-link", { email })) as { code?: string }; - setCode(issued.code ?? ""); - setMessage(issued.code ? "Code filled in for this demo." : "Check your email."); - return; - } - const verified = (await call("/v1/auth/verify", { email, code, org: orgSlug })) as { - token: string; - }; - setToken(verified.token); - const rows = (await fetch( - `${apiBase}/v1/session/rubric?eventId=${event.eventId}`, - { headers: { authorization: `Bearer ${verified.token}` } }, - ).then((response) => response.json())) as CriterionRow[]; - setCriteria(rows); - await loadProgress(verified.token); - const sent = await flushHeld((path, payload) => - fetch(`${apiBase}${path}`, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${verified.token}`, - }, - body: JSON.stringify(payload), - }).then((response) => { - if (!response.ok) throw new Error("still offline"); - }), - ); - setMessage(sent > 0 ? `Sent ${sent} score${sent === 1 ? "" : "s"} held on this phone.` : ""); - } catch (error) { - setMessage(error instanceof Error ? error.message : "Sign-in failed"); - } - }} - > - - - -
- ) : ( -
- - - {progress ?

{progress}

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

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

- {elapsed ?

{elapsed}

: null} -
- )} - {message ?

{message}

: null} -
- ); -} - -function readValue(event: { target: EventTarget | null }) { - const target = event.target; - if (target && "value" in target) return String(target.value); - return ""; -} - -const field: CSSProperties = { - display: "block", - width: "100%", - margin: "0.25rem 0 0.75rem", - padding: "0.75rem", - fontSize: "1.1rem", -}; - -const button: CSSProperties = { - display: "block", - width: "100%", - margin: "0.5rem 0", - padding: "0.9rem", - color: "white", - background: "#1c1917", - border: 0, - fontSize: "1rem", -}; diff --git a/sites/judging/app/j/[orgSlug]/[eventSlug]/page.tsx b/sites/judging/app/j/[orgSlug]/[eventSlug]/page.tsx deleted file mode 100644 index f1db4caa..00000000 --- a/sites/judging/app/j/[orgSlug]/[eventSlug]/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { JudgeDesk } from "./desk"; -import { loadEvent } from "../../../../lib/api"; - -export default async function JudgePage({ - params, - searchParams, -}: { - params: Promise<{ orgSlug: string; eventSlug: string }>; - searchParams: Promise<{ ticket?: string }>; -}) { - const { orgSlug, eventSlug } = await params; - const { ticket } = await searchParams; - const event = await loadEvent(orgSlug, eventSlug); - if (!event) return
This event is not available.
; - return ; -} diff --git a/sites/judging/app/layout.tsx b/sites/judging/app/layout.tsx deleted file mode 100644 index 0737340c..00000000 --- a/sites/judging/app/layout.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Metadata, Viewport } from "next"; - -export const metadata: Metadata = { - title: "Panel", - description: "Hackathon judging", - manifest: "/manifest.webmanifest", -}; - -export const viewport: Viewport = { - width: "device-width", - initialScale: 1, - themeColor: "#111111", -}; - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - - ); -} diff --git a/sites/judging/app/o/[orgSlug]/[eventSlug]/page.tsx b/sites/judging/app/o/[orgSlug]/[eventSlug]/page.tsx deleted file mode 100644 index df8e1ec7..00000000 --- a/sites/judging/app/o/[orgSlug]/[eventSlug]/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Console } from "./console"; -import { loadEvent } from "../../../../lib/api"; - -export default async function OrganizerPage({ - params, - searchParams, -}: { - params: Promise<{ orgSlug: string; eventSlug: string }>; - searchParams: Promise<{ ticket?: string }>; -}) { - const { orgSlug, eventSlug } = await params; - const { ticket } = await searchParams; - const event = await loadEvent(orgSlug, eventSlug); - if (!event) return
This event is not available.
; - return ; -} diff --git a/sites/judging/app/page.tsx b/sites/judging/app/page.tsx deleted file mode 100644 index 66db153a..00000000 --- a/sites/judging/app/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export default function HomePage() { - return ( -
-

Panel

-

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

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

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

Floor

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

{progress}

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

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

+ {elapsed ?

{elapsed}

: null} +
+ ); +} + +function readValue(event: { target: EventTarget | null }) { + const target = event.target; + if (target && "value" in target) return String(target.value); + return ""; +} + +const field: CSSProperties = { + display: "block", + width: "100%", + margin: "0.25rem 0 0.75rem", + padding: "0.75rem", + fontSize: "1.1rem", +}; + +const button: CSSProperties = { + display: "block", + width: "100%", + margin: "0.5rem 0", + padding: "0.9rem", + color: "white", + background: "#1c1917", + border: 0, + fontSize: "1rem", +}; diff --git a/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/page.tsx b/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/page.tsx new file mode 100644 index 00000000..7d9dc292 --- /dev/null +++ b/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/page.tsx @@ -0,0 +1,24 @@ +import { redirect } from "next/navigation"; +import { portalActor } from "@query/api/panel"; +import { JudgeDesk } from "./desk"; +import { loadEvent } from "@/lib/panel.server"; + +export default async function JudgePage({ + params, +}: { + params: Promise<{ orgSlug: string; eventSlug: string }>; +}) { + const { orgSlug, eventSlug } = await params; + const actor = await portalActor(); + if (!actor) { + redirect( + `/login?callbackUrl=${encodeURIComponent(`/judge/panel/${orgSlug}/${eventSlug}`)}`, + ); + } + const event = await loadEvent(orgSlug, eventSlug); + if (!event) + return ( +
This event is not available.
+ ); + return ; +} diff --git a/sites/judging/app/e/[orgSlug]/[eventSlug]/board.tsx b/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx similarity index 64% rename from sites/judging/app/e/[orgSlug]/[eventSlug]/board.tsx rename to sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx index 1e88cc3d..d6f7256f 100644 --- a/sites/judging/app/e/[orgSlug]/[eventSlug]/board.tsx +++ b/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx @@ -1,44 +1,46 @@ "use client"; import { useEffect, useState } from "react"; -import { apiBase } from "../../../../lib/api"; +import { apiBase } from "@/lib/panel"; type Snapshot = { eventId: string; phase: string; pollSeconds: number; projects: { id: string; tableNumber: number | null; looks: number }[]; - placements: { projectId: string; projectName?: string; placement: number | null }[]; - prizes: { title: string; place: number; track: string; amount: number | null }[]; + placements: { + projectId: string; + projectName?: string; + placement: number | null; + }[]; + prizes: { + title: string; + place: number; + track: string; + amount: number | null; + }[]; }; -export function LiveBoard({ orgSlug, eventSlug }: { orgSlug: string; eventSlug: string }) { +export function LiveBoard({ + orgSlug, + eventSlug, +}: { + orgSlug: string; + eventSlug: string; +}) { const [snapshot, setSnapshot] = useState(null); - const [live, setLive] = useState(false); useEffect(() => { let stop = false; let timer: ReturnType | undefined; - let socket: WebSocket | undefined; const pull = async () => { - const response = await fetch(`${apiBase}/v1/live/${orgSlug}/${eventSlug}`); + const response = await fetch( + `${apiBase}/v1/live/${orgSlug}/${eventSlug}`, + ); if (!response.ok || stop) return; const next = (await response.json()) as Snapshot; setSnapshot(next); - if (!socket) { - socket = new WebSocket( - `${apiBase.replace(/^http/, "ws")}/ws?channel=event:${next.eventId}:board`, - ); - socket.onopen = () => setLive(true); - socket.onclose = () => { - setLive(false); - socket = undefined; - }; - socket.onmessage = () => { - void pull(); - }; - } clearTimeout(timer); timer = setTimeout(() => void pull(), (next.pollSeconds || 5) * 1000); }; @@ -47,16 +49,13 @@ export function LiveBoard({ orgSlug, eventSlug }: { orgSlug: string; eventSlug: return () => { stop = true; clearTimeout(timer); - socket?.close(); }; }, [orgSlug, eventSlug]); if (!snapshot) return

Loading the floor.

; return (
-

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

+

{snapshot.phase}

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

Prizes

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

{event.orgName}

{event.name}

-

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

+

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

); diff --git a/sites/judging/lib/held.ts b/sites/mainweb/lib/panel-held.ts similarity index 100% rename from sites/judging/lib/held.ts rename to sites/mainweb/lib/panel-held.ts diff --git a/sites/mainweb/lib/panel.server.ts b/sites/mainweb/lib/panel.server.ts new file mode 100644 index 00000000..00fe571f --- /dev/null +++ b/sites/mainweb/lib/panel.server.ts @@ -0,0 +1,16 @@ +import { panel } from "@query/api/panel"; +import type { PublicEvent } from "./panel"; + +/** Calls the judging API in process, the same routes the browser reaches at /api/panel. */ +export async function panelRequest(path: string): Promise { + return panel().app.request(path); +} + +export async function loadEvent( + orgSlug: string, + eventSlug: string, +): Promise { + const response = await panelRequest(`/v1/public/${orgSlug}/${eventSlug}`); + if (!response.ok) return null; + return response.json() as Promise; +} diff --git a/sites/mainweb/lib/panel.ts b/sites/mainweb/lib/panel.ts new file mode 100644 index 00000000..79095225 --- /dev/null +++ b/sites/mainweb/lib/panel.ts @@ -0,0 +1,10 @@ +/** Judging runs inside this app; its API is mounted here. */ +export const apiBase = "/api/panel"; + +export type PublicEvent = { + eventId: string; + name: string; + phase: string; + branding: { tagline?: string; colors?: { accent?: string } }; + orgName: string; +}; diff --git a/sites/mainweb/next.config.mjs b/sites/mainweb/next.config.mjs index c329b835..7dd94ff7 100644 --- a/sites/mainweb/next.config.mjs +++ b/sites/mainweb/next.config.mjs @@ -58,7 +58,15 @@ const cspHeaderName = const nextConfig = { output: "standalone", reactCompiler: true, - transpilePackages: ["@query/api", "@query/auth", "@query/db", "@query/ui"], + transpilePackages: [ + "@query/api", + "@query/auth", + "@query/db", + "@query/judging-core", + "@query/judging-db", + "@query/judging-server", + "@query/ui", + ], outputFileTracingRoot: path.join(__dirname, "../../"), // Native/dynamic requires that a bundler mangles. Left external so the // standalone output loads them from node_modules at runtime. diff --git a/turbo.json b/turbo.json index 0ac93324..2e5c92e6 100644 --- a/turbo.json +++ b/turbo.json @@ -54,8 +54,6 @@ }, "globalEnv": [ "PORT", - "PORT_A", - "PORT_B", "DATABASE_URL", "NEXTAUTH_SECRET", "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY", @@ -99,14 +97,9 @@ "JUDGING_TEST_DATABASE_URL", "PANEL_DATABASE_URL", "PANEL_JWT_SECRET", - "PANEL_JWKS_URL", - "PANEL_DEV_AUTH", "PANEL_SMTP_URL", "PANEL_SMTP_FROM", - "PANEL_URL", - "PANEL_SERVICE_TOKEN", "PANEL_EVENT_ID", - "PANEL_WEB_URL", "PANEL_ORG_SLUG", "PANEL_EVENT_SLUG", "REDIS_URL" From 95446da8806b6d8f5b08dcc1585065615ca7f650 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 8 Oct 2026 15:58:54 -0400 Subject: [PATCH 13/82] perf(panel): keep judging inside the Neon free plan Neon suspends after five idle minutes and the plan has 100 compute hours a month, so anything that polls keeps the database billing. The public board and the organizer floor now poll only during judging_live and only while the tab is visible; the board gets a refresh button otherwise. /api/panel/readyz (a Postgres round trip) and /metrics are no longer public. Judging now borrows the club's connection pool, which is tuned to let Neon scale to zero, instead of opening a second pool to the same database. On the 0.5 GB side, a drained outbox row is deleted rather than kept, since event_log already holds the history. The judge desk no longer holds a score the server refused (past the hard limit, a voided visit). It used to keep it on the phone and resend the same refusal every time the page opened; only outages are held now. --- docs/judging/README.md | 16 +++++--- packages/api/src/services/panel.ts | 20 ++++------ packages/judging-db/src/client.ts | 10 +++-- packages/judging-db/src/index.ts | 2 +- .../judging-server/src/services/outbox.ts | 13 +++--- .../panel/[orgSlug]/[eventSlug]/console.tsx | 19 +++++++-- .../app/(portal)/api/panel/[...path]/route.ts | 7 +++- .../panel/[orgSlug]/[eventSlug]/desk.tsx | 39 +++++++++++++----- .../judging/[orgSlug]/[eventSlug]/board.tsx | 40 +++++++++++++++++-- 9 files changed, 120 insertions(+), 46 deletions(-) diff --git a/docs/judging/README.md b/docs/judging/README.md index 5043f260..66ce228e 100644 --- a/docs/judging/README.md +++ b/docs/judging/README.md @@ -34,9 +34,13 @@ are pulled back into `hackathon_result`. Set `PANEL_EVENT_ID`, `PANEL_ORG_SLUG`, and `PANEL_EVENT_SLUG` to the judging event an edition uses. Without them the portal shows no panel links. -Live views poll. There is no WebSocket in the portal process; the board uses -the event's `board_poll_seconds` and the console polls the floor every five -seconds. +Live views poll, and only while they can change. The board polls at the +event's `board_poll_seconds` and the console polls the floor every five +seconds, both only during `judging_live` and only while the tab is visible. +Neon suspends after five idle minutes and the free plan has 100 compute hours +a month, so a forgotten board in any other phase must not keep it awake. +`/api/panel/readyz` and `/api/panel/metrics` are not exposed for the same +reason. Judging uses the club's connection pool rather than a second one. ## Checks @@ -56,5 +60,7 @@ pnpm --filter @query/judging-cli panel seed demo pnpm --filter @query/judging-cli panel doctor ``` -Webhooks queue in `outbox`. Drain them with `panel outbox drain`, on a -schedule if an event uses webhooks. +Webhooks queue in `outbox`. Drain them with `panel outbox drain`; delivered +rows are deleted, since `event_log` keeps the history. Run it after each event +even without webhooks, so the queue does not sit in the 0.5 GB database. A +Hacklytics-size event is on the order of 20 MB across the judging tables. diff --git a/packages/api/src/services/panel.ts b/packages/api/src/services/panel.ts index cfc23202..3735f6ff 100644 --- a/packages/api/src/services/panel.ts +++ b/packages/api/src/services/panel.ts @@ -1,7 +1,7 @@ import { and, eq } from "drizzle-orm"; -import { admins, hackathons, judges } from "@query/db"; +import { admins, db as clubDb, hackathons, judges } from "@query/db"; import type { DrizzleDB } from "@query/db"; -import { createDb } from "@query/judging-db"; +import { createDbFromPool } from "@query/judging-db"; import type { PanelDb } from "@query/judging-db"; import { InMemoryBus, @@ -28,19 +28,15 @@ const globalForPanel = globalThis as unknown as { panel?: Embedded }; /** * Judging runs inside the portal: same process, same Postgres, and the portal - * sign-in decides who the caller is. One instance per process, so the metrics - * registry and the pool are not rebuilt on every request. + * sign-in decides who the caller is. It borrows the club's pool rather than + * opening its own: the club pool is tuned to let Neon scale to zero, and a + * second pool would hold a second set of connections to the same database. + * One instance per process, so the metrics registry is not rebuilt per request. */ export function panel(): Embedded { if (globalForPanel.panel) return globalForPanel.panel; - const url = process.env.DATABASE_URL; - if (!url) throw new Error("DATABASE_URL is not set"); - const { db } = createDb(url, { - ssl: - process.env.NODE_ENV === "production" - ? { rejectUnauthorized: true } - : undefined, - }); + if (!clubDb) throw new Error("DATABASE_URL is not set"); + const { db } = createDbFromPool(clubDb.$client); const metrics = createMetrics(); const bus = new InMemoryBus(); const app = createApp({ diff --git a/packages/judging-db/src/client.ts b/packages/judging-db/src/client.ts index a658b8c9..abb74e26 100644 --- a/packages/judging-db/src/client.ts +++ b/packages/judging-db/src/client.ts @@ -1,10 +1,14 @@ import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; -import type { PoolConfig } from "pg"; import * as schema from "./schema"; -export function createDb(url: string, options: PoolConfig = {}) { - const pool = new Pool({ connectionString: url, max: 10, ...options }); +export function createDb(url: string) { + const pool = new Pool({ connectionString: url, max: 10 }); + return createDbFromPool(pool); +} + +/** Judging tables on a pool the host already owns, so one app keeps one set of connections. */ +export function createDbFromPool(pool: Pool) { pool.on("error", () => { // The pool drops a dead client on its own. An unhandled "error" event // would take the process down. diff --git a/packages/judging-db/src/index.ts b/packages/judging-db/src/index.ts index 4514dd03..93619fb3 100644 --- a/packages/judging-db/src/index.ts +++ b/packages/judging-db/src/index.ts @@ -1,4 +1,4 @@ -export { createDb } from "./client"; +export { createDb, createDbFromPool } from "./client"; export type { PanelDb, PanelTx } from "./client"; export { databaseUrl, requireDatabaseUrl } from "./env"; export { appliedMigrations, migrate, migrationFiles } from "./migrate"; diff --git a/packages/judging-server/src/services/outbox.ts b/packages/judging-server/src/services/outbox.ts index 4f6ca441..1c43acc6 100644 --- a/packages/judging-server/src/services/outbox.ts +++ b/packages/judging-server/src/services/outbox.ts @@ -3,8 +3,12 @@ import { eq, isNull } from "drizzle-orm"; import type { PanelDb } from "@query/judging-db"; import { outbox, webhook } from "@query/judging-db"; -/** Delivers pending outbox rows. A topic with no active webhook is delivered. */ -export async function drainOutbox(db: PanelDb, now = new Date()): Promise { +/** + * Delivers pending outbox rows. A topic with no active webhook is delivered. + * A delivered row is deleted: event_log already keeps the history, and on a + * 0.5 GB database a second copy of every vote is not worth keeping. + */ +export async function drainOutbox(db: PanelDb): Promise { const pending = await db .select() .from(outbox) @@ -42,10 +46,7 @@ export async function drainOutbox(db: PanelDb, now = new Date()): Promise { + // The floor only moves while judging is live, and only matters on screen. + // Anything else would keep the database awake (see the public board). + if (phase !== "judging_live") return; let stop = false; + let timer: ReturnType | undefined; const pull = async () => { const response = await fetch(`${apiBase}/v1/floor/${event.eventId}`); if (!response.ok || stop) return; @@ -83,13 +88,20 @@ export function Console({ event }: { event: PublicEvent }) { }, ); }; - void pull(); - const timer = setInterval(() => void pull(), 5000); + const sync = () => { + clearInterval(timer); + if (document.visibilityState !== "visible") return; + void pull(); + timer = setInterval(() => void pull(), 5000); + }; + sync(); + document.addEventListener("visibilitychange", sync); return () => { stop = true; clearInterval(timer); + document.removeEventListener("visibilitychange", sync); }; - }, [event.eventId]); + }, [phase, event.eventId]); return (
@@ -140,6 +152,7 @@ export function Console({ event }: { event: PublicEvent }) { onClick={async () => { try { await post("event.setPhase", { eventId: event.eventId, phase }); + setPhase(phase); setMessage(phase); } catch (error) { setMessage( diff --git a/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts b/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts index 1c26a148..982485c6 100644 --- a/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts +++ b/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts @@ -10,8 +10,11 @@ const PREFIX = "/api/panel"; async function handler(req: Request) { const url = new URL(req.url); url.pathname = url.pathname.slice(PREFIX.length) || "/"; - // Process metrics are not public; the portal's own /api/metrics is the scrape target. - if (url.pathname === "/metrics") return new Response(null, { status: 404 }); + // Not public: process metrics, and a readiness probe that queries Postgres. + // An uptime checker on /readyz would keep Neon awake around the clock. + if (url.pathname === "/metrics" || url.pathname === "/readyz") { + return new Response(null, { status: 404 }); + } const body = req.method === "GET" || req.method === "HEAD" ? undefined diff --git a/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx b/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx index 9cc4d1ce..838bf5e2 100644 --- a/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx +++ b/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx @@ -74,8 +74,14 @@ export function JudgeDesk({ headers: { "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined, }); - const payload = (await response.json()) as { message?: string }; - if (!response.ok) throw new Error(payload.message ?? "Request failed"); + const payload = (await response.json().catch(() => ({}))) as { + message?: string; + }; + if (!response.ok) { + throw Object.assign(new Error(payload.message ?? "Request failed"), { + status: response.status, + }); + } return payload; } @@ -102,15 +108,19 @@ export function JudgeDesk({ headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }).then((response) => { - if (!response.ok) throw new Error("still offline"); + // A refusal (past the hard limit, a voided visit) is final; only an + // outage keeps the score on the phone for another try. + if (response.status >= 500) throw new Error("still offline"); }), - ).then((sent) => { - if (sent > 0) { - setMessage( - `Sent ${sent} score${sent === 1 ? "" : "s"} held on this phone.`, - ); - } - }); + ) + .then((sent) => { + if (sent > 0) { + setMessage( + `Sent ${sent} score${sent === 1 ? "" : "s"} held on this phone.`, + ); + } + }) + .catch(() => undefined); }; send(); window.addEventListener("online", send); @@ -372,6 +382,15 @@ export function JudgeDesk({ setMessage("Score stored."); setVisit(null); } catch (error) { + const status = (error as { status?: number }).status; + if (status !== undefined && status < 500) { + // The server refused this score. Holding it would resend + // the same refusal every time the page opens. + setMessage( + error instanceof Error ? error.message : "Score refused", + ); + return; + } await holdVote(body); setMessage( error instanceof Error diff --git a/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx b/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx index d6f7256f..c94909b5 100644 --- a/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx +++ b/sites/mainweb/app/judging/[orgSlug]/[eventSlug]/board.tsx @@ -30,32 +30,64 @@ export function LiveBoard({ }) { const [snapshot, setSnapshot] = useState(null); + const [reload, setReload] = useState(0); + useEffect(() => { let stop = false; let timer: ReturnType | undefined; const pull = async () => { + clearTimeout(timer); const response = await fetch( `${apiBase}/v1/live/${orgSlug}/${eventSlug}`, ); if (!response.ok || stop) return; const next = (await response.json()) as Snapshot; setSnapshot(next); - clearTimeout(timer); - timer = setTimeout(() => void pull(), (next.pollSeconds || 5) * 1000); + // Only live judging changes minute to minute. Polling any other phase, + // or a tab nobody is looking at, keeps the database awake for nothing: + // Neon suspends only after five idle minutes, and those minutes are the + // monthly compute allowance. + if ( + next.phase === "judging_live" && + document.visibilityState === "visible" + ) { + timer = setTimeout( + () => void pull(), + Math.max(next.pollSeconds || 5, 5) * 1000, + ); + } + }; + const onVisible = () => { + if (document.visibilityState === "visible") void pull(); }; void pull(); + document.addEventListener("visibilitychange", onVisible); return () => { stop = true; clearTimeout(timer); + document.removeEventListener("visibilitychange", onVisible); }; - }, [orgSlug, eventSlug]); + }, [orgSlug, eventSlug, reload]); if (!snapshot) return

Loading the floor.

; return (
-

{snapshot.phase}

+

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

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

Prizes

From 24a43fdb37bfe9214f3282341145550f76eee22b Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 8 Oct 2026 16:08:03 -0400 Subject: [PATCH 14/82] feat(panel): tell a judge about a recall or a voided visit The desk lost these when the WebSocket hub went away. It now asks /v1/session/status every 15 seconds, but only while a visit is open and the screen is on. Judging is live whenever a visit is open, so Neon is awake anyway, and a phone in a pocket sends nothing. A recall does not void the visit, and dispatch can hand the same visit back, so the status carries the time of the latest recall and the desk acts once per recall. The lookup reads only log rows written since the hand-out, on the existing (event_id, created_at) index. --- docs/judging/README.md | 4 +- packages/judging-server/src/app.ts | 6 +++ packages/judging-server/src/router.ts | 16 ++++++- .../judging-server/src/services/dispatch.ts | 44 ++++++++++++++++- .../services/visit-status.integration.test.ts | 41 ++++++++++++++++ .../panel/[orgSlug]/[eventSlug]/desk.tsx | 47 +++++++++++++++++++ 6 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 packages/judging-server/src/services/visit-status.integration.test.ts diff --git a/docs/judging/README.md b/docs/judging/README.md index 66ce228e..ec5bf6e2 100644 --- a/docs/judging/README.md +++ b/docs/judging/README.md @@ -36,7 +36,9 @@ event an edition uses. Without them the portal shows no panel links. Live views poll, and only while they can change. The board polls at the event's `board_poll_seconds` and the console polls the floor every five -seconds, both only during `judging_live` and only while the tab is visible. +seconds, both only during `judging_live` and only while the tab is visible. The judge +desk asks `/v1/session/status` every 15 seconds while a visit is open and the +screen is on, which is how a recall or a voided visit reaches the phone. Neon suspends after five idle minutes and the free plan has 100 compute hours a month, so a forgotten board in any other phase must not keep it awake. `/api/panel/readyz` and `/api/panel/metrics` are not exposed for the same diff --git a/packages/judging-server/src/app.ts b/packages/judging-server/src/app.ts index e6718151..b72251ca 100644 --- a/packages/judging-server/src/app.ts +++ b/packages/judging-server/src/app.ts @@ -41,6 +41,7 @@ const openApi = { "/v1/session/vote": { post: { summary: "Store the score for the open visit." } }, "/v1/session/skip": { post: { summary: "Pass on the open table without a score." } }, "/v1/session/progress": { get: { summary: "How many projects this judge has scored." } }, + "/v1/session/status": { get: { summary: "Whether the open visit was voided or the judge recalled." } }, "/v1/session/compare": { post: { summary: "Record which of the last two tables was better." } }, "/v1/session/rubric": { get: { summary: "Criteria for the event's default rubric." } }, "/v1/catalog/projects": { get: { summary: "Projects for table cards. Organizer." } }, @@ -291,6 +292,11 @@ export function createApp(options: { .parse(await c.req.json()); return rest((caller) => caller.session.skip(body))(c); }); + app.get("/v1/session/status", async (c) => { + const eventId = z.string().uuid().parse(c.req.query("eventId")); + const visitId = z.string().uuid().parse(c.req.query("visitId")); + return rest((caller) => caller.session.status({ eventId, visitId }))(c); + }); app.get("/v1/session/progress", async (c) => { const eventId = z.string().uuid().parse(c.req.query("eventId")); return rest((caller) => caller.session.progress({ eventId }))(c); diff --git a/packages/judging-server/src/router.ts b/packages/judging-server/src/router.ts index 6e3d7e9e..f450fe9b 100644 --- a/packages/judging-server/src/router.ts +++ b/packages/judging-server/src/router.ts @@ -10,7 +10,7 @@ import type { Bus } from "./bus"; import type { Metrics } from "./metrics"; import { log } from "./log"; import { arrive } from "./services/arrive"; -import { dispatchNext, judgeProgress, skipVisit } from "./services/dispatch"; +import { dispatchNext, judgeProgress, skipVisit, visitStatus } from "./services/dispatch"; import { assignJudge, applyAsJudge, @@ -281,6 +281,20 @@ export const appRouter = t.router({ asTrpc(error); } }), + status: authed + .input(z.object({ eventId: uuid, visitId: uuid })) + .query(async ({ ctx, input }) => { + const row = await requireJudge(ctx, input.eventId); + try { + return await visitStatus(ctx.db, { + eventId: input.eventId, + judgeId: row.id, + visitId: input.visitId, + }); + } catch (error) { + asTrpc(error); + } + }), }), event: t.router({ setPhase: atLeast("organizer") diff --git a/packages/judging-server/src/services/dispatch.ts b/packages/judging-server/src/services/dispatch.ts index 0b6655c4..97d21031 100644 --- a/packages/judging-server/src/services/dispatch.ts +++ b/packages/judging-server/src/services/dispatch.ts @@ -1,5 +1,5 @@ import { randomInt } from "node:crypto"; -import { and, eq, isNull, sql } from "drizzle-orm"; +import { and, desc, eq, gte, isNull, sql } from "drizzle-orm"; import { isLive, pickNext } from "@query/judging-core"; import type { TimerConfig } from "@query/judging-core"; import type { PanelDb, PanelTx } from "@query/judging-db"; @@ -391,6 +391,48 @@ export async function skipVisit( }); } +/** + * Whether the judge's open visit still stands: an organizer may have voided + * it, or called the judge back to the desk since it was handed out. The + * recall lookup reads only log rows written since the hand-out + * (event_log_event_idx), so a phone can ask while it walks. + */ +export async function visitStatus( + db: PanelDb, + input: { eventId: string; judgeId: string; visitId: string }, +) { + const [row] = await db + .select({ handedOutAt: visit.handedOutAt, voidedAt: visit.voidedAt }) + .from(visit) + .where( + and( + eq(visit.id, input.visitId), + eq(visit.eventId, input.eventId), + eq(visit.judgeId, input.judgeId), + ), + ); + if (!row) throw new Error("Visit not found"); + const [recall] = await db + .select({ at: eventLog.createdAt }) + .from(eventLog) + .where( + and( + eq(eventLog.eventId, input.eventId), + gte(eventLog.createdAt, row.handedOutAt), + eq(eventLog.kind, "judge.recalled"), + sql`${eventLog.subject}->>'judgeId' = ${input.judgeId}`, + ), + ) + .orderBy(desc(eventLog.createdAt)) + .limit(1); + // A recall does not void the visit, so the same visit can come back from + // dispatch. The time lets the phone act once per recall, not once per poll. + return { + voided: row.voidedAt !== null, + recalledAt: recall ? recall.at.toISOString() : null, + }; +} + export async function judgeProgress( db: PanelDb, input: { eventId: string; judgeId: string }, diff --git a/packages/judging-server/src/services/visit-status.integration.test.ts b/packages/judging-server/src/services/visit-status.integration.test.ts new file mode 100644 index 00000000..6aaf3ea7 --- /dev/null +++ b/packages/judging-server/src/services/visit-status.integration.test.ts @@ -0,0 +1,41 @@ +import { eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { createDb, databaseUrl, judge, migrate, seedDemo } from "@query/judging-db"; +import { dispatchNext, visitStatus } from "./dispatch"; +import { recallJudge, voidVisit } from "./records"; + +const url = databaseUrl(); + +describe.skipIf(!url)("visit status", () => { + it("reports a recall since the hand-out, then a void", async () => { + const { db, pool } = createDb(url as string); + try { + await migrate(pool); + const { eventId } = await seedDemo(db); + const [person] = await db.select().from(judge).where(eq(judge.eventId, eventId)); + if (!person) throw new Error("demo judge missing"); + + const handed = await dispatchNext(db, { eventId, judgeId: person.id, now: new Date() }); + if (handed.done) throw new Error("demo floor is empty"); + const ask = () => visitStatus(db, { eventId, judgeId: person.id, visitId: handed.visitId }); + + expect(await ask()).toEqual({ voided: false, recalledAt: null }); + + await recallJudge(db, { eventId, judgeId: person.id, actorEmail: "organizer@example.com" }); + const recalled = await ask(); + expect(recalled.voided).toBe(false); + expect(recalled.recalledAt).not.toBeNull(); + + await voidVisit(db, { + eventId, + visitId: handed.visitId, + now: new Date(), + actorEmail: "organizer@example.com", + reason: "test", + }); + expect((await ask()).voided).toBe(true); + } finally { + await pool.end(); + } + }); +}); diff --git a/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx b/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx index 838bf5e2..b748cefc 100644 --- a/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx +++ b/sites/mainweb/app/(portal)/judge/panel/[orgSlug]/[eventSlug]/desk.tsx @@ -127,6 +127,53 @@ export function JudgeDesk({ return () => window.removeEventListener("online", send); }, []); + const openVisitId = visit && !visit.done ? visit.visitId : undefined; + const acknowledgedRecall = useRef(null); + useEffect(() => { + // Recall and void are the only things an organizer sends a judge. Ask only + // while a visit is open and the screen is on: judging is live then, so the + // database is awake anyway, and a phone in a pocket costs nothing. + if (!openVisitId) return; + let stop = false; + let timer: ReturnType | undefined; + const check = async () => { + const response = await fetch( + `${apiBase}/v1/session/status?eventId=${event.eventId}&visitId=${openVisitId}`, + ); + if (!response.ok || stop) return; + const status = (await response.json()) as { + voided: boolean; + recalledAt: string | null; + }; + if (status.voided) { + setVisit(null); + setMessage("This visit was voided."); + return; + } + if ( + status.recalledAt && + status.recalledAt !== acknowledgedRecall.current + ) { + acknowledgedRecall.current = status.recalledAt; + setVisit(null); + setMessage("Come back to the desk."); + } + }; + const sync = () => { + clearInterval(timer); + if (document.visibilityState !== "visible") return; + void check(); + timer = setInterval(() => void check(), 15_000); + }; + sync(); + document.addEventListener("visibilitychange", sync); + return () => { + stop = true; + clearInterval(timer); + document.removeEventListener("visibilitychange", sync); + }; + }, [openVisitId, event.eventId]); + const walkingSince = visit?.handedOutAt && !visit.arrivedAt && !visit.done ? new Date(visit.handedOutAt).getTime() From bba035402e07d9dd631e215c8459e7464f08a43b Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 8 Oct 2026 16:42:56 -0400 Subject: [PATCH 15/82] feat(panel): judge each hackathon edition from the portal Judging is per hackathon. Staff switch an edition to panel on /admin/judging; that creates its judging event from the hackathon row (organization hacklytics, slug = hackathon id) with default timers and the club's five criteria, then copies in submitted projects and the portal's judges. The PANEL_EVENT_ID / PANEL_ORG_SLUG / PANEL_EVENT_SLUG settings are gone, and the portal routes are keyed by hackathon id. Synced judges were stored as invited, which the desk refuses, so no portal judge could ever score. Approval in the portal now approves them in judging and deactivating suspends them. Deploys run drizzle-kit push against the shared database. With the judging tables there, push stopped at a drop prompt with no TTY, exited 0, and skipped every club schema change; db:check would then fail this branch on the missing judging_backend column. The drizzle config now hides the judging tables (read from the judging migrations) and declares the judging enums. Publishing results drains the outbox, so it no longer needs a manual step, and panel doctor treats PANEL_JWT_SECRET as optional. --- docs/judging/README.md | 34 +++---- docs/judging/deploy.md | 10 ++- packages/api/src/routers/judge/admin.ts | 11 ++- packages/api/src/routers/judge/portal.ts | 15 +++- packages/api/src/services/panel-sync.ts | 88 ++++++++++++++----- packages/api/src/services/panel.ts | 16 +++- packages/db/drizzle.config.ts | 24 ++++- packages/judging-cli/src/main.ts | 6 +- packages/judging-db/src/enums.ts | 39 ++++++++ packages/judging-db/src/schema.ts | 51 ++++------- packages/judging-server/src/index.ts | 2 + packages/judging-server/src/router.ts | 8 ++ .../src/services/event.integration.test.ts | 38 ++++++++ packages/judging-server/src/services/event.ts | 78 ++++++++++++++++ .../app/(portal)/admin/judging/page.tsx | 36 +++++++- .../[eventSlug] => [hackathonId]}/console.tsx | 0 .../[eventSlug] => [hackathonId]}/editors.tsx | 0 .../[eventSlug] => [hackathonId]}/page.tsx | 6 +- .../[eventSlug] => [hackathonId]}/desk.tsx | 0 .../[eventSlug] => [hackathonId]}/page.tsx | 8 +- .../[eventSlug] => [hackathonId]}/board.tsx | 0 .../feedback/[token]/page.tsx | 0 .../[eventSlug] => [hackathonId]}/page.tsx | 10 +-- sites/mainweb/lib/panel.server.ts | 12 ++- turbo.json | 3 - 25 files changed, 386 insertions(+), 109 deletions(-) create mode 100644 packages/judging-db/src/enums.ts create mode 100644 packages/judging-server/src/services/event.integration.test.ts create mode 100644 packages/judging-server/src/services/event.ts rename sites/mainweb/app/(portal)/admin/judging/panel/{[orgSlug]/[eventSlug] => [hackathonId]}/console.tsx (100%) rename sites/mainweb/app/(portal)/admin/judging/panel/{[orgSlug]/[eventSlug] => [hackathonId]}/editors.tsx (100%) rename sites/mainweb/app/(portal)/admin/judging/panel/{[orgSlug]/[eventSlug] => [hackathonId]}/page.tsx (65%) rename sites/mainweb/app/(portal)/judge/panel/{[orgSlug]/[eventSlug] => [hackathonId]}/desk.tsx (100%) rename sites/mainweb/app/(portal)/judge/panel/{[orgSlug]/[eventSlug] => [hackathonId]}/page.tsx (66%) rename sites/mainweb/app/judging/{[orgSlug]/[eventSlug] => [hackathonId]}/board.tsx (100%) rename sites/mainweb/app/judging/{[orgSlug]/[eventSlug] => [hackathonId]}/feedback/[token]/page.tsx (100%) rename sites/mainweb/app/judging/{[orgSlug]/[eventSlug] => [hackathonId]}/page.tsx (65%) diff --git a/docs/judging/README.md b/docs/judging/README.md index ec5bf6e2..823e34c5 100644 --- a/docs/judging/README.md +++ b/docs/judging/README.md @@ -16,23 +16,27 @@ club Postgres, and the portal sign-in decides who is judging or organizing. ## In the portal +Judging is per hackathon edition. On `/admin/judging`, staff pick the edition +and press "Switch to panel judging". That creates the edition's judging event +(organization `hacklytics`, event slug = the hackathon id) in `setup`, with +default timers and the club's five criteria out of ten, then copies in the +submitted projects and the portal's judges. Switching back to classic leaves +the event in place. + | Route | Who | What | | --- | --- | --- | | `/api/panel/*` | the pages below | The judging API. Same routes as `docs/judging/api.md`, without the prefix. | -| `/judge/panel/[org]/[event]` | approved judges | Judge desk: next table, QR or table number, scores, pairwise, offline hold. | -| `/admin/judging/panel/[org]/[event]` | staff | Organizer console: phases, floor, tables, rubric, tracks, judges, results. | -| `/judging/[org]/[event]` | anyone | Public board while judging, placements after publish. | -| `/judging/[org]/[event]/feedback/[token]` | a team | That team's feedback card after publish. | +| `/judge/panel/[hackathonId]` | approved judges | Judge desk: next table, QR or table number, scores, pairwise, offline hold. | +| `/admin/judging/panel/[hackathonId]` | staff | Organizer console: phases, floor, tables, rubric, tracks, judges, results. | +| `/judging/[hackathonId]` | anyone | Public board while judging, placements after publish. | +| `/judging/[hackathonId]/feedback/[token]` | a team | That team's feedback card after publish. | Roles come from the `admins` row: `super_admin` is owner, other staff are admin, a volunteer row is volunteer, a bug tester has none. A judge is matched -by the email on their portal judge row for an edition whose -`judging_backend` is `panel`. Projects and judges are copied in process when -an organizer promotes submissions or approves a judge, and published results -are pulled back into `hackathon_result`. - -Set `PANEL_EVENT_ID`, `PANEL_ORG_SLUG`, and `PANEL_EVENT_SLUG` to the judging -event an edition uses. Without them the portal shows no panel links. +by the email on their portal judge row. Approving a judge in the portal +approves them in judging, and deactivating suspends them. Promoting +submissions copies projects in, and pulling results writes published +placements back into `hackathon_result`. Live views poll, and only while they can change. The board polls at the event's `board_poll_seconds` and the console polls the floor every five @@ -62,7 +66,7 @@ pnpm --filter @query/judging-cli panel seed demo pnpm --filter @query/judging-cli panel doctor ``` -Webhooks queue in `outbox`. Drain them with `panel outbox drain`; delivered -rows are deleted, since `event_log` keeps the history. Run it after each event -even without webhooks, so the queue does not sit in the 0.5 GB database. A -Hacklytics-size event is on the order of 20 MB across the judging tables. +Webhooks queue in `outbox`. Publishing results drains it; delivered rows are +deleted, since `event_log` keeps the history. A delivery that failed stays +queued for `panel outbox drain`. A Hacklytics-size event is on the order of +20 MB across the judging tables. diff --git a/docs/judging/deploy.md b/docs/judging/deploy.md index bbd89485..f13a850c 100644 --- a/docs/judging/deploy.md +++ b/docs/judging/deploy.md @@ -11,9 +11,13 @@ pnpm --filter @query/judging-cli panel migrate pnpm --filter @query/judging-cli panel doctor ``` -Then set `PANEL_EVENT_ID`, `PANEL_ORG_SLUG`, and `PANEL_EVENT_SLUG` in the -mainweb environment (`apphosting.yaml`) and switch the edition's -`judging_backend` to `panel`. +Then switch the edition to panel on `/admin/judging`. Nothing goes in the +environment: the event is created from the hackathon row. + +`packages/db/drizzle.config.ts` hides the judging tables from the deploy's +`drizzle-kit push` and declares the judging enum types +(`packages/judging-db/src/enums.ts`), so push neither tries to drop them nor +stalls on a prompt. `PANEL_JWT_SECRET` is only needed for callers outside the portal that send a bearer token; API keys from `catalog.issueApiKey` work without it. diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index c03e4ffc..c0b65d45 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -470,12 +470,11 @@ export const judgeAdminRouter = createTRPCRouter({ } } - if (input.isActive) { - await syncJudgeToPanel(ctx.db as DrizzleDB, { - hackathonId: updated.hackathonId, - email: updated.email, - }); - } + await syncJudgeToPanel(ctx.db as DrizzleDB, { + hackathonId: updated.hackathonId, + email: updated.email, + isActive: input.isActive, + }); return { success: true, isActive: input.isActive, queuedProjects }; }), diff --git a/packages/api/src/routers/judge/portal.ts b/packages/api/src/routers/judge/portal.ts index 2b2b8aa2..fabca918 100644 --- a/packages/api/src/routers/judge/portal.ts +++ b/packages/api/src/routers/judge/portal.ts @@ -21,7 +21,7 @@ import { lockDispatch, } from "./dispatch"; import type { DrizzleDB } from "@query/db"; -import { panelConsoleUrl, panelDeskUrl } from "../../services/panel-sync"; +import { panelConsoleUrl, panelDeskUrl, setJudgingBackend } from "../../services/panel-sync"; export const judgePortalRouter = createTRPCRouter({ @@ -81,6 +81,19 @@ export const judgePortalRouter = createTRPCRouter({ return panelConsoleUrl(ctx.db as DrizzleDB, input.hackathonId); }), + /** Moves an edition between classic judging and panel. Panel gets its event and a first sync. */ + setJudgingBackend: isAdmin + .input( + z.object({ + hackathonId: z.string().uuid(), + backend: z.enum(["legacy", "panel"]), + }), + ) + .mutation(async ({ ctx, input }) => { + await setJudgingBackend(ctx.db as DrizzleDB, input.hackathonId, input.backend); + return panelConsoleUrl(ctx.db as DrizzleDB, input.hackathonId); + }), + getMyAssignments: protectedProcedure.query(async ({ ctx }) => { const db = ctx.db as DrizzleDB; diff --git a/packages/api/src/services/panel-sync.ts b/packages/api/src/services/panel-sync.ts index d88fdc1e..99ed01ff 100644 --- a/packages/api/src/services/panel-sync.ts +++ b/packages/api/src/services/panel-sync.ts @@ -1,8 +1,9 @@ import { and, eq, inArray, sql } from "drizzle-orm"; import { hackathonProjects, hackathonResults, hackathons, judges, judgingProjects } from "@query/db"; import type { DrizzleDB } from "@query/db"; +import { judge as panelJudge } from "@query/judging-db"; import { publishedPlacements } from "@query/judging-server"; -import { panel, panelAsPortal } from "./panel"; +import { panel, panelAsPortal, panelEventFor } from "./panel"; /** * Copies an edition into judging when that edition has opted in. @@ -40,34 +41,78 @@ export async function syncProjectsToPanel(db: DrizzleDB, hackathonId: string) { } } +/** + * Mirrors a portal judge into the edition's judging event. Approval in the + * portal is approval in judging; deactivating suspends them there. + */ export async function syncJudgeToPanel( db: DrizzleDB, - judge: { hackathonId: string; email: string | null }, + judge: { hackathonId: string; email: string | null; isActive: boolean }, ) { try { if (!judge.email) return; const target = await panelTarget(db, judge.hackathonId); if (!target) return; - await panelAsPortal().judge.upsert({ - eventId: target.eventId, - email: judge.email, - name: judge.email, - externalId: null, - }); + await mirrorJudge(target.eventId, judge.email, judge.isActive); } catch { return; } } +async function mirrorJudge(eventId: string, email: string, isActive: boolean) { + const caller = panelAsPortal(); + // Upsert is refused once judging is closed; a judge already there can + // still be suspended or reinstated. + const stored = await caller.judge + .upsert({ eventId, email, name: email, externalId: null }) + .catch(() => undefined); + let judgeId = stored?.id; + if (!judgeId) { + const [row] = await panel() + .db.select({ id: panelJudge.id }) + .from(panelJudge) + .where(and(eq(panelJudge.eventId, eventId), eq(panelJudge.email, email.toLowerCase()))); + judgeId = row?.id; + } + if (!judgeId) return; + await caller.judge.setStatus({ + eventId, + judgeId, + status: isActive ? "approved" : "suspended", + }); +} + +/** + * Moves an edition onto panel judging: creates its event, then copies in the + * submitted projects and the judges the portal has approved. Switching back + * leaves the panel event in place, so switching again resumes it. + */ +export async function setJudgingBackend( + db: DrizzleDB, + hackathonId: string, + backend: "legacy" | "panel", +) { + await db.update(hackathons).set({ judgingBackend: backend }).where(eq(hackathons.id, hackathonId)); + if (backend === "legacy") return; + const target = await panelTarget(db, hackathonId); + if (!target) return; + await syncProjectsToPanel(db, hackathonId); + const rows = await db + .select({ email: judges.email, isActive: judges.isActive }) + .from(judges) + .where(eq(judges.hackathonId, hackathonId)); + for (const row of rows) { + if (row.email) await mirrorJudge(target.eventId, row.email, row.isActive); + } +} + async function panelTarget(db: DrizzleDB, hackathonId: string) { - const eventId = process.env.PANEL_EVENT_ID; - if (!eventId) return null; const [row] = await db - .select({ backend: hackathons.judgingBackend }) + .select({ id: hackathons.id, name: hackathons.name, backend: hackathons.judgingBackend }) .from(hackathons) .where(eq(hackathons.id, hackathonId)); if (row?.backend !== "panel") return null; - return { eventId }; + return panelEventFor(row); } export async function pullPanelResults(db: DrizzleDB, hackathonId: string) { @@ -120,11 +165,8 @@ export async function pullPanelResults(db: DrizzleDB, hackathonId: string) { } export async function panelDeskUrl(db: DrizzleDB, userId: string) { - const org = process.env.PANEL_ORG_SLUG; - const eventSlug = process.env.PANEL_EVENT_SLUG; - if (!org || !eventSlug) return { url: null as string | null }; const [row] = await db - .select({ email: judges.email }) + .select({ hackathonId: judges.hackathonId }) .from(judges) .innerJoin(hackathons, eq(hackathons.id, judges.hackathonId)) .where( @@ -134,18 +176,18 @@ export async function panelDeskUrl(db: DrizzleDB, userId: string) { eq(hackathons.judgingBackend, "panel"), ), ); - if (!row?.email) return { url: null }; - return { url: `/judge/panel/${org}/${eventSlug}` }; + if (!row) return { url: null as string | null }; + return { url: `/judge/panel/${row.hackathonId}` }; } export async function panelConsoleUrl(db: DrizzleDB, hackathonId: string) { - const org = process.env.PANEL_ORG_SLUG; - const eventSlug = process.env.PANEL_EVENT_SLUG; - if (!org || !eventSlug) return { url: null as string | null }; const [row] = await db .select({ backend: hackathons.judgingBackend }) .from(hackathons) .where(eq(hackathons.id, hackathonId)); - if (row?.backend !== "panel") return { url: null }; - return { url: `/admin/judging/panel/${org}/${eventSlug}` }; + const backend = row?.backend ?? "legacy"; + return { + backend, + url: backend === "panel" ? `/admin/judging/panel/${hackathonId}` : null, + }; } diff --git a/packages/api/src/services/panel.ts b/packages/api/src/services/panel.ts index 3735f6ff..f22e18c1 100644 --- a/packages/api/src/services/panel.ts +++ b/packages/api/src/services/panel.ts @@ -8,6 +8,7 @@ import { appRouter, createApp, createMetrics, + ensureEvent, } from "@query/judging-server"; import type { Actor, Role } from "@query/judging-server"; import { createContext } from "../context"; @@ -17,6 +18,19 @@ import { isStaffRole, } from "../types/portal-context"; +/** Every hackathon edition is one judging event in this organization. */ +export const PANEL_ORG = { slug: "hacklytics", name: "Hacklytics" } as const; + +/** The judging event for an edition, keyed by the hackathon id. Created on first use. */ +export async function panelEventFor(hackathon: { id: string; name: string }) { + return ensureEvent(panel().db, { + orgSlug: PANEL_ORG.slug, + orgName: PANEL_ORG.name, + eventSlug: hackathon.id, + name: hackathon.name, + }); +} + type Embedded = { db: PanelDb; app: ReturnType; @@ -101,7 +115,7 @@ export async function portalActor(): Promise { sub: ctx.userId, email: judgeRows[0]?.email ?? email, name: ctx.session?.user?.name ?? email, - org: process.env.PANEL_ORG_SLUG ?? null, + org: PANEL_ORG.slug, role: roleFor(admin ?? null), judgeExternalId: null, }; diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index 140b2ec7..1fa702c4 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "drizzle-kit"; import * as dotenv from "dotenv"; +import fs from "fs"; import path from "path"; // Load .env from root (two levels up) @@ -9,8 +10,29 @@ if (!process.env.DATABASE_URL) { throw new Error("DATABASE_URL is not defined in .env file"); } +// Judging (packages/judging-db) keeps its tables in this database and applies +// them with its own SQL migrations. Left visible, push sees them as tables to +// drop and stops at a prompt; the build has no TTY, so the prompt errors, push +// exits 0, and every club schema change after that is silently skipped. +// The list comes from the migration files, so a new judging table is covered. +const judgingMigrations = path.resolve(__dirname, "../judging-db/migrations"); +const judgingTables = [ + "panel_migration", + ...fs + .readdirSync(judgingMigrations) + .filter((file) => file.endsWith(".sql")) + .flatMap((file) => + [ + ...fs + .readFileSync(path.join(judgingMigrations, file), "utf8") + .matchAll(/^create table (\w+)/gm), + ].map((match) => match[1] as string), + ), +]; + export default defineConfig({ - schema: "./src/schemas/**/*.ts", + schema: ["./src/schemas/**/*.ts", "../judging-db/src/enums.ts"], + tablesFilter: judgingTables.map((table) => `!${table}`), out: "./drizzle", dialect: "postgresql", dbCredentials: { diff --git a/packages/judging-cli/src/main.ts b/packages/judging-cli/src/main.ts index a7d00297..69ac7081 100644 --- a/packages/judging-cli/src/main.ts +++ b/packages/judging-cli/src/main.ts @@ -107,11 +107,13 @@ async function main() { } } + // Optional: the portal signs judges in with its own session. The secret + // only matters for callers outside it that send a bearer token. const secret = process.env.PANEL_JWT_SECRET; checks.push({ name: "jwt", - ok: Boolean(secret), - detail: secret ? "PANEL_JWT_SECRET is set" : "PANEL_JWT_SECRET is not set", + ok: true, + detail: secret ? "PANEL_JWT_SECRET is set" : "not set (portal sign-in only)", }); checks.push({ name: "redis", diff --git a/packages/judging-db/src/enums.ts b/packages/judging-db/src/enums.ts new file mode 100644 index 00000000..e7bfb4a1 --- /dev/null +++ b/packages/judging-db/src/enums.ts @@ -0,0 +1,39 @@ +import { pgEnum } from "drizzle-orm/pg-core"; + +// Kept apart from the tables because packages/db's drizzle config loads this +// file too. Judging creates these types with its own SQL migrations; push +// must see them as declared, or it tries to drop them from the shared +// database. Its tables stay hidden from push (tablesFilter there). + +export const eventPhase = pgEnum("event_phase", [ + "setup", + "submissions_open", + "submissions_closed", + "judging_live", + "judging_closed", + "published", + "archived", +]); + +export const trackKind = pgEnum("track_kind", ["main", "sponsor", "special"]); + +export const dispatchStrategy = pgEnum("dispatch_strategy", [ + "coverage", + "uncertainty", +]); + +export const judgeStatus = pgEnum("judge_status", [ + "invited", + "applied", + "approved", + "suspended", +]); + +export const comparisonOutcome = pgEnum("comparison_outcome", ["a", "b", "tie"]); + +export const membershipRole = pgEnum("membership_role", [ + "owner", + "admin", + "organizer", + "volunteer", +]); diff --git a/packages/judging-db/src/schema.ts b/packages/judging-db/src/schema.ts index e206ba77..365d76a0 100644 --- a/packages/judging-db/src/schema.ts +++ b/packages/judging-db/src/schema.ts @@ -3,7 +3,6 @@ import { integer, jsonb, numeric, - pgEnum, pgTable, primaryKey, text, @@ -12,39 +11,23 @@ import { uuid, } from "drizzle-orm/pg-core"; import { sql } from "drizzle-orm"; - -export const eventPhase = pgEnum("event_phase", [ - "setup", - "submissions_open", - "submissions_closed", - "judging_live", - "judging_closed", - "published", - "archived", -]); - -export const trackKind = pgEnum("track_kind", ["main", "sponsor", "special"]); - -export const dispatchStrategy = pgEnum("dispatch_strategy", [ - "coverage", - "uncertainty", -]); - -export const judgeStatus = pgEnum("judge_status", [ - "invited", - "applied", - "approved", - "suspended", -]); - -export const comparisonOutcome = pgEnum("comparison_outcome", ["a", "b", "tie"]); - -export const membershipRole = pgEnum("membership_role", [ - "owner", - "admin", - "organizer", - "volunteer", -]); +import { + comparisonOutcome, + dispatchStrategy, + eventPhase, + judgeStatus, + membershipRole, + trackKind, +} from "./enums"; + +export { + comparisonOutcome, + dispatchStrategy, + eventPhase, + judgeStatus, + membershipRole, + trackKind, +}; export const organization = pgTable("organization", { id: uuid("id").defaultRandom().primaryKey(), diff --git a/packages/judging-server/src/index.ts b/packages/judging-server/src/index.ts index 3f3beebb..669d6c96 100644 --- a/packages/judging-server/src/index.ts +++ b/packages/judging-server/src/index.ts @@ -1,7 +1,9 @@ export { createApp } from "./app"; export { appRouter } from "./router"; +export { drainOutbox } from "./services/outbox"; export type { Context } from "./router"; export type { Actor, Role } from "./auth"; export { InMemoryBus } from "./bus"; export { createMetrics } from "./metrics"; export { publishedPlacements } from "./services/results"; +export { ensureEvent } from "./services/event"; diff --git a/packages/judging-server/src/router.ts b/packages/judging-server/src/router.ts index f450fe9b..90e44911 100644 --- a/packages/judging-server/src/router.ts +++ b/packages/judging-server/src/router.ts @@ -49,6 +49,7 @@ import { saveConfig, } from "./services/catalog"; import { castVote } from "./services/vote"; +import { drainOutbox } from "./services/outbox"; export type Context = { db: PanelDb; @@ -482,6 +483,13 @@ export const appRouter = t.router({ now: ctx.now, }); tell(ctx, input.eventId, null, "results.published"); + // Nothing drains the queue on a timer, and publish is the one step + // every event takes. Webhooks go out here; rows nobody listens for + // are cleared. A failed delivery stays queued for `panel outbox drain`. + for (let batch = 0; batch < 20; batch += 1) { + const delivered = await drainOutbox(ctx.db).catch(() => 0); + if (delivered === 0) break; + } return outcome; } catch (error) { asTrpc(error); diff --git a/packages/judging-server/src/services/event.integration.test.ts b/packages/judging-server/src/services/event.integration.test.ts new file mode 100644 index 00000000..46c2aea4 --- /dev/null +++ b/packages/judging-server/src/services/event.integration.test.ts @@ -0,0 +1,38 @@ +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { createDb, criterion, databaseUrl, event, migrate, rubric } from "@query/judging-db"; +import { ensureEvent } from "./event"; + +const url = databaseUrl(); + +describe.skipIf(!url)("ensureEvent", () => { + it("creates an edition's event once, with the default rubric", async () => { + const { db, pool } = createDb(url as string); + try { + await migrate(pool); + const input = { + orgSlug: "test-org", + orgName: "Test", + eventSlug: randomUUID(), + name: "Test edition", + }; + const first = await ensureEvent(db, input); + const again = await ensureEvent(db, input); + expect(again.eventId).toBe(first.eventId); + + const [row] = await db.select().from(event).where(eq(event.id, first.eventId)); + expect(row?.phase).toBe("setup"); + const criteria = await db + .select({ key: criterion.key }) + .from(criterion) + .innerJoin(rubric, eq(rubric.id, criterion.rubricId)) + .where(and(eq(rubric.eventId, first.eventId), eq(rubric.isDefault, true))); + expect(criteria.map((item) => item.key).sort()).toEqual( + ["clarity", "creativity", "impact", "scope", "soundness"], + ); + } finally { + await pool.end(); + } + }); +}); diff --git a/packages/judging-server/src/services/event.ts b/packages/judging-server/src/services/event.ts new file mode 100644 index 00000000..30fae295 --- /dev/null +++ b/packages/judging-server/src/services/event.ts @@ -0,0 +1,78 @@ +import { and, eq, sql } from "drizzle-orm"; +import { DEFAULT_TIMERS } from "@query/judging-core"; +import type { PanelDb } from "@query/judging-db"; +import { criterion, event, eventConfig, organization, rubric } from "@query/judging-db"; + +/** The five scores the club's judges have always given, each out of ten. */ +const DEFAULT_CRITERIA = [ + ["creativity", "Creativity & Originality"], + ["impact", "Impact & Relevance"], + ["scope", "Scope & Technical Depth"], + ["clarity", "Clarity & Engagement"], + ["soundness", "Soundness & Accuracy"], +] as const; + +/** + * The event for one hackathon edition, created the first time it is asked + * for: organization, event in setup, default config, and the default rubric. + * Safe to call on every sync; an existing event is returned untouched. + */ +export async function ensureEvent( + db: PanelDb, + input: { orgSlug: string; orgName: string; eventSlug: string; name: string }, +): Promise<{ eventId: string }> { + return db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${`panel:event:${input.orgSlug}:${input.eventSlug}`}))`, + ); + await tx + .insert(organization) + .values({ slug: input.orgSlug, name: input.orgName }) + .onConflictDoNothing({ target: organization.slug }); + const [org] = await tx + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.slug, input.orgSlug)); + if (!org) throw new Error("Organization was not stored"); + + const [existing] = await tx + .select({ id: event.id }) + .from(event) + .where(and(eq(event.orgId, org.id), eq(event.slug, input.eventSlug))); + if (existing) return { eventId: existing.id }; + + const [created] = await tx + .insert(event) + .values({ orgId: org.id, slug: input.eventSlug, name: input.name, phase: "setup" }) + .returning({ id: event.id }); + if (!created) throw new Error("Event was not stored"); + + await tx.insert(eventConfig).values({ + eventId: created.id, + targetSeconds: DEFAULT_TIMERS.targetSeconds, + hardLimitSeconds: DEFAULT_TIMERS.hardLimitSeconds, + walkLimitSeconds: DEFAULT_TIMERS.walkLimitSeconds, + submitGraceSeconds: DEFAULT_TIMERS.submitGraceSeconds, + minLooksPerProject: 1, + dispatchStrategy: "coverage", + }); + + const [main] = await tx + .insert(rubric) + .values({ eventId: created.id, name: "Main", isDefault: true }) + .returning({ id: rubric.id }); + if (!main) throw new Error("Rubric was not stored"); + await tx.insert(criterion).values( + DEFAULT_CRITERIA.map(([key, label], position) => ({ + rubricId: main.id, + position, + key, + label, + min: 1, + max: 10, + weight: 1, + })), + ); + return { eventId: created.id }; + }); +} diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index fa36d7c7..14e9d414 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -88,6 +88,9 @@ export default function AdminResultsPage() { // enforced: sync submissions, then build the queues. Doing them in the wrong // order leaves late projects in nobody's queue. const utils = trpc.useUtils(); + const setJudgingBackend = trpc.judge.setJudgingBackend.useMutation({ + onSuccess: () => utils.judge.panelConsole.invalidate(), + }); const [prepState, setPrepState] = useState<{ busy: boolean; message: string | null; @@ -257,10 +260,35 @@ export default function AdminResultsPage() { ? "Pick a hackathon to see its results." : "Open and close judging, watch the floor, and compare scores."}

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

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

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

- +
); } diff --git a/sites/mainweb/lib/panel.server.ts b/sites/mainweb/lib/panel.server.ts index 00fe571f..257d221a 100644 --- a/sites/mainweb/lib/panel.server.ts +++ b/sites/mainweb/lib/panel.server.ts @@ -1,4 +1,4 @@ -import { panel } from "@query/api/panel"; +import { PANEL_ORG, panel } from "@query/api/panel"; import type { PublicEvent } from "./panel"; /** Calls the judging API in process, the same routes the browser reaches at /api/panel. */ @@ -6,11 +6,15 @@ export async function panelRequest(path: string): Promise { return panel().app.request(path); } +/** The judging event for a hackathon edition, or null before it has one. */ export async function loadEvent( - orgSlug: string, - eventSlug: string, + hackathonId: string, ): Promise { - const response = await panelRequest(`/v1/public/${orgSlug}/${eventSlug}`); + const response = await panelRequest( + `/v1/public/${PANEL_ORG.slug}/${encodeURIComponent(hackathonId)}`, + ); if (!response.ok) return null; return response.json() as Promise; } + +export { PANEL_ORG }; diff --git a/turbo.json b/turbo.json index 2e5c92e6..51ec52db 100644 --- a/turbo.json +++ b/turbo.json @@ -99,9 +99,6 @@ "PANEL_JWT_SECRET", "PANEL_SMTP_URL", "PANEL_SMTP_FROM", - "PANEL_EVENT_ID", - "PANEL_ORG_SLUG", - "PANEL_EVENT_SLUG", "REDIS_URL" ] } From 554b561cf0349a5a05ed9cbf7f7f4c9cd88b41ce Mon Sep 17 00:00:00 2001 From: aamoghS Date: Tue, 13 Oct 2026 17:39:22 -0400 Subject: [PATCH 16/82] feat(judging): rework judging around rooms and add hackathon points Rework judging around rooms and a saved schedule: - judging_* tables: challenges and groups, buildings and rooms, rubric, schedule and appointments, evaluations with drafts and revisions, deliberation boards, room access links and guest judge sessions - Devpost submissions CSV import alongside portal promotion - schedule solver: aamoghCPSolver (counting bounds, room-class symmetry, sweep, goal-ordered repair) plus a CP-SAT fallback script (scripts/judging-schedule/cpsat.py) with problem export and checked solution upload - routers judging.{admin,schedule,desk,hacker,results}; judge.* trimmed to the roster - admin console, judge desk, hacker itinerary and results UI - check-in by event code, staff lookup or static pass instead of live QR codes Add hackathon points: adjustments and a store with computed balances, routers points.{admin,store,me}. Delete the panel packages (judging-core, judging-db, judging-server, judging-cli) and the pool dispatch judge routers. Panel enum types stay declared in retired.ts and its tables are filtered in drizzle.config.ts so drizzle-kit push does not stall on drop prompts. --- .gitignore | 3 +- PLAN.md | 444 ---- docs/architecture.md | 8 +- docs/contributing.md | 2 +- docs/glossary.md | 4 +- docs/judging/README.md | 72 - docs/judging/adr/001-standalone.md | 26 - docs/judging/adr/002-pure-core.md | 22 - docs/judging/adr/003-one-database.md | 26 - docs/judging/api.md | 17 - docs/judging/contributing.md | 28 - docs/judging/deploy.md | 23 - docs/judging/rubric.md | 7 - docs/judging/scoring.md | 9 - docs/operations/security.md | 1 - docs/operations/testing.md | 2 +- docs/packages/api.md | 11 +- docs/packages/db.md | 5 +- docs/sites/mainweb.md | 8 +- package.json | 2 +- packages/api/package.json | 5 +- .../api/scripts/judging-schedule/cpsat.py | 572 +++++ .../judging-schedule/example-problem.json | 232 ++ .../.internal-tests/hackathon-flow.test.ts | 310 --- .../src/.internal-tests/judge-edge.test.ts | 1601 ++---------- .../.internal-tests/participant-edge.test.ts | 43 +- .../src/.internal-tests/qr-checkin.test.ts | 31 +- .../api/src/.internal-tests/routers.test.ts | 72 +- packages/api/src/middleware/procedures.ts | 109 +- packages/api/src/root.ts | 4 + packages/api/src/routers/hackathon/admin.ts | 186 +- packages/api/src/routers/hackathon/content.ts | 192 +- packages/api/src/routers/hackathon/events.ts | 150 +- packages/api/src/routers/judge/admin.ts | 913 +------ .../api/src/routers/judge/dispatch.db.test.ts | 318 --- .../api/src/routers/judge/dispatch.test.ts | 110 - packages/api/src/routers/judge/dispatch.ts | 387 --- .../src/routers/judge/helpers-edge.test.ts | 98 - .../api/src/routers/judge/helpers.test.ts | 20 - packages/api/src/routers/judge/helpers.ts | 17 - packages/api/src/routers/judge/index.ts | 9 +- packages/api/src/routers/judge/portal.ts | 846 ++----- packages/api/src/routers/judge/rankings.ts | 476 ---- packages/api/src/routers/judge/replay.test.ts | 55 - packages/api/src/routers/judge/weight.ts | 84 - packages/api/src/routers/judging/admin.ts | 2254 +++++++++++++++++ packages/api/src/routers/judging/desk.ts | 1610 ++++++++++++ packages/api/src/routers/judging/hacker.ts | 1127 +++++++++ packages/api/src/routers/judging/index.ts | 16 + packages/api/src/routers/judging/results.ts | 738 ++++++ packages/api/src/routers/judging/schedule.ts | 1140 +++++++++ packages/api/src/routers/points/admin.ts | 238 ++ packages/api/src/routers/points/index.ts | 11 + packages/api/src/routers/points/me.ts | 314 +++ packages/api/src/routers/points/store.ts | 752 ++++++ packages/api/src/routers/team.ts | 58 +- .../judging/appointment-status.test.ts | 62 + .../services/judging/appointment-status.ts | 46 + .../src/services/judging/challenges.test.ts | 321 +++ .../api/src/services/judging/challenges.ts | 398 +++ .../src/services/judging/checklist.test.ts | 184 ++ .../api/src/services/judging/checklist.ts | 299 +++ .../api/src/services/judging/claims.test.ts | 197 ++ packages/api/src/services/judging/claims.ts | 539 ++++ .../api/src/services/judging/config.test.ts | 130 + packages/api/src/services/judging/config.ts | 102 + .../src/services/judging/deliberation.test.ts | 58 + .../api/src/services/judging/deliberation.ts | 42 + .../api/src/services/judging/devpost.test.ts | 558 ++++ packages/api/src/services/judging/devpost.ts | 578 +++++ .../services/judging/evaluation-store.test.ts | 144 ++ .../src/services/judging/evaluation-store.ts | 661 +++++ .../src/services/judging/evaluation.test.ts | 220 ++ .../api/src/services/judging/evaluation.ts | 149 ++ .../api/src/services/judging/import.test.ts | 112 + packages/api/src/services/judging/import.ts | 609 +++++ .../src/services/judging/itinerary.test.ts | 408 +++ .../api/src/services/judging/itinerary.ts | 321 +++ .../src/services/judging/placements.test.ts | 127 + .../api/src/services/judging/placements.ts | 99 + .../src/services/judging/principal.test.ts | 286 +++ .../api/src/services/judging/principal.ts | 506 ++++ packages/api/src/services/judging/resets.ts | 357 +++ .../src/services/judging/room-link.test.ts | 87 + .../api/src/services/judging/room-link.ts | 55 + .../api/src/services/judging/rooms.test.ts | 95 + packages/api/src/services/judging/rooms.ts | 154 ++ .../judging/schedule/aamogh-cp-solver.test.ts | 188 ++ .../judging/schedule/aamogh-cp-solver.ts | 1085 ++++++++ .../services/judging/schedule/board.test.ts | 103 + .../src/services/judging/schedule/board.ts | 111 + .../judging/schedule/cpsat-bridge.test.ts | 129 + .../services/judging/schedule/cpsat-bridge.ts | 157 ++ .../services/judging/schedule/inputs.test.ts | 116 + .../src/services/judging/schedule/inputs.ts | 174 ++ .../services/judging/schedule/moves.test.ts | 97 + .../src/services/judging/schedule/moves.ts | 131 + .../services/judging/schedule/rules.test.ts | 157 ++ .../src/services/judging/schedule/rules.ts | 177 ++ .../services/judging/schedule/slots.test.ts | 111 + .../src/services/judging/schedule/slots.ts | 120 + .../services/judging/schedule/solve.test.ts | 368 +++ .../src/services/judging/schedule/solve.ts | 782 ++++++ .../api/src/services/judging/scope.test.ts | 110 + packages/api/src/services/judging/scope.ts | 85 + .../api/src/services/judging/scoring.test.ts | 131 + packages/api/src/services/judging/scoring.ts | 87 + .../api/src/services/judging/timing.test.ts | 101 + packages/api/src/services/judging/timing.ts | 96 + packages/api/src/services/panel-sync.ts | 193 -- packages/api/src/services/panel.ts | 133 - packages/api/src/services/points.test.ts | 414 +++ packages/api/src/services/points.ts | 587 +++++ packages/api/src/services/portal-context.ts | 6 +- packages/api/src/trpc.ts | 64 +- packages/auth/src/email.ts | 35 +- packages/db/drizzle.config.ts | 54 +- packages/db/src/schemas/hackathons.ts | 37 +- packages/db/src/schemas/index.ts | 3 + packages/db/src/schemas/judge.ts | 19 +- packages/db/src/schemas/judging.ts | 1040 ++++++++ packages/db/src/schemas/points.ts | 161 ++ packages/db/src/schemas/retired.ts | 49 + packages/judging-cli/eslint.config.mjs | 10 - packages/judging-cli/package.json | 24 - packages/judging-cli/src/csv.test.ts | 41 - packages/judging-cli/src/csv.ts | 127 - packages/judging-cli/src/main.ts | 155 -- packages/judging-cli/src/transfer.ts | 67 - packages/judging-cli/tsconfig.json | 10 - packages/judging-core/eslint.config.mjs | 40 - packages/judging-core/package.json | 33 - .../judging-core/scripts/emit-extensions.mjs | 25 - .../judging-core/src/aggregate/bayes.test.ts | 27 - packages/judging-core/src/aggregate/bayes.ts | 19 - .../judging-core/src/aggregate/blend.test.ts | 18 - packages/judging-core/src/aggregate/blend.ts | 11 - .../src/aggregate/bradleyTerry.test.ts | 50 - .../src/aggregate/bradleyTerry.ts | 72 - .../judging-core/src/aggregate/rank.test.ts | 244 -- packages/judging-core/src/aggregate/rank.ts | 312 --- .../judging-core/src/aggregate/zscore.test.ts | 79 - packages/judging-core/src/aggregate/zscore.ts | 22 - packages/judging-core/src/comparison.ts | 5 - .../judging-core/src/dispatch.sim.test.ts | 120 - packages/judging-core/src/dispatch.test.ts | 139 - packages/judging-core/src/dispatch.ts | 128 - packages/judging-core/src/distance.test.ts | 31 - packages/judging-core/src/distance.ts | 30 - packages/judging-core/src/events.test.ts | 34 - packages/judging-core/src/events.ts | 66 - packages/judging-core/src/index.ts | 56 - packages/judging-core/src/match.test.ts | 21 - packages/judging-core/src/match.ts | 20 - packages/judging-core/src/package.test.ts | 14 - packages/judging-core/src/phase.test.ts | 59 - packages/judging-core/src/phase.ts | 63 - packages/judging-core/src/rubric.test.ts | 67 - packages/judging-core/src/rubric.ts | 85 - packages/judging-core/src/tables.test.ts | 34 - packages/judging-core/src/tables.ts | 52 - packages/judging-core/src/timers.test.ts | 88 - packages/judging-core/src/timers.ts | 74 - packages/judging-core/src/uncertainty.test.ts | 66 - packages/judging-core/src/uncertainty.ts | 66 - packages/judging-core/tsconfig.build.json | 12 - packages/judging-core/tsconfig.json | 10 - packages/judging-core/vitest.config.ts | 7 - packages/judging-db/eslint.config.mjs | 4 - packages/judging-db/migrations/0001_init.sql | 309 --- .../migrations/0002_feedback_token.sql | 1 - .../migrations/0003_shared_database.sql | 37 - packages/judging-db/package.json | 25 - packages/judging-db/src/client.ts | 21 - packages/judging-db/src/enums.ts | 39 - packages/judging-db/src/env.ts | 11 - packages/judging-db/src/index.ts | 6 - packages/judging-db/src/migrate.ts | 69 - packages/judging-db/src/schema.ts | 416 --- packages/judging-db/src/seed.ts | 238 -- packages/judging-db/tsconfig.json | 10 - packages/judging-server/eslint.config.mjs | 10 - packages/judging-server/package.json | 34 - packages/judging-server/src/app.test.ts | 40 - packages/judging-server/src/app.ts | 385 --- packages/judging-server/src/auth.test.ts | 16 - packages/judging-server/src/auth.ts | 100 - packages/judging-server/src/bus.test.ts | 51 - packages/judging-server/src/bus.ts | 131 - packages/judging-server/src/index.ts | 9 - packages/judging-server/src/log.ts | 3 - packages/judging-server/src/mail.ts | 12 - packages/judging-server/src/metrics.ts | 40 - packages/judging-server/src/router.ts | 674 ----- .../judging-server/src/services/arrive.ts | 76 - .../judging-server/src/services/catalog.ts | 454 ---- .../judging-server/src/services/compare.ts | 63 - .../src/services/dispatch.concurrent.test.ts | 33 - .../judging-server/src/services/dispatch.ts | 455 ---- .../src/services/event.integration.test.ts | 38 - packages/judging-server/src/services/event.ts | 78 - .../src/services/feedback.test.ts | 18 - .../judging-server/src/services/feedback.ts | 17 - .../judging-server/src/services/floor.test.ts | 29 - packages/judging-server/src/services/floor.ts | 79 - .../src/services/flow.integration.test.ts | 110 - packages/judging-server/src/services/live.ts | 122 - .../judging-server/src/services/logs.test.ts | 16 - packages/judging-server/src/services/logs.ts | 28 - .../judging-server/src/services/outbox.ts | 53 - .../src/services/overtime.test.ts | 30 - .../judging-server/src/services/overtime.ts | 49 - .../judging-server/src/services/records.ts | 358 --- .../src/services/redis.integration.test.ts | 41 - .../src/services/results.test.ts | 61 - .../judging-server/src/services/results.ts | 328 --- .../services/visit-status.integration.test.ts | 41 - .../judging-server/src/services/vote.test.ts | 48 - packages/judging-server/src/services/vote.ts | 175 -- packages/judging-server/tsconfig.json | 10 - packages/judging-server/vitest.config.ts | 8 - pnpm-lock.yaml | 190 -- .../[id]/events/[eventId]/code/page.tsx | 193 ++ .../(portal)/admin/hackathons/[id]/page.tsx | 27 +- .../admin/judging/[hackathonId]/page.tsx | 208 ++ .../admin/judging/[hackathonId]/qr/page.tsx | 213 ++ .../app/(portal)/admin/judging/page.tsx | 484 +--- .../judging/panel/[hackathonId]/console.tsx | 464 ---- .../judging/panel/[hackathonId]/editors.tsx | 426 ---- .../judging/panel/[hackathonId]/page.tsx | 16 - .../mainweb/app/(portal)/admin/setup/page.tsx | 2 +- .../app/(portal)/api/panel/[...path]/route.ts | 27 - sites/mainweb/app/(portal)/dashboard/page.tsx | 2 +- .../(portal)/hackathons/[id]/checkin/page.tsx | 183 ++ .../(portal)/hackathons/[id]/claim/page.tsx | 209 ++ .../(portal)/hackathons/[id]/judge/page.tsx | 784 ------ .../app/(portal)/hackathons/[id]/page.tsx | 17 +- .../app/(portal)/judge/[hackathonId]/page.tsx | 11 + .../(portal)/judge/activate/[linkId]/route.ts | 59 + .../judge/guest/[hackathonId]/page.tsx | 12 + .../judge/guest/invalid-link/page.tsx | 28 + sites/mainweb/app/(portal)/judge/page.tsx | 342 ++- .../judge/panel/[hackathonId]/desk.tsx | 506 ---- .../judge/panel/[hackathonId]/page.tsx | 24 - .../app/(portal)/judge/register/page.tsx | 7 +- sites/mainweb/app/(portal)/submit/page.tsx | 31 +- .../app/judging/[hackathonId]/board.tsx | 123 - .../[hackathonId]/feedback/[token]/page.tsx | 57 - .../app/judging/[hackathonId]/page.tsx | 27 - .../components/admin/hackathons/EventsTab.tsx | 96 +- .../admin/hackathons/JudgeLiveBoard.tsx | 192 -- .../components/admin/hackathons/JudgesTab.tsx | 881 ++----- .../admin/hackathons/ScannerTab.tsx | 86 +- .../admin/hackathons/TableCards.tsx | 220 -- .../points/EventCheckInControls.tsx | 143 ++ .../admin/hackathons/points/ManualCheckIn.tsx | 155 ++ .../hackathons/points/ParticipantSearch.tsx | 100 + .../hackathons/points/PointsAdminTab.tsx | 277 ++ .../admin/hackathons/points/PointsLedger.tsx | 342 +++ .../admin/hackathons/points/SaleStation.tsx | 379 +++ .../admin/hackathons/points/StoreItems.tsx | 512 ++++ .../hackathons/points/StorePurchases.tsx | 241 ++ .../admin/hackathons/points/StoreSettings.tsx | 121 + .../admin/hackathons/points/StoreTab.tsx | 39 + .../admin/hackathons/points/Toggle.tsx | 40 + .../admin/hackathons/points/pass.test.ts | 44 + .../admin/hackathons/points/pass.ts | 31 + .../admin/hackathons/points/use-debounced.ts | 14 + .../admin/judging/AnnouncementsSection.tsx | 231 ++ .../admin/judging/ChallengesSection.tsx | 549 ++++ .../admin/judging/ChecklistSection.tsx | 201 ++ .../components/admin/judging/DangerZone.tsx | 260 ++ .../components/admin/judging/EntriesList.tsx | 517 ++++ .../admin/judging/JudgeMatrixView.tsx | 195 -- .../components/admin/judging/JudgingTools.tsx | 131 - .../admin/judging/LaunchSection.tsx | 247 ++ .../admin/judging/ProjectsSection.tsx | 413 +++ .../components/admin/judging/RankingsView.tsx | 722 ------ .../admin/judging/RoomAssignmentsView.tsx | 132 - .../components/admin/judging/RoomsSection.tsx | 957 +++++++ .../admin/judging/RubricSection.tsx | 363 +++ .../admin/judging/claims/ClaimsPanel.tsx | 379 +++ .../admin/judging/results/ResultsTab.tsx | 668 +++++ .../admin/judging/schedule/ChoiceDialog.tsx | 294 +++ .../admin/judging/schedule/LiveBoard.tsx | 374 +++ .../admin/judging/schedule/RoomBoard.tsx | 97 + .../judging/schedule/SchedulePlanner.tsx | 460 ++++ .../admin/judging/schedule/ScheduleTab.tsx | 76 + .../admin/judging/schedule/format.ts | 105 + .../components/admin/judging/shared.tsx | 187 ++ .../components/hackathon/ResultsTab.tsx | 131 +- .../components/hackathon/ScheduleTab.tsx | 8 + .../hackathon/judging/HackerJudgingTab.tsx | 564 +++++ .../hackathon/judging/Itinerary.tsx | 284 +++ .../hackathon/judging/event-time.ts | 32 + .../hackathon/points/CheckInCodeForm.tsx | 105 + .../components/hackathon/points/PointsTab.tsx | 391 +++ .../hackathon/points/format.test.ts | 68 + .../components/hackathon/points/format.ts | 63 + .../components/judge/DeskAnnouncements.tsx | 54 + .../components/judge/EvaluationSheet.tsx | 699 +++++ .../components/judge/GuestNameGate.tsx | 86 + sites/mainweb/components/judge/JudgeDesk.tsx | 388 +++ .../components/judge/MySubmissions.tsx | 123 + .../mainweb/components/judge/ProjectQueue.tsx | 455 ++++ .../mainweb/components/judge/RoomSwitcher.tsx | 140 + sites/mainweb/components/judge/Shortlist.tsx | 609 +++++ .../mainweb/components/judge/answers.test.ts | 99 + sites/mainweb/components/judge/answers.ts | 61 + sites/mainweb/components/judge/desk.test.ts | 108 + sites/mainweb/components/judge/desk.ts | 103 + .../components/judge/use-draft-autosave.ts | 120 + .../components/judge/use-server-now.ts | 50 + .../components/portal/PortalWrapper.tsx | 7 +- sites/mainweb/lib/judging-prep.test.ts | 20 - sites/mainweb/lib/judging-prep.ts | 16 - sites/mainweb/lib/panel-held.ts | 59 - sites/mainweb/lib/panel.server.ts | 20 - sites/mainweb/lib/panel.ts | 10 - sites/mainweb/lib/portal-nav.ts | 4 +- sites/mainweb/next.config.mjs | 10 +- turbo.json | 9 +- 322 files changed, 40267 insertions(+), 20147 deletions(-) delete mode 100644 PLAN.md delete mode 100644 docs/judging/README.md delete mode 100644 docs/judging/adr/001-standalone.md delete mode 100644 docs/judging/adr/002-pure-core.md delete mode 100644 docs/judging/adr/003-one-database.md delete mode 100644 docs/judging/api.md delete mode 100644 docs/judging/contributing.md delete mode 100644 docs/judging/deploy.md delete mode 100644 docs/judging/rubric.md delete mode 100644 docs/judging/scoring.md create mode 100644 packages/api/scripts/judging-schedule/cpsat.py create mode 100644 packages/api/scripts/judging-schedule/example-problem.json delete mode 100644 packages/api/src/routers/judge/dispatch.db.test.ts delete mode 100644 packages/api/src/routers/judge/dispatch.test.ts delete mode 100644 packages/api/src/routers/judge/dispatch.ts delete mode 100644 packages/api/src/routers/judge/helpers-edge.test.ts delete mode 100644 packages/api/src/routers/judge/helpers.test.ts delete mode 100644 packages/api/src/routers/judge/helpers.ts delete mode 100644 packages/api/src/routers/judge/rankings.ts delete mode 100644 packages/api/src/routers/judge/replay.test.ts delete mode 100644 packages/api/src/routers/judge/weight.ts create mode 100644 packages/api/src/routers/judging/admin.ts create mode 100644 packages/api/src/routers/judging/desk.ts create mode 100644 packages/api/src/routers/judging/hacker.ts create mode 100644 packages/api/src/routers/judging/index.ts create mode 100644 packages/api/src/routers/judging/results.ts create mode 100644 packages/api/src/routers/judging/schedule.ts create mode 100644 packages/api/src/routers/points/admin.ts create mode 100644 packages/api/src/routers/points/index.ts create mode 100644 packages/api/src/routers/points/me.ts create mode 100644 packages/api/src/routers/points/store.ts create mode 100644 packages/api/src/services/judging/appointment-status.test.ts create mode 100644 packages/api/src/services/judging/appointment-status.ts create mode 100644 packages/api/src/services/judging/challenges.test.ts create mode 100644 packages/api/src/services/judging/challenges.ts create mode 100644 packages/api/src/services/judging/checklist.test.ts create mode 100644 packages/api/src/services/judging/checklist.ts create mode 100644 packages/api/src/services/judging/claims.test.ts create mode 100644 packages/api/src/services/judging/claims.ts create mode 100644 packages/api/src/services/judging/config.test.ts create mode 100644 packages/api/src/services/judging/config.ts create mode 100644 packages/api/src/services/judging/deliberation.test.ts create mode 100644 packages/api/src/services/judging/deliberation.ts create mode 100644 packages/api/src/services/judging/devpost.test.ts create mode 100644 packages/api/src/services/judging/devpost.ts create mode 100644 packages/api/src/services/judging/evaluation-store.test.ts create mode 100644 packages/api/src/services/judging/evaluation-store.ts create mode 100644 packages/api/src/services/judging/evaluation.test.ts create mode 100644 packages/api/src/services/judging/evaluation.ts create mode 100644 packages/api/src/services/judging/import.test.ts create mode 100644 packages/api/src/services/judging/import.ts create mode 100644 packages/api/src/services/judging/itinerary.test.ts create mode 100644 packages/api/src/services/judging/itinerary.ts create mode 100644 packages/api/src/services/judging/placements.test.ts create mode 100644 packages/api/src/services/judging/placements.ts create mode 100644 packages/api/src/services/judging/principal.test.ts create mode 100644 packages/api/src/services/judging/principal.ts create mode 100644 packages/api/src/services/judging/resets.ts create mode 100644 packages/api/src/services/judging/room-link.test.ts create mode 100644 packages/api/src/services/judging/room-link.ts create mode 100644 packages/api/src/services/judging/rooms.test.ts create mode 100644 packages/api/src/services/judging/rooms.ts create mode 100644 packages/api/src/services/judging/schedule/aamogh-cp-solver.test.ts create mode 100644 packages/api/src/services/judging/schedule/aamogh-cp-solver.ts create mode 100644 packages/api/src/services/judging/schedule/board.test.ts create mode 100644 packages/api/src/services/judging/schedule/board.ts create mode 100644 packages/api/src/services/judging/schedule/cpsat-bridge.test.ts create mode 100644 packages/api/src/services/judging/schedule/cpsat-bridge.ts create mode 100644 packages/api/src/services/judging/schedule/inputs.test.ts create mode 100644 packages/api/src/services/judging/schedule/inputs.ts create mode 100644 packages/api/src/services/judging/schedule/moves.test.ts create mode 100644 packages/api/src/services/judging/schedule/moves.ts create mode 100644 packages/api/src/services/judging/schedule/rules.test.ts create mode 100644 packages/api/src/services/judging/schedule/rules.ts create mode 100644 packages/api/src/services/judging/schedule/slots.test.ts create mode 100644 packages/api/src/services/judging/schedule/slots.ts create mode 100644 packages/api/src/services/judging/schedule/solve.test.ts create mode 100644 packages/api/src/services/judging/schedule/solve.ts create mode 100644 packages/api/src/services/judging/scope.test.ts create mode 100644 packages/api/src/services/judging/scope.ts create mode 100644 packages/api/src/services/judging/scoring.test.ts create mode 100644 packages/api/src/services/judging/scoring.ts create mode 100644 packages/api/src/services/judging/timing.test.ts create mode 100644 packages/api/src/services/judging/timing.ts delete mode 100644 packages/api/src/services/panel-sync.ts delete mode 100644 packages/api/src/services/panel.ts create mode 100644 packages/api/src/services/points.test.ts create mode 100644 packages/api/src/services/points.ts create mode 100644 packages/db/src/schemas/judging.ts create mode 100644 packages/db/src/schemas/points.ts create mode 100644 packages/db/src/schemas/retired.ts delete mode 100644 packages/judging-cli/eslint.config.mjs delete mode 100644 packages/judging-cli/package.json delete mode 100644 packages/judging-cli/src/csv.test.ts delete mode 100644 packages/judging-cli/src/csv.ts delete mode 100644 packages/judging-cli/src/main.ts delete mode 100644 packages/judging-cli/src/transfer.ts delete mode 100644 packages/judging-cli/tsconfig.json delete mode 100644 packages/judging-core/eslint.config.mjs delete mode 100644 packages/judging-core/package.json delete mode 100644 packages/judging-core/scripts/emit-extensions.mjs delete mode 100644 packages/judging-core/src/aggregate/bayes.test.ts delete mode 100644 packages/judging-core/src/aggregate/bayes.ts delete mode 100644 packages/judging-core/src/aggregate/blend.test.ts delete mode 100644 packages/judging-core/src/aggregate/blend.ts delete mode 100644 packages/judging-core/src/aggregate/bradleyTerry.test.ts delete mode 100644 packages/judging-core/src/aggregate/bradleyTerry.ts delete mode 100644 packages/judging-core/src/aggregate/rank.test.ts delete mode 100644 packages/judging-core/src/aggregate/rank.ts delete mode 100644 packages/judging-core/src/aggregate/zscore.test.ts delete mode 100644 packages/judging-core/src/aggregate/zscore.ts delete mode 100644 packages/judging-core/src/comparison.ts delete mode 100644 packages/judging-core/src/dispatch.sim.test.ts delete mode 100644 packages/judging-core/src/dispatch.test.ts delete mode 100644 packages/judging-core/src/dispatch.ts delete mode 100644 packages/judging-core/src/distance.test.ts delete mode 100644 packages/judging-core/src/distance.ts delete mode 100644 packages/judging-core/src/events.test.ts delete mode 100644 packages/judging-core/src/events.ts delete mode 100644 packages/judging-core/src/index.ts delete mode 100644 packages/judging-core/src/match.test.ts delete mode 100644 packages/judging-core/src/match.ts delete mode 100644 packages/judging-core/src/package.test.ts delete mode 100644 packages/judging-core/src/phase.test.ts delete mode 100644 packages/judging-core/src/phase.ts delete mode 100644 packages/judging-core/src/rubric.test.ts delete mode 100644 packages/judging-core/src/rubric.ts delete mode 100644 packages/judging-core/src/tables.test.ts delete mode 100644 packages/judging-core/src/tables.ts delete mode 100644 packages/judging-core/src/timers.test.ts delete mode 100644 packages/judging-core/src/timers.ts delete mode 100644 packages/judging-core/src/uncertainty.test.ts delete mode 100644 packages/judging-core/src/uncertainty.ts delete mode 100644 packages/judging-core/tsconfig.build.json delete mode 100644 packages/judging-core/tsconfig.json delete mode 100644 packages/judging-core/vitest.config.ts delete mode 100644 packages/judging-db/eslint.config.mjs delete mode 100644 packages/judging-db/migrations/0001_init.sql delete mode 100644 packages/judging-db/migrations/0002_feedback_token.sql delete mode 100644 packages/judging-db/migrations/0003_shared_database.sql delete mode 100644 packages/judging-db/package.json delete mode 100644 packages/judging-db/src/client.ts delete mode 100644 packages/judging-db/src/enums.ts delete mode 100644 packages/judging-db/src/env.ts delete mode 100644 packages/judging-db/src/index.ts delete mode 100644 packages/judging-db/src/migrate.ts delete mode 100644 packages/judging-db/src/schema.ts delete mode 100644 packages/judging-db/src/seed.ts delete mode 100644 packages/judging-db/tsconfig.json delete mode 100644 packages/judging-server/eslint.config.mjs delete mode 100644 packages/judging-server/package.json delete mode 100644 packages/judging-server/src/app.test.ts delete mode 100644 packages/judging-server/src/app.ts delete mode 100644 packages/judging-server/src/auth.test.ts delete mode 100644 packages/judging-server/src/auth.ts delete mode 100644 packages/judging-server/src/bus.test.ts delete mode 100644 packages/judging-server/src/bus.ts delete mode 100644 packages/judging-server/src/index.ts delete mode 100644 packages/judging-server/src/log.ts delete mode 100644 packages/judging-server/src/mail.ts delete mode 100644 packages/judging-server/src/metrics.ts delete mode 100644 packages/judging-server/src/router.ts delete mode 100644 packages/judging-server/src/services/arrive.ts delete mode 100644 packages/judging-server/src/services/catalog.ts delete mode 100644 packages/judging-server/src/services/compare.ts delete mode 100644 packages/judging-server/src/services/dispatch.concurrent.test.ts delete mode 100644 packages/judging-server/src/services/dispatch.ts delete mode 100644 packages/judging-server/src/services/event.integration.test.ts delete mode 100644 packages/judging-server/src/services/event.ts delete mode 100644 packages/judging-server/src/services/feedback.test.ts delete mode 100644 packages/judging-server/src/services/feedback.ts delete mode 100644 packages/judging-server/src/services/floor.test.ts delete mode 100644 packages/judging-server/src/services/floor.ts delete mode 100644 packages/judging-server/src/services/flow.integration.test.ts delete mode 100644 packages/judging-server/src/services/live.ts delete mode 100644 packages/judging-server/src/services/logs.test.ts delete mode 100644 packages/judging-server/src/services/logs.ts delete mode 100644 packages/judging-server/src/services/outbox.ts delete mode 100644 packages/judging-server/src/services/overtime.test.ts delete mode 100644 packages/judging-server/src/services/overtime.ts delete mode 100644 packages/judging-server/src/services/records.ts delete mode 100644 packages/judging-server/src/services/redis.integration.test.ts delete mode 100644 packages/judging-server/src/services/results.test.ts delete mode 100644 packages/judging-server/src/services/results.ts delete mode 100644 packages/judging-server/src/services/visit-status.integration.test.ts delete mode 100644 packages/judging-server/src/services/vote.test.ts delete mode 100644 packages/judging-server/src/services/vote.ts delete mode 100644 packages/judging-server/tsconfig.json delete mode 100644 packages/judging-server/vitest.config.ts create mode 100644 sites/mainweb/app/(portal)/admin/hackathons/[id]/events/[eventId]/code/page.tsx create mode 100644 sites/mainweb/app/(portal)/admin/judging/[hackathonId]/page.tsx create mode 100644 sites/mainweb/app/(portal)/admin/judging/[hackathonId]/qr/page.tsx delete mode 100644 sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/console.tsx delete mode 100644 sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/editors.tsx delete mode 100644 sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/page.tsx delete mode 100644 sites/mainweb/app/(portal)/api/panel/[...path]/route.ts create mode 100644 sites/mainweb/app/(portal)/hackathons/[id]/checkin/page.tsx create mode 100644 sites/mainweb/app/(portal)/hackathons/[id]/claim/page.tsx delete mode 100644 sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx create mode 100644 sites/mainweb/app/(portal)/judge/[hackathonId]/page.tsx create mode 100644 sites/mainweb/app/(portal)/judge/activate/[linkId]/route.ts create mode 100644 sites/mainweb/app/(portal)/judge/guest/[hackathonId]/page.tsx create mode 100644 sites/mainweb/app/(portal)/judge/guest/invalid-link/page.tsx delete mode 100644 sites/mainweb/app/(portal)/judge/panel/[hackathonId]/desk.tsx delete mode 100644 sites/mainweb/app/(portal)/judge/panel/[hackathonId]/page.tsx delete mode 100644 sites/mainweb/app/judging/[hackathonId]/board.tsx delete mode 100644 sites/mainweb/app/judging/[hackathonId]/feedback/[token]/page.tsx delete mode 100644 sites/mainweb/app/judging/[hackathonId]/page.tsx delete mode 100644 sites/mainweb/components/admin/hackathons/JudgeLiveBoard.tsx delete mode 100644 sites/mainweb/components/admin/hackathons/TableCards.tsx create mode 100644 sites/mainweb/components/admin/hackathons/points/EventCheckInControls.tsx create mode 100644 sites/mainweb/components/admin/hackathons/points/ManualCheckIn.tsx create mode 100644 sites/mainweb/components/admin/hackathons/points/ParticipantSearch.tsx create mode 100644 sites/mainweb/components/admin/hackathons/points/PointsAdminTab.tsx create mode 100644 sites/mainweb/components/admin/hackathons/points/PointsLedger.tsx create mode 100644 sites/mainweb/components/admin/hackathons/points/SaleStation.tsx create mode 100644 sites/mainweb/components/admin/hackathons/points/StoreItems.tsx create mode 100644 sites/mainweb/components/admin/hackathons/points/StorePurchases.tsx create mode 100644 sites/mainweb/components/admin/hackathons/points/StoreSettings.tsx create mode 100644 sites/mainweb/components/admin/hackathons/points/StoreTab.tsx create mode 100644 sites/mainweb/components/admin/hackathons/points/Toggle.tsx create mode 100644 sites/mainweb/components/admin/hackathons/points/pass.test.ts create mode 100644 sites/mainweb/components/admin/hackathons/points/pass.ts create mode 100644 sites/mainweb/components/admin/hackathons/points/use-debounced.ts create mode 100644 sites/mainweb/components/admin/judging/AnnouncementsSection.tsx create mode 100644 sites/mainweb/components/admin/judging/ChallengesSection.tsx create mode 100644 sites/mainweb/components/admin/judging/ChecklistSection.tsx create mode 100644 sites/mainweb/components/admin/judging/DangerZone.tsx create mode 100644 sites/mainweb/components/admin/judging/EntriesList.tsx delete mode 100644 sites/mainweb/components/admin/judging/JudgeMatrixView.tsx delete mode 100644 sites/mainweb/components/admin/judging/JudgingTools.tsx create mode 100644 sites/mainweb/components/admin/judging/LaunchSection.tsx create mode 100644 sites/mainweb/components/admin/judging/ProjectsSection.tsx delete mode 100644 sites/mainweb/components/admin/judging/RankingsView.tsx delete mode 100644 sites/mainweb/components/admin/judging/RoomAssignmentsView.tsx create mode 100644 sites/mainweb/components/admin/judging/RoomsSection.tsx create mode 100644 sites/mainweb/components/admin/judging/RubricSection.tsx create mode 100644 sites/mainweb/components/admin/judging/claims/ClaimsPanel.tsx create mode 100644 sites/mainweb/components/admin/judging/results/ResultsTab.tsx create mode 100644 sites/mainweb/components/admin/judging/schedule/ChoiceDialog.tsx create mode 100644 sites/mainweb/components/admin/judging/schedule/LiveBoard.tsx create mode 100644 sites/mainweb/components/admin/judging/schedule/RoomBoard.tsx create mode 100644 sites/mainweb/components/admin/judging/schedule/SchedulePlanner.tsx create mode 100644 sites/mainweb/components/admin/judging/schedule/ScheduleTab.tsx create mode 100644 sites/mainweb/components/admin/judging/schedule/format.ts create mode 100644 sites/mainweb/components/admin/judging/shared.tsx create mode 100644 sites/mainweb/components/hackathon/judging/HackerJudgingTab.tsx create mode 100644 sites/mainweb/components/hackathon/judging/Itinerary.tsx create mode 100644 sites/mainweb/components/hackathon/judging/event-time.ts create mode 100644 sites/mainweb/components/hackathon/points/CheckInCodeForm.tsx create mode 100644 sites/mainweb/components/hackathon/points/PointsTab.tsx create mode 100644 sites/mainweb/components/hackathon/points/format.test.ts create mode 100644 sites/mainweb/components/hackathon/points/format.ts create mode 100644 sites/mainweb/components/judge/DeskAnnouncements.tsx create mode 100644 sites/mainweb/components/judge/EvaluationSheet.tsx create mode 100644 sites/mainweb/components/judge/GuestNameGate.tsx create mode 100644 sites/mainweb/components/judge/JudgeDesk.tsx create mode 100644 sites/mainweb/components/judge/MySubmissions.tsx create mode 100644 sites/mainweb/components/judge/ProjectQueue.tsx create mode 100644 sites/mainweb/components/judge/RoomSwitcher.tsx create mode 100644 sites/mainweb/components/judge/Shortlist.tsx create mode 100644 sites/mainweb/components/judge/answers.test.ts create mode 100644 sites/mainweb/components/judge/answers.ts create mode 100644 sites/mainweb/components/judge/desk.test.ts create mode 100644 sites/mainweb/components/judge/desk.ts create mode 100644 sites/mainweb/components/judge/use-draft-autosave.ts create mode 100644 sites/mainweb/components/judge/use-server-now.ts delete mode 100644 sites/mainweb/lib/judging-prep.test.ts delete mode 100644 sites/mainweb/lib/judging-prep.ts delete mode 100644 sites/mainweb/lib/panel-held.ts delete mode 100644 sites/mainweb/lib/panel.server.ts delete mode 100644 sites/mainweb/lib/panel.ts diff --git a/.gitignore b/.gitignore index cbdd6f3f..f7fd22b2 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ build .DS_Store *.pem nul +__pycache__/ # Debug npm-debug.log* @@ -54,8 +55,6 @@ next-env.d.ts *.db *.db-journal migrations/ -!packages/judging-db/migrations/ -!packages/judging-db/migrations/** # Editor .vscode/* diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 1680889d..00000000 --- a/PLAN.md +++ /dev/null @@ -1,444 +0,0 @@ -# Judging platform plan - -**Repo:** `query` · **Drafted:** 2026-10-07 · **Working name:** `panel` (rename freely) - -A hackathon judging platform built as ordinary workspaces in this monorepo -(`packages/judging-*`, scope `@query/judging-*`) and served by the portal -(`sites/mainweb`). Hacklytics -is its first user. It is not being split into its own repository. - -The current judging code in `packages/api/src/routers/judge` and -`sites/mainweb` keeps running until cutover, then is deleted. - ---- - -## 0. Why build it this way - -**What exists** works for one event: pool dispatch with QR arrival -(`dispatch.ts`), per-judge z-scores with a Bayesian shrink (`rankings.ts`), -frozen results, printable table cards. It is also welded to one edition: a -five-column rubric in `judge_vote`, timers as constants, tracks as `text[]`, -a `"createx"` string in the matcher, one results track, table distance as -`abs(n1 - n2)`, auth and roles borrowed from the club portal, cache -per-instance and polling for every live view. - -**What "real infrastructure" means here** - -1. **A product, not a feature.** Own data model, own API, own UI, own deploy. - Anyone with Postgres can run it. The club site is a client of it. -2. **Everything is data.** Rubric, tracks, prizes, timers, rooms, scoring - weights, dispatch strategy, branding: rows and config, never code. -3. **Correct under load and partition.** Advisory locks and unique indexes for - every write that matters; realtime as a convenience layer that can be - switched off without losing correctness. -4. **Boring and portable.** Postgres, Node, one Docker image, optional Redis. - No vendor SDK in the core. Cloud bits live behind adapters. -5. **Pure core.** Every decision (next table, score, rank) is a function from - plain data to plain data, tested without a database. - ---- - -## 1. Layout - -``` -packages/judging-core/ @query/judging-core pure TS, zero runtime deps. rubric - validation, dispatch, distance, - uncertainty, aggregators, phase - state machine, timers -packages/judging-db/ @query/judging-db Drizzle schema, SQL migrations - (generated, committed), seed, demo -packages/judging-server/ @query/judging-server Node service: tRPC over HTTP, - OpenAPI mirror, WebSocket hub, auth - adapters, outbox + webhooks, - Prometheus metrics. A library: - mainweb mounts it at /api/panel -packages/judging-cli/ @query/judging-cli import/export/seed/migrate/doctor -sites/mainweb/ web portal routes: judge desk - (/judge/panel), organizer console - (/admin/judging/panel), public - board and team feedback (/judging) -docs/judging/ ADRs, deploy, API, rubric guide -``` - -Lint, typecheck, test and build run with the rest of the repo in `ci.yml`. -`packages/api` imports `@query/judging-*` like any other workspace (§7). - ---- - -## 2. Domain model (`@query/judging-db`) - -Multi-tenant from day one so one deployment can host many events; a -single-event self-host simply has one org and one event. - -``` -organization id, slug, name, branding jsonb (logo, colors, tagline) -event id, org_id, slug, name, starts_at, ends_at, - phase enum (setup | submissions_open | submissions_closed | - judging_live | judging_closed | published | archived) -event_config event_id pk, target_seconds, hard_limit_seconds, - walk_limit_seconds, submit_grace_seconds, - min_looks_per_project, dispatch_strategy (coverage|uncertainty), - pairwise_enabled, pairwise_weight, bayesian_c, - calibration_looks, calibration_weight, - feedback_cards_enabled, leaderboard_public bool, - board_poll_seconds (fallback when ws is off) - -track id, event_id, slug, name, kind (main|sponsor|special), - judge_group text, rubric_id null, position, is_active -prize id, track_id, place, title, amount, description - -rubric id, event_id, name, is_default -criterion id, rubric_id, position, key, label, description, - min, max, weight, anchors jsonb - -zone id, event_id, name, position -table event_id, number, zone_id, x null, y null unique(event_id, number) - -project id, event_id, external_id null, name, description, - team_name, members jsonb, links jsonb, table_number null, - zone_id null, qr_token uuid unique, arrived_first_at, - withdrawn_at -project_track project_id, track_id - -judge id, event_id, external_id null, name, email, org, title, - status (invited|applied|approved|suspended), - track_id null, zone_id null, is_lead - unique(event_id, email) - -visit id, event_id, judge_id, project_id, judge_group, - handed_out_at, arrived_at null, completed_at null, - voided_at null, void_reason null -vote id, visit_id unique, judge_id, project_id, event_id, - total numeric, comment, duration_seconds, is_calibration -vote_score vote_id, criterion_id, value unique(vote_id, criterion_id) -comparison id, event_id, judge_id, judge_group, a_project_id, - b_project_id, outcome (a|b|tie), created_at - -result_run id, event_id, computed_at, computed_by, config_snapshot jsonb, - published_at null, notes -result run_id, project_id, track_id, placement, score numeric, - rubric_component, pairwise_component, vote_count, - comparison_count, flags text[] - unique(run_id, project_id, track_id) - -event_log id, event_id, kind, actor jsonb, subject jsonb, payload jsonb, - created_at -- append only -outbox id, event_id, topic, payload jsonb, created_at, - delivered_at null, attempts -webhook id, org_id, url, secret, topics text[], is_active -api_key id, org_id, hashed_key, scopes text[], created_at, revoked_at - -user id, email, name -- platform identities -membership user_id, org_id, role (owner|admin|organizer|volunteer) -judge_identity judge_id, user_id -- which login is which judge -``` - -Design notes: - -- `visit` replaces `judge_queue`: it is a record of a hand-out, not a plan. - A visit without a vote past the hard limit is voided, not deleted. -- `vote.total` is derived at write time from `vote_score` and the criterion - weights; the score rows are the truth. -- `result_run` snapshots the config used, so a result is reproducible and an - admin can compute twice and compare before publishing one. -- `external_id` on project and judge is how an integrator (the club site, a - Devpost CSV, a Google Form) keeps its own identity in sync. -- Migrations are generated SQL files committed under `packages/judging-db/migrations`, - applied with `panel migrate`. No `drizzle-kit push` in this product; a - self-hoster needs reproducible, reviewable migrations. - ---- - -## 3. The core (`@query/judging-core`) - -No IO, no dates from the clock (every function takes `now`), no randomness -without an injected source. Each module has a fixture-based test file. - -- `phase.ts` state machine: allowed transitions, what each phase permits - (submit, apply, dispatch, vote, compute, publish). Server checks it on - every mutation. -- `rubric.ts` `validateScores(criteria, input)`, `weightedTotal(criteria, scores)`. -- `timers.ts` `isLive`, `isPastCutoff`, taking the config. -- `distance.ts` `distance(a, b, zones)`: same zone `abs(n1 - n2)`; different - zone a large constant plus the diff; Euclidean when `x, y` set. -- `dispatch.ts` `pickNext(candidates, judge, config, random)` with strategies: - - `coverage`: fewest looks, nearest, random (today's algorithm). - - `uncertainty`: coverage until `min_looks_per_project` everywhere, then - highest rank-uncertainty first. Uncertainty = variance of normalised - scores / sqrt(votes), plus a bonus for contested pairwise neighbours. -- `aggregate/zscore.ts`, `aggregate/bayes.ts`, `aggregate/bradleyTerry.ts`, - `aggregate/blend.ts`, `aggregate/rank.ts` composing them per track and - judge group, honouring calibration down-weighting. -- `uncertainty.ts` per-project vector, consumed by dispatch. -- `events.ts` typed `kind` union for the log, outbox topics and ws messages, - shared by server and web. - -Published to npm on release so anyone can run the ranking offline on an -export. - ---- - -## 4. Server (`@query/judging-server`) - -Node 22, Hono as the HTTP shell, tRPC router mounted on it, `trpc-openapi` -exposing the same procedures as REST with a generated OpenAPI document for -non-TypeScript integrators. Single process; horizontal scale is stateless -plus Redis. - -**Routers** - -- `org`, `event`, `config`, `track`, `prize`, `rubric`, `zone`, `table`: - organizer CRUD, phase-gated. -- `project`: import (API, CSV via CLI), assign tables (per zone, by track - balance), withdraw, QR token rotate. -- `judge`: invite, apply, approve, suspend, assign track and zone. -- `session` (judge-facing): `next`, `arrive(qrToken)`, `vote`, `compare`, - `skip`, `progress`, `rubric`. -- `results`: `compute` (new `result_run`), `diff(runA, runB)`, `publish`, - `unpublish`, `export`. -- `live`: snapshot for the admin board and public leaderboard. -- `feedback`: a team's card after publish. -- `webhook`, `apiKey`: integrator management. - -**Concurrency** - -- `pg_advisory_xact_lock(hashtext('dispatch:' || event_id))` around `next`, - exactly as today. Dispatch is milliseconds and runs a few times a minute - even at 60 judges. -- Unique indexes on `(judge_id, project_id)` across live visits (partial - index where `voided_at is null`), on `vote.visit_id`, on - `(run_id, project_id, track_id)`. -- Every mutation writes its `event_log` row and any `outbox` row in the same - transaction. The outbox is drained by the same process on a short loop (no - external scheduler) and by `panel outbox drain` for operators who want cron. - -**Realtime** - -- WebSocket hub on `/ws` (`ws` library). Clients subscribe to channels: - `event:{id}:board`, `event:{id}:leaderboard`, `judge:{id}`. -- Fan-out adapter interface: `InMemoryBus` (single instance, default) and - `RedisBus` (ioredis pub/sub) selected by `REDIS_URL`. Correctness never - depends on the bus; a client that misses a message gets the same state on - its next snapshot, and the web falls back to polling at `board_poll_seconds` - when the socket is down. -- Server pushes: dispatch handed out, arrival, vote landed, judge overtime, - phase change, results published. Admin can push `recall` to a judge - (bring them back to the desk) and `void` a visit. - -**Auth adapters** (`server/src/auth/`) - -- `magic-link`: email code, built in, zero config beyond SMTP. -- `oidc`: any provider (Google, GitHub, Okta) via `openid-client`. -- `trusted-jwt`: an integrator (the club site) mints a short-lived JWT with - `{ sub, email, name, org, role, judge_external_id }`; the server verifies - with a shared JWKS. This is how mainweb users land in the judge PWA with - no second login. -- `api-key`: for machine integrators (import, export, webhooks). - -Roles: `owner`, `admin`, `organizer`, `volunteer` on the org; `judge` is a -row on the event, not a role, and `is_lead` is a flag. Every procedure -declares the minimum role; there is no unguarded "protected" alias, the lesson -from `../PLAN.md` W8. - -**Observability** - -- `/metrics` Prometheus: dispatch latency, votes per minute, live judges, - coverage histogram per event, ws connections, outbox lag. -- Structured JSON logs with `event_id`, `judge_id`, `visit_id` on every line. -- `/healthz` (process), `/readyz` (Postgres, bus). - ---- - -## 5. Web (`@query/judging-web`) - -Next.js app, standalone output, one Docker image. Branding from -`organization.branding`; no hardcoded name or colours. - -- **Judge PWA** (`/j/[eventSlug]`): installable, works on a phone with one - hand. Screens: queue/next, walking (shows table, zone and a count-up), - arrive by camera QR or typed table number, score (criteria rendered from - rubric rows, anchors as tap targets, comment), compare (previous table vs - this one, one tap), done. Scores are held in IndexedDB until the server - acknowledges, so a dead Wi-Fi corner loses nothing; the server rejects a - late vote past the cutoff and the client shows why. -- **Admin console** (`/o/[orgSlug]/[eventSlug]`): phase control, rubric and - track editors, zone and table layout (grid with drag to set `x, y`), - judges (invite, approve, assign, recall), projects (import, tables, - withdraw), table cards print view per zone, live board (coverage heatmap by - table, judges on the floor, overtime, idle), results (compute, diff two - runs, inspect a project's rubric and pairwise components, publish), logs. -- **Public** (`/e/[orgSlug]/[eventSlug]`): leaderboard after publish, live - "judging in progress" board if `leaderboard_public` (no scores, just - coverage and phase), prize list per track. -- **Team feedback** (`/e/.../feedback/[token]`): per-criterion mean vs event - median, anonymised comments, placement per track or "not placed". Token - minted per project at publish; integrators can fetch it by `external_id`. - ---- - -## 6. CLI (`@query/judging-cli`) - -`panel migrate`, `panel seed demo` (an event with 40 projects, 6 judges, -three tracks, a sponsor rubric), `panel import projects ` (column map in -a yaml; Devpost export supported out of the box), `panel export results - --format csv|json`, `panel outbox drain`, `panel doctor` (checks -Postgres, Redis, migrations, config). - -CSV import was removed from the club site on purpose (`../PLAN.md` §"what not -to build"). It is in the product because a self-hoster has no other source. -The club adapter does not use it; it imports through the API. - ---- - -## 7. Integrating the club site - -Thin and replaceable. - -- `packages/api/src/routers/judging.ts` (new, small): on `promoteSubmissions` - calls `@query/judging-server` `project.upsert` with `external_id = hackathon_project.id`; - on judge approval calls `judge.upsert`; on publish, pulls results and - feedback tokens back into `hackathon_result` so `/hackathons/[id]` keeps - rendering from its own tables. -- The judge desk and organizer console are portal routes on the portal - session (`/judge/panel/...`, `/admin/judging/panel/...`). No handoff. -- Old `routers/judge/*` and the judging UI stay behind a feature flag per - edition (`hackathon.judging_backend = legacy | panel`) until one event has - run on the new system, then are deleted. -- Deploy: ships with mainweb. The API is `/api/panel` in the same process, - on the club Postgres (ADR-003). No separate service. - ---- - -## 8. Phases - -Each phase ships green on `pnpm test`, `lint --max-warnings 0`, `typecheck`, -`build`, and leaves the current event-day path untouched. - -### Phase 0 — Scaffold and core (1 week) - -- Workspaces, tsconfig, eslint boundary rule, `judging/README.md` with the - vision, MIT `LICENSE`, ADR-001 (why standalone), ADR-002 (pure core). -- Port `pickNext`, `isLive`, `isPastCutoff`, `projectMatchesTrack`, - `zNormalize`, the Bayesian step into `@query/judging-core` as parametrised - functions. Bring `dispatch.test.ts` cases with them. -- Add `distance`, `phase`, `rubric`, `bradleyTerry`, `blend`, `uncertainty` - with tests. - -**Verify:** `@query/judging-core` has no dependencies and 100% of exported functions -have a test. The legacy `rankings.ts` output on the seed equals -`@query/judging-core` `rank()` output byte for byte at `pairwise_weight = 0`. - -### Phase 1 — Data and server (2 weeks) - -- Schema and first migration. Seed and demo. -- Hono + tRPC + OpenAPI. Routers from §4 except `live` and `feedback`. -- Auth: magic-link and trusted-jwt. Roles enforced per procedure. -- Advisory lock dispatch, visit voiding, outbox table and in-process drain. -- `event_log` on every mutation. `/metrics`, `/healthz`, `/readyz`. -- Dockerfile, `docker-compose.yml` (Postgres, server). - -**Verify:** DB tests: two concurrent `next` calls never hand out the same -table; a vote after cutoff is rejected with the configured limit in the -message; phase machine refuses `vote` outside `judging_live`; every mutation -in the flow test produces exactly one log row. `panel doctor` passes in the -compose stack. - -### Phase 2 — Judge PWA and admin console (3 weeks) - -- Judge flow end to end with offline hold. Camera QR via `getUserMedia`. -- Admin: phases, rubric, tracks, prizes, zones, tables, judges, projects, - table cards, results compute and publish. -- Branding from org row. - -**Verify:** Playwright: seed demo, approve a judge, run a full visit on a -phone viewport, compute, publish, see the leaderboard. Kill the network -mid-score, restore, vote lands once. - -### Phase 3 — Realtime and live board (1 week) - -- WebSocket hub, `InMemoryBus`, `RedisBus`. Live board and leaderboard - subscribe; fallback polling. -- Admin `recall` and `void` pushes. - -**Verify:** two server instances behind compose with Redis; a vote on one -appears on a board connected to the other within one second. Stop Redis; -boards degrade to polling with a visible indicator and no errors. - -### Phase 4 — Hybrid scoring and uncertainty dispatch (2 weeks) - -- Compare screen, `comparison` rows, Bradley–Terry in results, blend - components visible in the results inspector and the run diff. -- `uncertainty` strategy with a cached vector per event (in-process, hint - only). -- Team feedback cards and tokens. - -**Verify:** property test: consistent comparisons produce the transitive -order at `w = 1`; `w = 0` reproduces Phase 2 results. Simulation: 200 -projects, 20 judges, three synthetic hours; top-10 Kendall tau is higher -under `uncertainty` than `coverage` at equal looks. Feedback returns 403 -before publish and for the wrong token. - -### Phase 5 — Club integration and first live event (2 weeks plus the event) - -- `routers/judging.ts` adapter, trusted-JWT handoff, results pull-back, - feature flag per edition. -- Terraform: two Cloud Run services, Neon connection via Secret Manager, - optional Memorystore, uptime check and alert on `/readyz`. -- Run a small internal event on `panel`, then Hacklytics. - -**Verify:** the legacy and new systems produce the same placings on a replay -of an old event's votes. Post-event retro recorded as ADR-00x. - -### Phase 6 — Cleanup (1 week) - -- Docs: quick start (`docker compose --profile judging up`, `panel seed demo`), - deploy guide, API reference from OpenAPI, rubric design guide, scoring - explainer. -- Delete `routers/judge/*` and the legacy judging UI from the club site. - -**Verify:** a fresh clone runs the demo in under five minutes with nothing -but Docker. - -Dropped: splitting judging into its own repository. It stays in this -monorepo. - ---- - -## 9. Decisions taken and open - -**Taken** - -- Judging lives in the monorepo as `@query/judging-*` workspaces on the club - Postgres (ADR-003). No later extraction. -- WebSockets on our own server with a bus adapter; Redis optional. -- Terraform for GCP as an optional module; Docker Compose is the primary - documented deploy. -- Committed SQL migrations, not `push`. -- Pairwise comparisons against the judge's previous table, not a - server-chosen comparator (no extra walk, no recall burden). Revisit after - the Phase 4 simulation. -- CSV import exists in the product; the club adapter does not use it. - -**Open** - -- **Name.** `panel` is a placeholder; check npm and GitHub availability - before Phase 6. -- **Rank visibility.** Teams outside the podium see "not placed", not a - number. Organizer's call per event; default off. -- **Hosted mode.** Multi-tenant tables exist, but billing, org sign-up and - abuse controls are not in this plan. Self-host first. -- **Peer voting.** Possible as `judge_group = 'peer'` with its own weight. - Not designed here. - ---- - -## 10. Not in this plan - -- A scheduler or job queue service. The outbox drain runs in-process; cron is - an operator option, not a requirement. -- Devpost API sync. CSV import covers it; an API adapter can come as a - community contribution. -- Replacing the club site's registration, check-in, teams or submissions. - Those stay in `packages/api`; only judging moves. -- Any change to `packages/db` schema beyond the `judging_backend` flag and the - feedback token column needed for cutover. diff --git a/docs/architecture.md b/docs/architecture.md index af80d523..5f6fa53b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,7 +33,7 @@ ## Two products, one database -The schema is split on purpose. Mixing them previously made membership vanish when a new hackathon edition was drafted. Panel judging tables live in this same Postgres database. +The schema is split on purpose. Mixing them previously made membership vanish when a new hackathon edition was drafted. ### Club @@ -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/judging/README.md b/docs/judging/README.md deleted file mode 100644 index 823e34c5..00000000 --- a/docs/judging/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# Panel - -Hackathon judging inside the portal. Rubrics, tracks, timers, rooms, and -dispatch strategy are rows, not code. It runs in the mainweb process on the -club Postgres, and the portal sign-in decides who is judging or organizing. - -## Layout - -| Path | Package | What it is | -| --- | --- | --- | -| `packages/judging-core/` | `@query/judging-core` | Pure functions. No I/O, no clock, no runtime dependencies. | -| `packages/judging-db/` | `@query/judging-db` | Schema, committed SQL migrations, demo seed. | -| `packages/judging-server/` | `@query/judging-server` | The judging API (Hono + tRPC) as a library. | -| `packages/judging-cli/` | `@query/judging-cli` | `panel migrate`, `panel seed demo`, `panel doctor`, `panel outbox drain`. | -| `packages/api/src/services/panel.ts` | `@query/api/panel` | One API instance per process, and the portal user as a judging actor. | - -## In the portal - -Judging is per hackathon edition. On `/admin/judging`, staff pick the edition -and press "Switch to panel judging". That creates the edition's judging event -(organization `hacklytics`, event slug = the hackathon id) in `setup`, with -default timers and the club's five criteria out of ten, then copies in the -submitted projects and the portal's judges. Switching back to classic leaves -the event in place. - -| Route | Who | What | -| --- | --- | --- | -| `/api/panel/*` | the pages below | The judging API. Same routes as `docs/judging/api.md`, without the prefix. | -| `/judge/panel/[hackathonId]` | approved judges | Judge desk: next table, QR or table number, scores, pairwise, offline hold. | -| `/admin/judging/panel/[hackathonId]` | staff | Organizer console: phases, floor, tables, rubric, tracks, judges, results. | -| `/judging/[hackathonId]` | anyone | Public board while judging, placements after publish. | -| `/judging/[hackathonId]/feedback/[token]` | a team | That team's feedback card after publish. | - -Roles come from the `admins` row: `super_admin` is owner, other staff are -admin, a volunteer row is volunteer, a bug tester has none. A judge is matched -by the email on their portal judge row. Approving a judge in the portal -approves them in judging, and deactivating suspends them. Promoting -submissions copies projects in, and pulling results writes published -placements back into `hackathon_result`. - -Live views poll, and only while they can change. The board polls at the -event's `board_poll_seconds` and the console polls the floor every five -seconds, both only during `judging_live` and only while the tab is visible. The judge -desk asks `/v1/session/status` every 15 seconds while a visit is open and the -screen is on, which is how a recall or a voided visit reaches the phone. -Neon suspends after five idle minutes and the free plan has 100 compute hours -a month, so a forgotten board in any other phase must not keep it awake. -`/api/panel/readyz` and `/api/panel/metrics` are not exposed for the same -reason. Judging uses the club's connection pool rather than a second one. - -## Checks - -`pnpm lint`, `pnpm typecheck`, and `pnpm test` at the root cover these -packages with the rest of the repository. - -## Database - -Postgres is the one database the rest of the monorepo uses (`DATABASE_URL`). -Panel migrations add its tables there. - -``` -docker compose up -d -$env:DATABASE_URL = "postgresql://postgres:postgres@localhost:5433/neondb" -pnpm --filter @query/judging-cli panel migrate -pnpm --filter @query/judging-cli panel seed demo -pnpm --filter @query/judging-cli panel doctor -``` - -Webhooks queue in `outbox`. Publishing results drains it; delivered rows are -deleted, since `event_log` keeps the history. A delivery that failed stays -queued for `panel outbox drain`. A Hacklytics-size event is on the order of -20 MB across the judging tables. diff --git a/docs/judging/adr/001-standalone.md b/docs/judging/adr/001-standalone.md deleted file mode 100644 index 0c335096..00000000 --- a/docs/judging/adr/001-standalone.md +++ /dev/null @@ -1,26 +0,0 @@ -# ADR-001: Standalone product - -## Status - -Superseded. Panel's packages are ordinary monorepo workspaces -(`packages/judging-*`) under the `@query/*` scope, served by the portal. The import -boundary and the plan to split it out were dropped. - -## Decision - -Panel lives under `judging/` with its own packages and its own deploy. It does -not share a module graph with the application that hosts the first event. -ADR-003 puts the tables in that application's Postgres instead of a second -database. - -Internal imports are `@panel/*` only. A lint rule rejects imports from the -host. The host is allowed to import `@panel/*`. That one-way edge is what -makes `git subtree split --prefix=judging` a later extraction rather than a -rewrite. - -## Why - -Judging welded into one event's tables cannot be given to the next event, or -to anyone else, without carrying the rest of that application with it. -The panel tables are prefixed so they can share that database until an -extraction. diff --git a/docs/judging/adr/002-pure-core.md b/docs/judging/adr/002-pure-core.md deleted file mode 100644 index f5bc4aa5..00000000 --- a/docs/judging/adr/002-pure-core.md +++ /dev/null @@ -1,22 +0,0 @@ -# ADR-002: Pure core - -## Status - -Accepted - -## Decision - -Every decision Panel makes — the next table, whether a visit is still live, -whether a score is valid, how a set of votes becomes a ranking — is a function -in `@query/judging-core`. Those functions take plain data, including `now` and a -`random` source, and return plain data. They do not read the clock, generate -randomness, or touch a database, the network, or the filesystem. - -The server is the only place that performs I/O. It loads rows, calls the -core, and writes the result in the same transaction as the event log. - -## Why - -A ranking you cannot recompute from the votes is not a result you can defend. -Tests of the core need no database, so the scoring bugs surface without -standing up Postgres. The same functions can later run offline on an export. diff --git a/docs/judging/adr/003-one-database.md b/docs/judging/adr/003-one-database.md deleted file mode 100644 index aa2d80d5..00000000 --- a/docs/judging/adr/003-one-database.md +++ /dev/null @@ -1,26 +0,0 @@ -# ADR-003: One database, one server - -## Status - -Accepted. Supersedes the separate-database sentence in ADR-001. - -## Decision - -Panel tables live in the same Postgres as the club app. The connection is -`DATABASE_URL`. `PANEL_DATABASE_URL` is read only when that is unset. The -tables that would collide with the club schema are `panel_user`, -`panel_event`, and `panel_judge`. - -There is one API process. Redis is optional. When `REDIS_URL` is unset or -Redis fails, that process keeps delivering live updates in memory. - -`judging/` stays in this monorepo. Splitting it out, and deleting the legacy -judge UI, wait until an event has run on panel. Sign-in mail waits on -`PANEL_SMTP_URL`. Neither connection is made yet. - -## Why - -The host is the existing app. A second database and a second API fleet were -the extraction plan, and they are not how this checkout runs. The replay of -the published score fixture still matches `@query/judging-core` `rank()` at pairwise -weight 0, so the scoring check does not need that event. diff --git a/docs/judging/api.md b/docs/judging/api.md deleted file mode 100644 index dd35ed3e..00000000 --- a/docs/judging/api.md +++ /dev/null @@ -1,17 +0,0 @@ -# API - -`GET /openapi.json` is the route list (OpenAPI 3.0.3). The same procedures are also mounted at `/trpc`. - -Send `Authorization: Bearer` with a JWT from `POST /v1/auth/verify`, or with an API key from `catalog.issueApiKey`. A key whose scopes include `import`, `export`, or `webhooks` acts as an organizer. A revoked key is rejected. - -These routes do not require a token: - -- `GET /healthz`, `GET /readyz`, `GET /metrics`, `GET /openapi.json` -- `POST /v1/auth/magic-link` and `POST /v1/auth/verify` -- `GET /v1/public/{orgSlug}/{eventSlug}` -- `GET /v1/live/{orgSlug}/{eventSlug}` -- `GET /v1/feedback/{token}` - -`GET /v1/results/{eventId}` requires a bearer token and returns 403 until results are published. Feedback returns 403 for an unknown token and before publish. - -Live clients connect to `/ws?channel=event:{eventId}:board`. The channels are `event:{id}:board`, `event:{id}:leaderboard`, and `judge:{id}`. diff --git a/docs/judging/contributing.md b/docs/judging/contributing.md deleted file mode 100644 index 8048ca15..00000000 --- a/docs/judging/contributing.md +++ /dev/null @@ -1,28 +0,0 @@ -# Contributing - -Panel is `packages/judging-*` plus its portal routes in `sites/mainweb` (listed in `README.md`). `pnpm lint`, `pnpm typecheck`, and `pnpm test` at the root cover it with the rest of the repository. - -## Checks - -From the repository root: - -``` -pnpm --filter @query/judging-core test -pnpm --filter @query/judging-server test -pnpm --filter @query/judging-cli test -pnpm --filter @query/judging-core lint -pnpm --filter @query/judging-server lint -pnpm --filter web lint -pnpm --filter @query/judging-db typecheck -pnpm --filter @query/judging-server typecheck -pnpm --filter web typecheck -pnpm --filter @query/judging-cli typecheck -``` - -## Migrations - -Schema changes are SQL files in `packages/judging-db/migrations`, applied with `panel migrate`. Do not rename or drop a column in the same change that ships code reading the new shape. - -## Pull requests - -Describe the behaviour a judge or an organizer will see. Include the command you ran. diff --git a/docs/judging/deploy.md b/docs/judging/deploy.md deleted file mode 100644 index f13a850c..00000000 --- a/docs/judging/deploy.md +++ /dev/null @@ -1,23 +0,0 @@ -# Deploy - -Judging ships with mainweb. There is no separate service or image: the API is -`/api/panel` in the portal, and the pages are portal routes (see `README.md`). - -Before the first event on panel, against the production database: - -``` -DATABASE_URL= -pnpm --filter @query/judging-cli panel migrate -pnpm --filter @query/judging-cli panel doctor -``` - -Then switch the edition to panel on `/admin/judging`. Nothing goes in the -environment: the event is created from the hackathon row. - -`packages/db/drizzle.config.ts` hides the judging tables from the deploy's -`drizzle-kit push` and declares the judging enum types -(`packages/judging-db/src/enums.ts`), so push neither tries to drop them nor -stalls on a prompt. - -`PANEL_JWT_SECRET` is only needed for callers outside the portal that send a -bearer token; API keys from `catalog.issueApiKey` work without it. diff --git a/docs/judging/rubric.md b/docs/judging/rubric.md deleted file mode 100644 index 70b42cc3..00000000 --- a/docs/judging/rubric.md +++ /dev/null @@ -1,7 +0,0 @@ -# Rubric - -A rubric is a named list of criteria. Each criterion has a key, a label, a minimum, a maximum, and a weight. The default rubric is what judges see. A track can point at a different rubric, which is how a sponsor prize asks different questions. - -Scores outside a criterion's range are rejected. The visit total is the weighted mean of the criteria. Comments are stored with the vote and shown on the team card only after results are published. - -Create one from the organizer console with the track form for a new prize group, or through `catalog.saveRubric` with the criteria in order. Anchors are the numbers a judge can tap; they are the integers from min to max. diff --git a/docs/judging/scoring.md b/docs/judging/scoring.md deleted file mode 100644 index 65d46488..00000000 --- a/docs/judging/scoring.md +++ /dev/null @@ -1,9 +0,0 @@ -# Scoring - -A project's rubric score is the weighted mean of its criterion values. Each judge's scores are then z-scored against that judge's own mean and spread and placed on the field's mean and spread, so a harsh judge and a lenient judge who ranked the same projects agree. - -Projects with few votes are shrunk toward the field average. The config value `bayesian_c` is how many average votes that prior is worth. At 2, two real votes and the prior weigh the same. - -Pairwise comparisons are a separate component. `pairwise_weight` is the share of the final score that comes from them. At 0 the ranking is the rubric score only, which matches a results run that never asked judges to compare tables. - -Publishing freezes one result run. A later run can be diffed against it before it is published. diff --git a/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/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 3e16ba56..4e9faf91 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 packages/judging-core packages/judging-server packages/judging-cli" + "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 795d7c4e..9c184f4f 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -19,7 +19,7 @@ "./metrics": "./src/services/metrics.ts", "./portal-context": "./src/types/portal-context.ts", "./resume-list": "./src/services/resume-list.ts", - "./panel": "./src/services/panel.ts" + "./judging-access": "./src/services/judging/principal.ts" }, "scripts": { "lint": "eslint . --max-warnings 0", @@ -29,8 +29,6 @@ "dependencies": { "@query/auth": "workspace:*", "@query/db": "workspace:*", - "@query/judging-db": "workspace:*", - "@query/judging-server": "workspace:*", "@tanstack/react-query": "5.103.2", "@trpc/client": "11.19.0", "@trpc/next": "11.19.0", @@ -45,7 +43,6 @@ "zod": "4.6.5" }, "devDependencies": { - "@query/judging-core": "workspace:*", "@query/tsconfig": "workspace:*", "@types/sanitize-html": "^2.16.0", "typescript": "7.0.2", diff --git a/packages/api/scripts/judging-schedule/cpsat.py b/packages/api/scripts/judging-schedule/cpsat.py new file mode 100644 index 00000000..9604723b --- /dev/null +++ b/packages/api/scripts/judging-schedule/cpsat.py @@ -0,0 +1,572 @@ +# /// 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) + + if problem.hint: + hinted = {} + 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) + + deadline = time.monotonic() + seconds + best: Placement | None = None + all_optimal = True + for position, goal in enumerate(stages): + remaining = deadline - time.monotonic() + # The first stage has to find any schedule at all, 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 = cp_model.CpSolver() + solver.parameters.max_time_in_seconds = max(1.0, share) + solver.parameters.num_workers = workers + solver.parameters.random_seed = 7 + if best is not None: + # Presolve costs seconds on a big event and every stage after the + # first already starts from a complete schedule, so skip it there. + solver.parameters.cp_model_presolve = False + model.minimize(goals[goal]) + status = solver.solve(model) + + if status == cp_model.INFEASIBLE and best is None: + return ("infeasible", None) + if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): + if best is None: + return ("unknown", None) + # Keep the previous schedule and hold this goal where it already + # is, so later goals cannot trade it away. + all_optimal = False + value = score(problem, best)[goal] * (-1 if goal == "breakSlots" else 1) + log(f" {goal}: {abs(value)} (no improvement within its time share, {solver.status_name(status)})") + model.add(goals[goal] <= value) + continue + + all_optimal = all_optimal and status == cp_model.OPTIMAL + best = {} + for i in range(len(tasks)): + r = next(room for room, lit in choices[i] if solver.boolean_value(lit)) + best[i] = (r, solver.value(start[i])) + value = int(solver.objective_value) + log(f" {goal}: {abs(value)}{'' if status == cp_model.OPTIMAL else ' (time limit)'}") + model.add(goals[goal] <= value) + model.clear_hints() + for v in tracked: + model.add_hint(v, solver.value(v)) + + 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") + 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; 1 makes runs repeatable; default 8") + args = parser.parse_args(argv) + + def log(message: str): + print(message, file=sys.stderr) + + try: + with open(args.problem, encoding="utf-8-sig") as handle: + problem = load_problem(json.load(handle)) + except (OSError, ValueError, KeyError, TypeError, InputError) as error: + log(f"cannot read {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, timed_out = "infeasible", None, False, 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) + timed_out = timed_out or status == "unknown" + 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 placement is None and timed_out: + # One attempt ran out of time rather than proving there is no schedule. + status = "unknown" + + 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/src/.internal-tests/hackathon-flow.test.ts b/packages/api/src/.internal-tests/hackathon-flow.test.ts index 6a920b27..c2ba5a7c 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; @@ -492,107 +491,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 +779,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/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..c70e8be2 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"), @@ -208,10 +208,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: { @@ -373,6 +373,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 +650,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); + }); }); // ===================================================================== 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..721cde67 100644 --- a/packages/api/src/.internal-tests/routers.test.ts +++ b/packages/api/src/.internal-tests/routers.test.ts @@ -1685,7 +1685,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 +1716,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/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts index 0955a4c8..0877fc9a 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 { @@ -960,7 +966,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 +988,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, }, }); @@ -1061,19 +1072,26 @@ export const hackathonAdminRouter = createTRPCRouter({ ), }); - const alreadyCheckedIn = `${participant.user.name || participant.user.email} is already checked into ${event.name}.`; + const attendance = { + event, + participant: { + id: participant.id, + name: participant.user.name || participant.user.email, + }, + method: "scan" as const, + actorId: ctx.userId, + }; if (existingScan) { throw new TRPCError({ code: "CONFLICT", - message: alreadyCheckedIn, + message: alreadyCheckedInMessage(attendance), }); } - // 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. + // 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. @@ -1081,20 +1099,7 @@ export const hackathonAdminRouter = createTRPCRouter({ 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; - } + 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 @@ -1134,7 +1139,144 @@ export const hackathonAdminRouter = createTRPCRouter({ return { success: true, checkedIn: promotedToCheckedIn, + pointsAwarded: event.points, message: `Successfully checked in ${participant.user.name || participant.user.email}!`, }; }), + + // 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(hackathonEvents.id, input.eventId), + eq(hackathonEvents.hackathonId, input.hackathonId), + ), + columns: { id: true }, + }); + + if (!event) { + throw new TRPCError({ code: "NOT_FOUND", message: "Event not found." }); + } + + const matches = await searchParticipants(db, { + hackathonId: input.hackathonId, + query: input.query, + limit: 20, + }); + + const attended = + matches.length === 0 + ? [] + : await db + .select({ participantId: hackathonEventAttendees.participantId }) + .from(hackathonEventAttendees) + .where( + and( + eq(hackathonEventAttendees.eventId, input.eventId), + inArray( + hackathonEventAttendees.participantId, + matches.map((match) => match.id), + ), + ), + ); + const inEvent = new Set(attended.map((row) => row.participantId)); + + return matches.map((match) => ({ + ...match, + checkedIntoEvent: inEvent.has(match.id), + })); + }), + + // 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." }); + } + + const name = fullName( + personName({ ...participant, accountName: participant.user.name }), + participant.user.email, + ); + + if (participant.registrationStatus !== "checked_in") { + throw new TRPCError({ + code: "FORBIDDEN", + message: `${name} has not checked in at the registration desk yet. Send them there first.`, + }); + } + + // 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: "FORBIDDEN", + message: `${event.name} has already ended. Credit somebody who was there with a points adjustment.`, + }); + } + + const { pointsAwarded } = await recordEventAttendance(db, { + event, + participant: { id: participant.id, name }, + method: "manual", + actorId: ctx.userId, + }); + + ctx.cache.delete(`hackathon:${input.hackathonId}:events`); + + return { name, eventName: event.name, pointsAwarded }; + }), }); 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/events.ts b/packages/api/src/routers/hackathon/events.ts index f540b715..87d66637 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,6 +93,8 @@ export const hackathonEventsRouter = createTRPCRouter({ location: input.location, startTime: input.startTime, endTime: input.endTime, + points: input.points, + selfCheckIn: input.selfCheckIn, }) .returning(); @@ -78,6 +116,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 }) => { @@ -153,7 +194,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 +233,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 +282,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/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index c0b65d45..44a289b6 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -5,91 +5,19 @@ 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 type { DrizzleDB } from "@query/db"; -import { isLive, loadGroups, loadPool } from "./dispatch"; -import { syncJudgeToPanel, syncProjectsToPanel } from "../../services/panel-sync"; - -// Judges draw tables from a shared pool (dispatch.ts) instead of holding a -// list built in advance. Clears rows an earlier version built that the judge -// 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 @@ -97,32 +25,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; }), @@ -185,176 +113,14 @@ 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 }) => { - const promoted = await (ctx.db as DrizzleDB).transaction(async (tx) => { - // Serializes concurrent promotions, so two organisers pressing the button - // together cannot both read the same max table number and duplicate it. - await tx - .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, - ); - - 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, - })), - ); - - 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, - }; - }); - await syncProjectsToPanel(ctx.db as DrizzleDB, input.hackathonId); - return promoted; - }), - setActive: isAdmin .input( z.object({ @@ -384,9 +150,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" }) @@ -397,53 +162,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 { @@ -470,106 +197,14 @@ export const judgeAdminRouter = createTRPCRouter({ } } - await syncJudgeToPanel(ctx.db as DrizzleDB, { - hackathonId: updated.hackathonId, - email: updated.email, - isActive: input.isActive, - }); - return { success: true, isActive: input.isActive, queuedProjects }; - }), - - // 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({ @@ -596,478 +231,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 fabca918..1b76e92d 100644 --- a/packages/api/src/routers/judge/portal.ts +++ b/packages/api/src/routers/judge/portal.ts @@ -4,32 +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"; -import { panelConsoleUrl, panelDeskUrl, setJudgingBackend } from "../../services/panel-sync"; +// 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, @@ -40,6 +70,7 @@ export const judgePortalRouter = createTRPCRouter({ isJudge: false, judgeId: null, name: null, + hackathonId: null, }; } @@ -48,6 +79,7 @@ export const judgePortalRouter = createTRPCRouter({ isJudge: boolean; judgeId: string | null; name: string | null; + hackathonId: string | null; }>(cacheKey); if (cached) return cached; @@ -63,89 +95,16 @@ 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; }), - /** The panel judge desk in the portal, only for an edition that opted in. */ - panelDesk: protectedProcedure.query(async ({ ctx }) => { - return panelDeskUrl(ctx.db as DrizzleDB, ctx.userId as string); - }), - - /** The panel organizer console in the portal when this edition uses panel. */ - panelConsole: isAdmin - .input(z.object({ hackathonId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - return panelConsoleUrl(ctx.db as DrizzleDB, input.hackathonId); - }), - - /** Moves an edition between classic judging and panel. Panel gets its event and a first sync. */ - setJudgingBackend: isAdmin - .input( - z.object({ - hackathonId: z.string().uuid(), - backend: z.enum(["legacy", "panel"]), - }), - ) - .mutation(async ({ ctx, input }) => { - await setJudgingBackend(ctx.db as DrizzleDB, input.hackathonId, input.backend); - return panelConsoleUrl(ctx.db as DrizzleDB, input.hackathonId); - }), - - getMyAssignments: protectedProcedure.query(async ({ ctx }) => { - const db = ctx.db as DrizzleDB; - - // 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; @@ -171,641 +130,154 @@ export const judgePortalRouter = createTRPCRouter({ 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. + // assignment's own status column — decides this. approved: activeById.get(row.judgeId) === true, })); }), - // 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) { + if (participant) { 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)) { - 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), - }); - - // 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, - }; - }); - }), - - // 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), + await tx.insert(judgeAssignments).values({ + judgeId: judge.id, + hackathonId: input.hackathonId, + track, + status: "pending", }); - 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, - }; + return { success: 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 05957556..00000000 --- a/packages/api/src/routers/judge/rankings.ts +++ /dev/null @@ -1,476 +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 { weightProjects } from "./weight"; -import { pullPanelResults } from "../../services/panel-sync"; - -// The whole ranking pipeline in one place, so the live view and the frozen -// snapshot cannot drift: two implementations of a scoring formula are two -// 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; - type VoteWithJudge = (typeof projects)[number]["votes"][number]; - const weighted = new Map( - weightProjects( - projects.map((project) => ({ - id: project.id, - votes: project.votes.map((vote) => ({ judgeId: vote.judgeId, score: vote.score })), - })), - ).map((row) => [row.id, row]), - ); - - const rawRankings = projects.map((project) => { - const scored = weighted.get(project.id); - const voteCount = project.votes.length; - const normalizedScores = scored?.normalizedScores ?? []; - const normalizedAvg = scored?.normalizedAvg ?? 0; - - // Raw scores (unadjusted) - const totalScore = project.votes.reduce((sum, v) => sum + v.score, 0); - const avgScore = voteCount > 0 ? totalScore / voteCount : 0; - - // 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", - })), - }; - }); - - const rankings = rawRankings.map((r) => { - const n = r.voteCount; - const weightedScore = weighted.get(r.project.id)?.weightedScore ?? 0; - const confidenceLevel: "NONE" | "LOW" | "MEDIUM" | "HIGH" = - n === 0 ? "NONE" : n === 1 ? "LOW" : n === 2 ? "MEDIUM" : "HIGH"; - const scoreShift = round2(r.normalizedAvg - r.avgScore); // how much bias-correction shifted this project - - 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 votedRows = [...weighted.values()].filter((row) => row.voteCount > 0); - const globalAvg = - votedRows.length > 0 - ? round2(votedRows.reduce((sum, row) => sum + row.normalizedAvg, 0) / votedRows.length) - : 0; - - const result = { - rankings, - globalAvg, - 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 }) => { - await pullPanelResults(ctx.db as DrizzleDB, input.hackathonId); - 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/judge/replay.test.ts b/packages/api/src/routers/judge/replay.test.ts deleted file mode 100644 index 7d844a15..00000000 --- a/packages/api/src/routers/judge/replay.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { rank } from "@query/judging-core"; -import { weightProjects } from "./weight"; - -const projects = [ - { - id: "A", - votes: [ - { judgeId: "j1", score: 8 }, - { judgeId: "j2", score: 9 }, - ], - }, - { - id: "B", - votes: [ - { judgeId: "j1", score: 5 }, - { judgeId: "j2", score: 9 }, - ], - }, - { - id: "C", - votes: [ - { judgeId: "j1", score: 2 }, - { judgeId: "j2", score: 6 }, - ], - }, -]; - -describe("legacy rankings and panel rank", () => { - it("places the published fixture the same way at pairwise weight 0", () => { - const legacy = weightProjects(projects) - .filter((row) => row.voteCount > 0) - .sort((a, b) => b.weightedScore - a.weightedScore || a.id.localeCompare(b.id)); - const panel = rank( - projects.map((project) => ({ id: project.id, trackIds: ["overall"] })), - projects.flatMap((project) => - project.votes.map((vote) => ({ - judgeId: vote.judgeId, - projectId: project.id, - judgeGroup: "main", - total: vote.score, - })), - ), - [], - { bayesianC: 2, pairwiseWeight: 0, calibrationWeight: 1 }, - ) - .filter((row) => row.placement !== null) - .sort((a, b) => (a.placement ?? 0) - (b.placement ?? 0)); - - expect(panel.map((row) => [row.projectId, row.score])).toEqual( - legacy.map((row) => [row.id, row.weightedScore]), - ); - expect(panel.map((row) => row.score)).toEqual([7.71, 6.94, 4.85]); - }); -}); diff --git a/packages/api/src/routers/judge/weight.ts b/packages/api/src/routers/judge/weight.ts deleted file mode 100644 index 3f9ada92..00000000 --- a/packages/api/src/routers/judge/weight.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { zNormalize } from "./helpers"; - -const round2 = (n: number) => Math.round(n * 100) / 100; -const C = 2; - -export type WeightVote = { judgeId: string; score: number }; -export type WeightProject = { id: string; votes: readonly WeightVote[] }; - -export type WeightedProject = { - id: string; - voteCount: number; - normalizedAvg: number; - weightedScore: number; - normalizedScores: number[]; -}; - -/** - * The ranking the club site publishes: z-score each judge, average per - * project, shrink toward the mean of those averages with c = 2. Panel's - * `rank()` at pairwise weight 0 is checked against this function. - */ -export function weightProjects(projects: readonly WeightProject[]): WeightedProject[] { - const scoresByJudge = new Map(); - for (const project of projects) { - for (const vote of project.votes) { - const existing = scoresByJudge.get(vote.judgeId) ?? []; - existing.push(vote.score); - scoresByJudge.set(vote.judgeId, existing); - } - } - - const allRawScores = [...scoresByJudge.values()].flat(); - const globalMean = - allRawScores.length > 0 - ? allRawScores.reduce((a, b) => a + b, 0) / allRawScores.length - : 0; - const globalVariance = - allRawScores.length > 0 - ? allRawScores.reduce((sum, value) => sum + (value - globalMean) ** 2, 0) / - allRawScores.length - : 1; - const globalStd = Math.sqrt(globalVariance) || 1; - - const normalizedScoreLookup = new Map>(); - for (const [judgeId, rawScores] of scoresByJudge.entries()) { - const normalized = zNormalize(rawScores, globalMean, globalStd); - const lookup = new Map(); - rawScores.forEach((raw, index) => { - const existing = lookup.get(raw); - const value = normalized[index]!; - lookup.set(raw, existing !== undefined ? (existing + value) / 2 : value); - }); - normalizedScoreLookup.set(judgeId, lookup); - } - - const raw = projects.map((project) => { - const voteCount = project.votes.length; - const normalizedScores = project.votes.map( - (vote) => normalizedScoreLookup.get(vote.judgeId)?.get(vote.score) ?? vote.score, - ); - const normalizedAvg = - voteCount > 0 - ? round2(normalizedScores.reduce((sum, value) => sum + value, 0) / voteCount) - : 0; - return { id: project.id, voteCount, normalizedAvg, normalizedScores }; - }); - - const voted = raw.filter((row) => row.voteCount > 0); - const globalAvg = - voted.length > 0 - ? round2(voted.reduce((sum, row) => sum + row.normalizedAvg, 0) / voted.length) - : 0; - - return raw.map((row) => ({ - ...row, - weightedScore: - row.voteCount > 0 - ? round2( - (row.voteCount / (row.voteCount + C)) * row.normalizedAvg + - (C / (row.voteCount + C)) * globalAvg, - ) - : 0, - })); -} diff --git a/packages/api/src/routers/judging/admin.ts b/packages/api/src/routers/judging/admin.ts new file mode 100644 index 00000000..09958198 --- /dev/null +++ b/packages/api/src/routers/judging/admin.ts @@ -0,0 +1,2254 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { + and, + asc, + count, + eq, + exists, + gte, + ilike, + inArray, + isNotNull, + isNull, + max, + 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, + portalOrigin, + revokeRoomAccess, + roomAppointmentCount, + roomLinkUrl, +} from "../../services/judging/rooms"; +import { + dropEvaluations, + dropRooms, + resetAll, + resetLaunch, + resetProjects, + resetSetup, +} from "../../services/judging/resets"; +import { + buildChecklist, + readChecklistFacts, + rubricLockReason, + stateChangeProblem, +} from "../../services/judging/checklist"; + +// 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 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 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`, + 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, + 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() }) + .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, + ); + return { + ...challenge, + effectiveMode: effectiveMode(challenges, challenge.id), + optIns: entries?.optIns ?? 0, + entries: entries?.total ?? 0, + rooms: + roomCounts.find((row) => row.challengeId === challenge.id) + ?.value ?? 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", "rooms (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, + 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) => ({ + ...building, + rooms: rooms.filter( + (room) => room.buildingId === building.id && !room.archivedAt, + ).length, + })), + 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, + 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, + 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; + } + }), + + /** + * 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(), + 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.challengeId === undefined && + input.buildingId === undefined + ) { + return { room, signedOut, challengeChanges }; + } + const [updated] = await tx + .update(judgingRooms) + .set({ + name: input.name, + 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. */ + reorderRooms: isAdmin + .input(hackathonInput.extend({ roomIds: z.array(uuid).max(500) })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + await db.transaction(async (tx) => { + await lockEdition(tx, input.hackathonId); + for (const [index, roomId] of input.roomIds.entries()) { + 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, + }; + }), + + /** 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); + 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, + }, + }); + 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..3362bd59 --- /dev/null +++ b/packages/api/src/routers/judging/desk.ts @@ -0,0 +1,1610 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { and, asc, desc, eq, ilike, inArray, isNull, or } from "drizzle-orm"; +import { + hackathons, + 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 } 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 on your judging room's door."; + +// 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 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, + 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; +} + +/** 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 to sit in. 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 rows = await db + .select({ + id: judgingRooms.id, + name: judgingRooms.name, + 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", + })), + }; + }), + + 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 in the room 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 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 in the room 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, 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 projects = entries + .filter( + (entry) => + !(input.hideJudged && evaluationOf.has(entry.id)) && + !( + roomFilter === "applied" && + presentationOf.get(entry.id)?.roomId !== seat?.roomId + ), + ) + .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, 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, + }, + 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..47529e88 --- /dev/null +++ b/packages/api/src/routers/judging/hacker.ts @@ -0,0 +1,1127 @@ +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, + 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..9753f33f --- /dev/null +++ b/packages/api/src/routers/judging/results.ts @@ -0,0 +1,738 @@ +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 { 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; + }); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "judging.results.publish", + resourceId: input.hackathonId, + severity: "warn", + metadata: { placements: published }, + }); + + 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..303f811c --- /dev/null +++ b/packages/api/src/routers/judging/schedule.ts @@ -0,0 +1,1140 @@ +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 { 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, + 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", + buildingName: rooms.get(id)?.buildingName ?? null, + }), + }; +} + +/** 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 in a building. Add a room 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")} hold ${d.capacity} in this window. Add a room, 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 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, + 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 or rooms 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 room and time are 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, + 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 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; + }), + + /** + * 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, + 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..f74361c9 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"; @@ -681,8 +681,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 +743,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)) @@ -803,10 +803,10 @@ export const teamRouter = createTRPCRouter({ } // 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 +815,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 +1000,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 +1152,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/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..130bf053 --- /dev/null +++ b/packages/api/src/services/judging/checklist.test.ts @@ -0,0 +1,184 @@ +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", hasBuilding: true, hasLink: true }, + { name: "Klaus 1447", 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("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. Add one on the Rooms tab.", + }); + + const noBuilding = { + ...ready, + scheduledRoots: [ + { + label: "General", + rooms: [ + { name: "Klaus 1116", hasBuilding: true, hasLink: false }, + { name: "Annex", 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", 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..115088fd --- /dev/null +++ b/packages/api/src/services/judging/checklist.ts @@ -0,0 +1,299 @@ +import { and, count, countDistinct, eq, isNull, sql } from "drizzle-orm"; +import { + judgingChallenges, + judgingEntries, + judgingEntryChallenges, + judgingRoomLinks, + judgingRooms, + judgingRubricItems, + judgingSchedules, +} from "@query/db"; +import { ensureJudgingConfig } from "./config"; +import type { JudgingDb } from "./config"; + +// The organiser's launch checklist: what has to be true before judges walk +// into rooms 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; 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 is needed.", + }; + } + for (const root of facts.scheduledRoots) { + if (root.rooms.length === 0) { + return { done: false, hint: `${root.label} has no room. 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 walks between rooms.`, + }; + } + } + const rooms = facts.scheduledRoots.reduce((n, r) => n + r.rooms.length, 0); + return { + done: true, + hint: `${plural(rooms, "room")} 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 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 their presentation times and rooms." + : "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, + 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, hasBuilding, hasLink }) => ({ + name, + 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..27307f01 --- /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 in your room." + : "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..13dfc836 --- /dev/null +++ b/packages/api/src/services/judging/itinerary.test.ts @@ -0,0 +1,408 @@ +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, + 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", 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", building: null }, + { name: "MLH 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..c48ed801 --- /dev/null +++ b/packages/api/src/services/judging/itinerary.ts @@ -0,0 +1,321 @@ +// 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 { 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; + 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; 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, 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/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..bf88e052 --- /dev/null +++ b/packages/api/src/services/judging/principal.test.ts @@ -0,0 +1,286 @@ +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 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 }; + 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", + "values", + "onConflictDoNothing", + ]) { + builder[method] = () => builder; + } + builder.limit = next; + builder.returning = next; + return builder; + }; + const db = { + select: () => chain("select"), + insert: () => chain("insert"), + }; + return { db: db as unknown as JudgingDb, calls }; +} + +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, eight-hour cookie", () => { + expect(guestCookieOptions()).toMatchObject({ + httpOnly: true, + sameSite: "lax", + path: "/", + maxAge: 8 * 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); + }); +}); diff --git a/packages/api/src/services/judging/principal.ts b/packages/api/src/services/judging/principal.ts new file mode 100644 index 00000000..1969ab26 --- /dev/null +++ b/packages/api/src/services/judging/principal.ts @@ -0,0 +1,506 @@ +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. + +export { JUDGING_GUEST_COOKIE }; + +export const GUEST_SESSION_ENDED = + "Your guest judging session has ended. Scan the QR code on your room's door 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; +} + +/** + * 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. + */ +export function guestCookieOptions() { + return { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + path: "/", + maxAge: GUEST_SESSION_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; + }; + +/** + * 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. + */ +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, + }; + } + } + + const token = newGuestToken(); + const expiresAt = new Date(now.getTime() + GUEST_SESSION_SECONDS * 1000); + await db.insert(judgingGuestSessions).values({ + tokenHash: hashGuestToken(token), + linkId: input.linkId, + roomId: link.roomId, + hackathonId: link.hackathonId, + expiresAt, + }); + + 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..1080234f --- /dev/null +++ b/packages/api/src/services/judging/rooms.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { portalOrigin, revokeRoomAccess, roomLinkUrl } 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); + }); +}); diff --git a/packages/api/src/services/judging/rooms.ts b/packages/api/src/services/judging/rooms.ts new file mode 100644 index 00000000..6443a063 --- /dev/null +++ b/packages/api/src/services/judging/rooms.ts @@ -0,0 +1,154 @@ +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; +} + +/** 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..18b154ae --- /dev/null +++ b/packages/api/src/services/judging/schedule/aamogh-cp-solver.test.ts @@ -0,0 +1,188 @@ +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 }; +} + +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("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..56d86792 --- /dev/null +++ b/packages/api/src/services/judging/schedule/aamogh-cp-solver.ts @@ -0,0 +1,1085 @@ +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 before the exact repair takes a fixed +// number of shots at the tightest projects. +// +// 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 trying relaxed breaks. */ +const UNPLACED_PATIENCE = 60; +/** + * Breaks come last and polishing has done most of the work, so the exact + * repair gets a fixed number of tries at them, however they go. + */ +const BREAKS_KICKS = 60; + +/** 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); + + // 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); + + return { + R, + T, + S, + C, + E, + sameGap: inputs.sameGap, + crossGap: relaxed ? inputs.sameGap : inputs.crossGap, + roomChallenge, + roomBuilding, + taskChallenge, + taskEntry, + taskSponsor, + entryTasks, + classesOf, + 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 { + const m = this.m; + if (this.grid[r * m.S + s] !== -1) return false; + const building = m.roomBuilding[r]!; + 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] === building ? 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) order.push(t); + order.sort((a, b) => this.slotOf[a]! - this.slotOf[b]!); + 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; + } +} + +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); + } + } + } +} + +/** 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); + + // Every value for t inside the caps, one room per symmetry class and slot. + const enumerate = (t: number, limit: number): number => { + const cap = m.taskSponsor[t] ? Math.min(caps.sponsor, caps.all) : caps.all; + 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; + if (count >= limit) return count; + } + break; + } + } + } + return count; + }; + + 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 remaining = freed.slice(); + const placedHere: number[] = []; + + const search = (): boolean => { + if (remaining.length === 0) return true; + if (counter.nodes > budgetEnd) return false; + + // Fail-first: fewest values. Counting stops early past the best so far. + let pickIndex = -1; + let pickCount = Infinity; + for (let i = 0; i < remaining.length; i += 1) { + const count = enumerate(remaining[i]!, Math.min(pickCount, 64)); + if (count < pickCount) { + pickCount = count; + pickIndex = i; + if (count === 0) break; + } + } + const t = remaining[pickIndex]!; + remaining.splice(pickIndex, 1); + + if (pickCount === 0) { + if (partial) { + const done = search(); + if (!done) remaining.splice(pickIndex, 0, t); + return done; + } + remaining.splice(pickIndex, 0, t); + return false; + } + + const count = enumerate(t, Infinity); + 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); + + for (const [r, s] of tries) { + counter.nodes += 1; + board.place(t, r, s); + placedHere.push(t); + if (search()) return true; + placedHere.pop(); + board.lift(t); + if (counter.nodes > budgetEnd) break; + } + remaining.splice(pickIndex, 0, t); + 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 projects (all of them, or those in `only`); 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, + only?: Uint8Array, +): 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) visits.push(t); + if (visits.length < 2) return 0; + visits.sort((a, b) => board.slotOf[a]! - board.slotOf[b]!); + 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); + if (!only || only[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]!; + 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); + cost(e); + return false; + } + const f = m.taskEntry[u]!; + if (f === e) return false; + tries += 1; + 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); + // The trial costs overwrote the cached change counts; put them back. + cost(e); + cost(f); + return false; + }; + + // Whether t's own project-mates leave it room at slot s in building b. The + // same for every room of a class, so one check rules out the whole class. + const mateRoom = (t: number, b: number, s: number) => { + for (const v of m.entryTasks[m.taskEntry[t]!]!) { + if (v === t || board.roomOf[v]! < 0) continue; + const gap = m.roomBuilding[board.roomOf[v]!] === b ? m.sameGap : m.crossGap; + if (Math.abs(s - board.slotOf[v]!) - 1 < gap) return false; + } + return true; + }; + + 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) { + if (!mateRoom(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 (!mateRoom(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; +} + +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); + 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; + let polished = false; + const afterPolish = new Board(m); + let kicks = 0; + + 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; + + // Changes and breaks go to the cheap trades first; the exact repair only + // gets what trading cannot reach, and a few kicks to escape it. + if (goal >= CHANGES && !polished) { + polished = true; + saved.copyFrom(board); + stats.nodes += polish(m, board, current[FINISH]!, polishBudget); + afterPolish.copyFrom(board); + board.score(trial); + if (lexCompare(trial, current) <= 0) { + current.set(trial); + if (lexCompare(current, bestScore) < 0) { + bestScore.set(current); + best.copyFrom(board); + stats.improvements += 1; + } + } else { + board.copyFrom(saved); + } + continue; + } + if (goal === BREAKS && (kicks += 1) > BREAKS_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.score(trial); + if (done && lexCompare(trial, current) <= 0) { + const better = lexCompare(trial, current) < 0; + current.set(trial); + if (better) stalled.fill(0, goal); + else stalled[goal] += 1; + if (lexCompare(current, bestScore) < 0) { + bestScore.set(current); + best.copyFrom(board); + stats.improvements += 1; + } + } else { + board.copyFrom(saved); + 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) { + const moved = new Uint8Array(m.E); + for (let t = 0; t < m.T; t += 1) { + if (board.roomOf[t] !== afterPolish.roomOf[t] || board.slotOf[t] !== afterPolish.slotOf[t]) { + moved[m.taskEntry[t]!] = 1; + } + } + stats.nodes += polish(m, board, bestScore[FINISH]!, polishBudget, moved); + 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); + const 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..56100af9 --- /dev/null +++ b/packages/api/src/services/judging/schedule/cpsat-bridge.ts @@ -0,0 +1,157 @@ +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. + +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 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/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..3dd7dd56 --- /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 a room 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/panel-sync.ts b/packages/api/src/services/panel-sync.ts deleted file mode 100644 index 99ed01ff..00000000 --- a/packages/api/src/services/panel-sync.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { and, eq, inArray, sql } from "drizzle-orm"; -import { hackathonProjects, hackathonResults, hackathons, judges, judgingProjects } from "@query/db"; -import type { DrizzleDB } from "@query/db"; -import { judge as panelJudge } from "@query/judging-db"; -import { publishedPlacements } from "@query/judging-server"; -import { panel, panelAsPortal, panelEventFor } from "./panel"; - -/** - * Copies an edition into judging when that edition has opted in. - * A failure here does not undo the club write. The portal stays the source - * of registration, and the same action can be run again. - */ -export async function syncProjectsToPanel(db: DrizzleDB, hackathonId: string) { - try { - const target = await panelTarget(db, hackathonId); - if (!target) return; - const rows = await db - .select({ - id: hackathonProjects.id, - name: hackathonProjects.name, - }) - .from(hackathonProjects) - .where( - and( - eq(hackathonProjects.hackathonId, hackathonId), - inArray(hackathonProjects.status, ["submitted", "judging"]), - ), - ); - const caller = panelAsPortal(); - for (const row of rows) { - await caller.project.upsert({ - eventId: target.eventId, - externalId: row.id, - name: row.name, - teamName: null, - tableNumber: null, - }); - } - } catch { - return; - } -} - -/** - * Mirrors a portal judge into the edition's judging event. Approval in the - * portal is approval in judging; deactivating suspends them there. - */ -export async function syncJudgeToPanel( - db: DrizzleDB, - judge: { hackathonId: string; email: string | null; isActive: boolean }, -) { - try { - if (!judge.email) return; - const target = await panelTarget(db, judge.hackathonId); - if (!target) return; - await mirrorJudge(target.eventId, judge.email, judge.isActive); - } catch { - return; - } -} - -async function mirrorJudge(eventId: string, email: string, isActive: boolean) { - const caller = panelAsPortal(); - // Upsert is refused once judging is closed; a judge already there can - // still be suspended or reinstated. - const stored = await caller.judge - .upsert({ eventId, email, name: email, externalId: null }) - .catch(() => undefined); - let judgeId = stored?.id; - if (!judgeId) { - const [row] = await panel() - .db.select({ id: panelJudge.id }) - .from(panelJudge) - .where(and(eq(panelJudge.eventId, eventId), eq(panelJudge.email, email.toLowerCase()))); - judgeId = row?.id; - } - if (!judgeId) return; - await caller.judge.setStatus({ - eventId, - judgeId, - status: isActive ? "approved" : "suspended", - }); -} - -/** - * Moves an edition onto panel judging: creates its event, then copies in the - * submitted projects and the judges the portal has approved. Switching back - * leaves the panel event in place, so switching again resumes it. - */ -export async function setJudgingBackend( - db: DrizzleDB, - hackathonId: string, - backend: "legacy" | "panel", -) { - await db.update(hackathons).set({ judgingBackend: backend }).where(eq(hackathons.id, hackathonId)); - if (backend === "legacy") return; - const target = await panelTarget(db, hackathonId); - if (!target) return; - await syncProjectsToPanel(db, hackathonId); - const rows = await db - .select({ email: judges.email, isActive: judges.isActive }) - .from(judges) - .where(eq(judges.hackathonId, hackathonId)); - for (const row of rows) { - if (row.email) await mirrorJudge(target.eventId, row.email, row.isActive); - } -} - -async function panelTarget(db: DrizzleDB, hackathonId: string) { - const [row] = await db - .select({ id: hackathons.id, name: hackathons.name, backend: hackathons.judgingBackend }) - .from(hackathons) - .where(eq(hackathons.id, hackathonId)); - if (row?.backend !== "panel") return null; - return panelEventFor(row); -} - -export async function pullPanelResults(db: DrizzleDB, hackathonId: string) { - const target = await panelTarget(db, hackathonId); - if (!target) return 0; - const body = await publishedPlacements(panel().db, target.eventId); - if (!body) throw new Error("Panel event not found"); - if (!body.published) throw new Error("Panel results are not published"); - const judging = await db - .select({ id: judgingProjects.id, sourceProjectId: judgingProjects.sourceProjectId }) - .from(judgingProjects) - .where(eq(judgingProjects.hackathonId, hackathonId)); - const bySource = new Map( - judging.flatMap((row) => - row.sourceProjectId ? [[row.sourceProjectId, row.id] as const] : [], - ), - ); - let written = 0; - for (const row of body.rows) { - if (!row.externalId || row.placement === null) continue; - const projectId = bySource.get(row.externalId); - if (!projectId) continue; - await db - .insert(hackathonResults) - .values({ - hackathonId, - projectId, - sourceProjectId: row.externalId, - track: row.track || "overall", - placement: row.placement, - weightedScore: row.score.toFixed(2), - voteCount: row.voteCount, - }) - .onConflictDoUpdate({ - target: [ - hackathonResults.hackathonId, - hackathonResults.projectId, - hackathonResults.track, - ], - set: { - placement: sql`excluded.placement`, - weightedScore: sql`excluded.weighted_score`, - voteCount: sql`excluded.vote_count`, - sourceProjectId: row.externalId, - }, - }); - written += 1; - } - return written; -} - -export async function panelDeskUrl(db: DrizzleDB, userId: string) { - const [row] = await db - .select({ hackathonId: judges.hackathonId }) - .from(judges) - .innerJoin(hackathons, eq(hackathons.id, judges.hackathonId)) - .where( - and( - eq(judges.userId, userId), - eq(judges.isActive, true), - eq(hackathons.judgingBackend, "panel"), - ), - ); - if (!row) return { url: null as string | null }; - return { url: `/judge/panel/${row.hackathonId}` }; -} - -export async function panelConsoleUrl(db: DrizzleDB, hackathonId: string) { - const [row] = await db - .select({ backend: hackathons.judgingBackend }) - .from(hackathons) - .where(eq(hackathons.id, hackathonId)); - const backend = row?.backend ?? "legacy"; - return { - backend, - url: backend === "panel" ? `/admin/judging/panel/${hackathonId}` : null, - }; -} diff --git a/packages/api/src/services/panel.ts b/packages/api/src/services/panel.ts deleted file mode 100644 index f22e18c1..00000000 --- a/packages/api/src/services/panel.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import { admins, db as clubDb, hackathons, judges } from "@query/db"; -import type { DrizzleDB } from "@query/db"; -import { createDbFromPool } from "@query/judging-db"; -import type { PanelDb } from "@query/judging-db"; -import { - InMemoryBus, - appRouter, - createApp, - createMetrics, - ensureEvent, -} from "@query/judging-server"; -import type { Actor, Role } from "@query/judging-server"; -import { createContext } from "../context"; -import { - isBugTesterRole, - isExpiredAdmin, - isStaffRole, -} from "../types/portal-context"; - -/** Every hackathon edition is one judging event in this organization. */ -export const PANEL_ORG = { slug: "hacklytics", name: "Hacklytics" } as const; - -/** The judging event for an edition, keyed by the hackathon id. Created on first use. */ -export async function panelEventFor(hackathon: { id: string; name: string }) { - return ensureEvent(panel().db, { - orgSlug: PANEL_ORG.slug, - orgName: PANEL_ORG.name, - eventSlug: hackathon.id, - name: hackathon.name, - }); -} - -type Embedded = { - db: PanelDb; - app: ReturnType; - bus: InMemoryBus; - metrics: ReturnType; -}; - -const globalForPanel = globalThis as unknown as { panel?: Embedded }; - -/** - * Judging runs inside the portal: same process, same Postgres, and the portal - * sign-in decides who the caller is. It borrows the club's pool rather than - * opening its own: the club pool is tuned to let Neon scale to zero, and a - * second pool would hold a second set of connections to the same database. - * One instance per process, so the metrics registry is not rebuilt per request. - */ -export function panel(): Embedded { - if (globalForPanel.panel) return globalForPanel.panel; - if (!clubDb) throw new Error("DATABASE_URL is not set"); - const { db } = createDbFromPool(clubDb.$client); - const metrics = createMetrics(); - const bus = new InMemoryBus(); - const app = createApp({ - db, - metrics, - bus, - jwtSecret: process.env.PANEL_JWT_SECRET ?? "", - devAuth: false, - busMode: "memory", - resolveActor: () => portalActor(), - }); - globalForPanel.panel = { db, app, bus, metrics }; - return globalForPanel.panel; -} - -/** The judging procedures, called by the portal itself to sync an edition. */ -export function panelAsPortal() { - const { db, bus, metrics } = panel(); - return appRouter.createCaller({ - db, - bus, - metrics, - now: new Date(), - actor: { - sub: "portal", - email: "portal", - name: "Portal", - org: null, - role: "admin", - judgeExternalId: null, - }, - }); -} - -/** - * The signed-in portal user as a judging actor. Staff organize, a volunteer - * row is a volunteer, and a judge on a panel edition is matched by the email - * on their judge row, which is the one synced into judging. - */ -export async function portalActor(): Promise { - const ctx = await createContext(); - const email = ctx.session?.user?.email; - if (!ctx.db || !ctx.userId || !email) return null; - const db = ctx.db as DrizzleDB; - const [admin, judgeRows] = await Promise.all([ - db.query.admins.findFirst({ - where: and(eq(admins.userId, ctx.userId), eq(admins.isActive, true)), - }), - db - .select({ email: judges.email }) - .from(judges) - .innerJoin(hackathons, eq(hackathons.id, judges.hackathonId)) - .where( - and( - eq(judges.userId, ctx.userId), - eq(judges.isActive, true), - eq(hackathons.judgingBackend, "panel"), - ), - ), - ]); - return { - sub: ctx.userId, - email: judgeRows[0]?.email ?? email, - name: ctx.session?.user?.name ?? email, - org: PANEL_ORG.slug, - role: roleFor(admin ?? null), - judgeExternalId: null, - }; -} - -function roleFor( - admin: { role: string; expiresAt: Date | null } | null, -): Role | null { - if (!admin || isExpiredAdmin(admin) || isBugTesterRole(admin.role)) { - return null; - } - if (admin.role === "super_admin") return "owner"; - if (isStaffRole(admin.role)) return "admin"; - return "volunteer"; -} diff --git a/packages/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..677258f9 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 = { @@ -380,20 +403,6 @@ 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": [], @@ -401,7 +410,6 @@ const CACHE_INVALIDATION_MAP: Record = { // 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 +455,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 +617,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..87fa2c89 100644 --- a/packages/auth/src/email.ts +++ b/packages/auth/src/email.ts @@ -218,7 +218,7 @@ 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.", + "On the day, scan the QR code on your judging room's door to take your seat, then score each project as it presents.", ], ctaLabel: "Open the judge portal", ctaUrl: `${host}/judge`, @@ -353,6 +353,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. Your judging times, rooms and 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 1fa702c4..b26aefcb 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -1,6 +1,5 @@ import { defineConfig } from "drizzle-kit"; import * as dotenv from "dotenv"; -import fs from "fs"; import path from "path"; // Load .env from root (two levels up) @@ -10,29 +9,44 @@ if (!process.env.DATABASE_URL) { throw new Error("DATABASE_URL is not defined in .env file"); } -// Judging (packages/judging-db) keeps its tables in this database and applies -// them with its own SQL migrations. Left visible, push sees them as tables to -// drop and stops at a prompt; the build has no TTY, so the prompt errors, push -// exits 0, and every club schema change after that is silently skipped. -// The list comes from the migration files, so a new judging table is covered. -const judgingMigrations = path.resolve(__dirname, "../judging-db/migrations"); -const judgingTables = [ +// 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", - ...fs - .readdirSync(judgingMigrations) - .filter((file) => file.endsWith(".sql")) - .flatMap((file) => - [ - ...fs - .readFileSync(path.join(judgingMigrations, file), "utf8") - .matchAll(/^create table (\w+)/gm), - ].map((match) => match[1] as string), - ), + "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", "../judging-db/src/enums.ts"], - tablesFilter: judgingTables.map((table) => `!${table}`), + 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 1df9860c..1de48ce3 100644 --- a/packages/db/src/schemas/hackathons.ts +++ b/packages/db/src/schemas/hackathons.ts @@ -56,11 +56,19 @@ export const hackathons = pgTable( websiteUrl: text("website_url"), isPublic: boolean("is_public").notNull().default(true), judgingActive: boolean("judging_active").notNull().default(false), - // legacy keeps the current judge portal. panel hands the same edition to - // the standalone judging service. Additive: existing rows stay on legacy. + // 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(), }, @@ -408,12 +416,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`), ], ); @@ -427,6 +447,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), @@ -440,6 +468,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"), ], ); 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..8fe098df --- /dev/null +++ b/packages/db/src/schemas/judging.ts @@ -0,0 +1,1040 @@ +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), + ], +); + +// A room judges sit in. 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(), + 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/packages/judging-cli/eslint.config.mjs b/packages/judging-cli/eslint.config.mjs deleted file mode 100644 index b1dfd25a..00000000 --- a/packages/judging-cli/eslint.config.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import { config } from "@query/eslint-config/base"; - -/** @type {import("eslint").Linter.Config[]} */ -export default [ - ...config, - { - files: ["src/**/*.ts"], - rules: { "no-console": "off" }, - }, -]; diff --git a/packages/judging-cli/package.json b/packages/judging-cli/package.json deleted file mode 100644 index 36268c76..00000000 --- a/packages/judging-cli/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@query/judging-cli", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "panel": "tsx src/main.ts", - "lint": "eslint . --max-warnings 0", - "typecheck": "tsc --noEmit", - "test": "node ../../node_modules/vitest/vitest.mjs run src/csv.test.ts" - }, - "dependencies": { - "@query/judging-db": "workspace:*", - "@query/judging-server": "workspace:*", - "drizzle-orm": "0.45.3", - "tsx": "^4.23.15" - }, - "devDependencies": { - "@query/eslint-config": "workspace:*", - "@query/tsconfig": "workspace:*", - "@types/node": "22.20.4", - "typescript": "7.0.2" - } -} diff --git a/packages/judging-cli/src/csv.test.ts b/packages/judging-cli/src/csv.test.ts deleted file mode 100644 index b451f79c..00000000 --- a/packages/judging-cli/src/csv.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { formatResultsCsv, formatResultsJson, parseProjectCsv } from "./csv"; - -describe("parseProjectCsv", () => { - it("reads a Devpost-style header", () => { - const rows = parseProjectCsv( - "Project Title,Submission Url,Team Name,Table\nAlpha,https://example.com/a,A Team,4\n", - ); - expect(rows).toEqual([ - { - externalId: "https://example.com/a", - name: "Alpha", - teamName: "A Team", - tableNumber: 4, - }, - ]); - }); - - it("uses a column map when the headers are not the defaults", () => { - const rows = parseProjectCsv("Title,Squad\nBeta,B Team\n", { name: "Title", team: "Squad" }); - expect(rows[0]?.name).toBe("Beta"); - expect(rows[0]?.teamName).toBe("B Team"); - }); - - it("rejects a sheet with no name column", () => { - expect(() => parseProjectCsv("notes\nhello\n")).toThrow(/name/); - }); -}); - -describe("formatResultsCsv", () => { - it("quotes a name that contains a comma", () => { - expect( - formatResultsCsv([{ project: "A, B", track: "general", placement: 1, score: 7.71 }]), - ).toBe('project,track,placement,score\n"A, B",general,1,7.71\n'); - }); - - it("writes the same rows as JSON", () => { - const rows = [{ project: "Alpha", track: "general", placement: 1, score: 7.71 }]; - expect(JSON.parse(formatResultsJson(rows))).toEqual(rows); - }); -}); diff --git a/packages/judging-cli/src/csv.ts b/packages/judging-cli/src/csv.ts deleted file mode 100644 index 3414472a..00000000 --- a/packages/judging-cli/src/csv.ts +++ /dev/null @@ -1,127 +0,0 @@ -export type ImportedProject = { - externalId: string; - name: string; - teamName: string | null; - tableNumber: number | null; -}; - -/** Devpost exports and a plain name,team,table sheet. The first row is headers. - * A column map renames headers: `name: Project Title`. - */ -export function parseProjectCsv( - text: string, - columnMap: Record = {}, -): ImportedProject[] { - const rows = parseRows(text); - const header = rows[0]; - if (!header) return []; - const index = new Map(header.map((cell, i) => [cell.trim().toLowerCase(), i])); - const nameAt = column(index, columnMap.name, ["project title", "name"]); - if (nameAt === undefined) { - throw new Error("CSV needs a name or Project Title column"); - } - const teamAt = column(index, columnMap.team, ["team name", "team"]); - const tableAt = column(index, columnMap.table, ["table", "table number"]); - const idAt = column(index, columnMap.externalId, ["submission url", "external id", "id"]); - - return rows.slice(1).flatMap((row) => { - const name = row[nameAt]?.trim(); - if (!name) return []; - const tableRaw = tableAt === undefined ? "" : (row[tableAt] ?? ""); - const tableNumber = tableRaw.trim() === "" ? null : Number(tableRaw); - return [ - { - externalId: (idAt === undefined ? "" : (row[idAt] ?? "")).trim() || name, - name, - teamName: teamAt === undefined ? null : row[teamAt]?.trim() || null, - tableNumber: tableNumber !== null && Number.isFinite(tableNumber) ? tableNumber : null, - }, - ]; - }); -} - -export type ExportedResult = { - project: string; - track: string; - placement: number | null; - score: number; -}; - -export function formatResultsCsv(rows: readonly ExportedResult[]): string { - const lines = ["project,track,placement,score"]; - for (const row of rows) { - lines.push( - [cell(row.project), cell(row.track), row.placement ?? "", row.score].join(","), - ); - } - return `${lines.join("\n")}\n`; -} - -export function formatResultsJson(rows: readonly ExportedResult[]): string { - return `${JSON.stringify(rows, null, 2)}\n`; -} - -export function parseColumnMap(text: string): Record { - const map: Record = {}; - for (const line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const split = trimmed.indexOf(":"); - if (split === -1) continue; - map[trimmed.slice(0, split).trim()] = trimmed.slice(split + 1).trim(); - } - return map; -} - -function column( - index: Map, - mapped: string | undefined, - fallbacks: string[], -): number | undefined { - const names = mapped ? [mapped.toLowerCase(), ...fallbacks] : fallbacks; - for (const name of names) { - const at = index.get(name); - if (at !== undefined) return at; - } - return undefined; -} - -function cell(value: string): string { - if (/[",\n]/.test(value)) return `"${value.replaceAll('"', '""')}"`; - return value; -} - -function parseRows(text: string): string[][] { - const rows: string[][] = []; - let row: string[] = []; - let field = ""; - let quoted = false; - const source = text.replace(/^\uFEFF/, ""); - for (let i = 0; i < source.length; i += 1) { - const char = source[i]; - if (quoted) { - if (char === '"') { - if (source[i + 1] === '"') { - field += '"'; - i += 1; - } else quoted = false; - } else field += char; - continue; - } - if (char === '"') quoted = true; - else if (char === ",") { - row.push(field); - field = ""; - } else if (char === "\n") { - row.push(field); - rows.push(row); - row = []; - field = ""; - } else if (char !== "\r") field += char; - } - if (field.length > 0 || row.length > 0) { - row.push(field); - rows.push(row); - } - return rows.filter((item) => item.some((value) => value.trim() !== "")); -} diff --git a/packages/judging-cli/src/main.ts b/packages/judging-cli/src/main.ts deleted file mode 100644 index 69ac7081..00000000 --- a/packages/judging-cli/src/main.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { - appliedMigrations, - createDb, - migrate, - migrationFiles, - databaseUrl, - requireDatabaseUrl, - seedDemo, -} from "@query/judging-db"; -import { drainOutbox } from "@query/judging-server/outbox"; -import { exportResults, importProjects } from "./transfer"; - -const [command, arg] = process.argv.slice(2); - -async function main() { - if (command === "import" && arg === "projects") { - const positionals = positionalsAfter(4); - const file = positionals[0]; - const eventId = positionals[1]; - const mapFile = option("--map"); - if (!file || !eventId) { - console.log("usage: panel import projects [--map columns.yaml]"); - process.exit(1); - } - const count = await importProjects(file, eventId, mapFile); - console.log(count); - return; - } - - if (command === "export" && arg === "results") { - const format = option("--format") === "json" ? "json" : "csv"; - const positionals = positionalsAfter(4); - const eventId = positionals[0]; - const file = positionals[1] ?? (format === "json" ? "results.json" : "results.csv"); - if (!eventId) { - console.log("usage: panel export results [file] [--format csv|json]"); - process.exit(1); - } - const count = await exportResults(eventId, file, format); - console.log(count); - return; - } - - if (command === "migrate") { - const { pool } = createDb(requireDatabaseUrl()); - const fresh = await migrate(pool); - console.log(fresh.length > 0 ? fresh.join("\n") : "up to date"); - await pool.end(); - return; - } - - if (command === "seed" && arg === "demo") { - const { db, pool } = createDb(requireDatabaseUrl()); - await migrate(pool); - const seeded = await seedDemo(db); - console.log(seeded.eventId); - await pool.end(); - return; - } - - if (command === "outbox" && arg === "drain") { - const { db, pool } = createDb(requireDatabaseUrl()); - let total = 0; - try { - for (;;) { - const delivered = await drainOutbox(db); - total += delivered; - if (delivered === 0) break; - } - } finally { - await pool.end(); - } - console.log(total); - return; - } - - if (command === "doctor") { - const checks: { name: string; ok: boolean; detail: string }[] = []; - const url = databaseUrl(); - if (!url) { - checks.push({ - name: "postgres", - ok: false, - detail: "DATABASE_URL is not set", - }); - } else { - const { pool } = createDb(url); - try { - await pool.query("select 1"); - checks.push({ name: "postgres", ok: true, detail: "connected" }); - const files = await migrationFiles(); - const applied = await appliedMigrations(pool); - const pending = files.filter((file) => !applied.includes(file)); - checks.push({ - name: "migrations", - ok: pending.length === 0, - detail: pending.length > 0 ? `pending ${pending.join(", ")}` : "up to date", - }); - } catch (error) { - checks.push({ - name: "postgres", - ok: false, - detail: error instanceof Error ? error.message : "connection failed", - }); - } finally { - await pool.end(); - } - } - - // Optional: the portal signs judges in with its own session. The secret - // only matters for callers outside it that send a bearer token. - const secret = process.env.PANEL_JWT_SECRET; - checks.push({ - name: "jwt", - ok: true, - detail: secret ? "PANEL_JWT_SECRET is set" : "not set (portal sign-in only)", - }); - checks.push({ - name: "redis", - ok: true, - detail: process.env.REDIS_URL ? "REDIS_URL is set" : "not configured", - }); - - for (const check of checks) { - console.log(`${check.ok ? "ok" : "fail"} ${check.name} ${check.detail}`); - } - if (checks.some((check) => !check.ok)) process.exit(1); - return; - } - - console.log( - "usage: panel migrate | panel seed demo | panel doctor | panel outbox drain | panel import projects | panel export results [file]", - ); - process.exit(1); -} - -main().catch((error: unknown) => { - console.error(error instanceof Error ? error.message : error); - process.exit(1); -}); - -function option(name: string): string | undefined { - const index = process.argv.indexOf(name); - if (index === -1) return undefined; - return process.argv[index + 1]; -} - -function positionalsAfter(start: number): string[] { - const args = process.argv.slice(start); - return args.filter((value, index) => { - if (value.startsWith("--")) return false; - const previous = args[index - 1]; - return previous !== "--format" && previous !== "--map"; - }); -} diff --git a/packages/judging-cli/src/transfer.ts b/packages/judging-cli/src/transfer.ts deleted file mode 100644 index 4469539f..00000000 --- a/packages/judging-cli/src/transfer.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { and, eq } from "drizzle-orm"; -import { createDb, project, requireDatabaseUrl, result, resultRun, track } from "@query/judging-db"; -import { formatResultsCsv, formatResultsJson, parseColumnMap, parseProjectCsv } from "./csv"; - -export async function importProjects(file: string, eventId: string, mapFile?: string) { - const columnMap = mapFile ? parseColumnMap(await readFile(mapFile, "utf8")) : {}; - const rows = parseProjectCsv(await readFile(file, "utf8"), columnMap); - const { db, pool } = createDb(requireDatabaseUrl()); - try { - for (const row of rows) { - const [found] = await db - .select({ id: project.id }) - .from(project) - .where(and(eq(project.eventId, eventId), eq(project.externalId, row.externalId))); - if (found) { - await db - .update(project) - .set({ name: row.name, teamName: row.teamName, tableNumber: row.tableNumber }) - .where(eq(project.id, found.id)); - } else { - await db.insert(project).values({ - eventId, - externalId: row.externalId, - name: row.name, - teamName: row.teamName, - tableNumber: row.tableNumber, - }); - } - } - return rows.length; - } finally { - await pool.end(); - } -} - -export async function exportResults( - eventId: string, - file: string, - format: "csv" | "json" = "csv", -) { - const { db, pool } = createDb(requireDatabaseUrl()); - try { - const [run] = await db - .select({ id: resultRun.id }) - .from(resultRun) - .where(eq(resultRun.eventId, eventId)); - if (!run) throw new Error("No result run for that event"); - const rows = await db - .select({ - project: project.name, - track: track.slug, - placement: result.placement, - score: result.score, - }) - .from(result) - .innerJoin(project, eq(project.id, result.projectId)) - .innerJoin(track, eq(track.id, result.trackId)) - .where(eq(result.runId, run.id)); - const body = - format === "json" ? formatResultsJson(rows) : formatResultsCsv(rows); - await writeFile(file, body); - return rows.length; - } finally { - await pool.end(); - } -} diff --git a/packages/judging-cli/tsconfig.json b/packages/judging-cli/tsconfig.json deleted file mode 100644 index f54bde2c..00000000 --- a/packages/judging-cli/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tooling/typescript/base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "types": ["node"] - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/judging-core/eslint.config.mjs b/packages/judging-core/eslint.config.mjs deleted file mode 100644 index 09f98a9a..00000000 --- a/packages/judging-core/eslint.config.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import { config } from "@query/eslint-config/base"; - -/** @type {import("eslint").Linter.Config[]} */ -export default [ - ...config, - { - files: ["src/**/*.ts"], - ignores: ["src/**/*.test.ts"], - rules: { - "no-restricted-imports": [ - "error", - { - paths: [ - { - name: "process", - importNames: ["env"], - message: - "@query/judging-core takes its inputs as arguments. It does not read the environment.", - }, - ], - patterns: [ - { - group: [ - "node:*", - "fs", - "path", - "crypto", - "node:fs", - "node:path", - "node:crypto", - ], - message: - "@query/judging-core has no runtime dependencies and does not call Node APIs.", - }, - ], - }, - ], - }, - }, -]; diff --git a/packages/judging-core/package.json b/packages/judging-core/package.json deleted file mode 100644 index 1e961bdd..00000000 --- a/packages/judging-core/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@query/judging-core", - "version": "0.0.0", - "private": true, - "type": "module", - "description": "Pure judging decisions: dispatch, rubric, phase, and ranking.", - "exports": { - ".": "./src/index.ts" - }, - "files": ["dist"], - "publishConfig": { - "access": "public", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - } - }, - "scripts": { - "lint": "eslint . --max-warnings 0", - "build": "tsc -p tsconfig.build.json && node scripts/emit-extensions.mjs", - "typecheck": "tsc --noEmit", - "test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts" - }, - "devDependencies": { - "@query/eslint-config": "workspace:*", - "@query/tsconfig": "workspace:*", - "@types/node": "22.20.4", - "typescript": "7.0.2", - "vitest": "^5.0.1" - } -} diff --git a/packages/judging-core/scripts/emit-extensions.mjs b/packages/judging-core/scripts/emit-extensions.mjs deleted file mode 100644 index 4efdcb3b..00000000 --- a/packages/judging-core/scripts/emit-extensions.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import { readdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const dist = join(dirname(fileURLToPath(import.meta.url)), "../dist"); - -await walk(dist); - -async function walk(dir) { - for (const entry of await readdir(dir, { withFileTypes: true })) { - const path = join(dir, entry.name); - if (entry.isDirectory()) { - await walk(path); - continue; - } - if (!entry.name.endsWith(".js")) continue; - const source = await readFile(path, "utf8"); - const next = source.replaceAll( - /(from\s+["'])(\.\.?\/[^"']+)(["'])/g, - (match, open, spec, close) => - spec.endsWith(".js") ? match : `${open}${spec}.js${close}`, - ); - if (next !== source) await writeFile(path, next); - } -} diff --git a/packages/judging-core/src/aggregate/bayes.test.ts b/packages/judging-core/src/aggregate/bayes.test.ts deleted file mode 100644 index df67d118..00000000 --- a/packages/judging-core/src/aggregate/bayes.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { bayesianShrink, round2 } from "./bayes"; - -describe("bayesianShrink", () => { - it("is zero when nobody voted", () => { - expect(bayesianShrink(9, 0, 5, 2)).toBe(0); - }); - - it("weighs two votes equally with the prior at c = 2", () => { - expect(bayesianShrink(8, 2, 4, 2)).toBe(6); - }); - - it("approaches the project's own average as votes accumulate", () => { - const few = bayesianShrink(10, 1, 0, 2); - const many = bayesianShrink(10, 100, 0, 2); - expect(many).toBeGreaterThan(few); - expect(many).toBeGreaterThan(9.5); - }); -}); - -describe("round2", () => { - it("rounds half up to the hundredth", () => { - expect(round2(1.234)).toBe(1.23); - expect(round2(1.235)).toBe(1.24); - expect(round2(7.085)).toBe(7.09); - }); -}); diff --git a/packages/judging-core/src/aggregate/bayes.ts b/packages/judging-core/src/aggregate/bayes.ts deleted file mode 100644 index 15f764fe..00000000 --- a/packages/judging-core/src/aggregate/bayes.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Pull a project's normalised average toward the field when few judges have - * seen it. `c` is how many average votes the prior is worth. At `c = 2`, - * two real votes and the prior weigh the same. - */ -export function bayesianShrink( - normalizedAvg: number, - n: number, - globalAvg: number, - c: number, -): number { - if (n <= 0) return 0; - return (n / (n + c)) * normalizedAvg + (c / (n + c)) * globalAvg; -} - -/** Hundredths, same rounding the published placings use. */ -export function round2(value: number): number { - return Math.round(value * 100) / 100; -} diff --git a/packages/judging-core/src/aggregate/blend.test.ts b/packages/judging-core/src/aggregate/blend.test.ts deleted file mode 100644 index d40cf033..00000000 --- a/packages/judging-core/src/aggregate/blend.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { blend } from "./blend"; - -describe("blend", () => { - it("returns the rubric at weight 0 and the pairwise score at weight 1", () => { - expect(blend(4, 10, 0)).toBe(4); - expect(blend(4, 10, 1)).toBe(10); - }); - - it("takes the midpoint at a half", () => { - expect(blend(4, 10, 0.5)).toBe(7); - }); - - it("rejects a weight outside 0 to 1", () => { - expect(() => blend(1, 1, 1.1)).toThrow(/weight/); - expect(() => blend(1, 1, -0.1)).toThrow(/weight/); - }); -}); diff --git a/packages/judging-core/src/aggregate/blend.ts b/packages/judging-core/src/aggregate/blend.ts deleted file mode 100644 index 9b3bba95..00000000 --- a/packages/judging-core/src/aggregate/blend.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** `weight` is the share of the pairwise component. Zero is rubric only. */ -export function blend( - rubric: number, - pairwise: number, - weight: number, -): number { - if (!Number.isFinite(weight) || weight < 0 || weight > 1) { - throw new Error("pairwise weight must be between 0 and 1"); - } - return (1 - weight) * rubric + weight * pairwise; -} diff --git a/packages/judging-core/src/aggregate/bradleyTerry.test.ts b/packages/judging-core/src/aggregate/bradleyTerry.test.ts deleted file mode 100644 index 3a54f9b6..00000000 --- a/packages/judging-core/src/aggregate/bradleyTerry.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { bradleyTerry } from "./bradleyTerry"; - -describe("bradleyTerry", () => { - it("returns an empty map when there is nobody", () => { - expect(bradleyTerry([], []).size).toBe(0); - }); - - it("leaves everyone equal when there are no comparisons", () => { - const strength = bradleyTerry(["a", "b"], []); - expect(strength.get("a")).toBe(1); - expect(strength.get("b")).toBe(1); - }); - - it("ranks a consistent set in transitive order", () => { - const strength = bradleyTerry( - ["a", "b", "c"], - [ - { a: "a", b: "b", outcome: "a" }, - { a: "b", b: "c", outcome: "a" }, - { a: "a", b: "c", outcome: "a" }, - ], - ); - const a = strength.get("a") ?? 0; - const b = strength.get("b") ?? 0; - const c = strength.get("c") ?? 0; - expect(a).toBeGreaterThan(b); - expect(b).toBeGreaterThan(c); - }); - - it("puts a tie closer together than a sweep", () => { - const tied = bradleyTerry( - ["a", "b"], - [ - { a: "a", b: "b", outcome: "tie" }, - { a: "a", b: "b", outcome: "tie" }, - ], - ); - const swept = bradleyTerry( - ["a", "b"], - [ - { a: "a", b: "b", outcome: "a" }, - { a: "a", b: "b", outcome: "a" }, - ], - ); - const tieGap = Math.abs((tied.get("a") ?? 0) - (tied.get("b") ?? 0)); - const sweepGap = Math.abs((swept.get("a") ?? 0) - (swept.get("b") ?? 0)); - expect(tieGap).toBeLessThan(sweepGap); - }); -}); diff --git a/packages/judging-core/src/aggregate/bradleyTerry.ts b/packages/judging-core/src/aggregate/bradleyTerry.ts deleted file mode 100644 index 71132a65..00000000 --- a/packages/judging-core/src/aggregate/bradleyTerry.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { Comparison } from "../comparison"; - -/** - * Bradley-Terry strengths from pairwise outcomes. A tie is half a win each. - * Strengths are scaled so their mean is 1. Projects that never appear in a - * comparison are left at 1. Iteration is the minorization-maximization - * update; `iterations` is a cap, not a clock. - */ -export function bradleyTerry( - projectIds: readonly string[], - comparisons: readonly Comparison[], - iterations = 50, -): Map { - const ids = [...new Set(projectIds)]; - const strength = new Map(ids.map((id) => [id, 1])); - if (ids.length === 0 || comparisons.length === 0) return strength; - - const wins = new Map(ids.map((id) => [id, 0])); - const games = new Map>(); - - const addGame = (left: string, right: string) => { - const row = games.get(left) ?? new Map(); - row.set(right, (row.get(right) ?? 0) + 1); - games.set(left, row); - }; - - for (const comparison of comparisons) { - if ( - comparison.a === comparison.b || - !strength.has(comparison.a) || - !strength.has(comparison.b) - ) { - continue; - } - if (comparison.outcome === "tie") { - wins.set(comparison.a, (wins.get(comparison.a) ?? 0) + 0.5); - wins.set(comparison.b, (wins.get(comparison.b) ?? 0) + 0.5); - } else if (comparison.outcome === "a") { - wins.set(comparison.a, (wins.get(comparison.a) ?? 0) + 1); - } else { - wins.set(comparison.b, (wins.get(comparison.b) ?? 0) + 1); - } - addGame(comparison.a, comparison.b); - addGame(comparison.b, comparison.a); - } - - for (let step = 0; step < iterations; step += 1) { - const next = new Map(); - for (const id of ids) { - const opponents = games.get(id); - if (!opponents || opponents.size === 0) { - next.set(id, strength.get(id) ?? 1); - continue; - } - let denominator = 0; - for (const [other, count] of opponents) { - const pi = strength.get(id) ?? 1; - const pj = strength.get(other) ?? 1; - const sum = pi + pj; - if (sum > 0) denominator += count / sum; - } - const won = wins.get(id) ?? 0; - next.set(id, denominator > 0 ? won / denominator : 0); - } - let total = 0; - for (const value of next.values()) total += value; - const mean = total / ids.length || 1; - for (const id of ids) strength.set(id, (next.get(id) ?? 0) / mean); - } - - return strength; -} diff --git a/packages/judging-core/src/aggregate/rank.test.ts b/packages/judging-core/src/aggregate/rank.test.ts deleted file mode 100644 index 800c28d9..00000000 --- a/packages/judging-core/src/aggregate/rank.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { zNormalize } from "./zscore"; -import { bayesianShrink, round2 } from "./bayes"; -import { rank } from "./rank"; -import type { ProjectInput, RankComparison, RankConfig, VoteInput } from "./rank"; - -/** - * The ranking the current event publishes: z-score per judge against the - * population mean and spread, round the project mean, shrink toward the mean - * of those means with c = 2, round again. Same raw score maps to one - * normalised value because the z-score is linear, so a value-keyed lookup - * and an index-aligned one agree. - */ -function legacyRank( - projects: readonly { id: string }[], - votes: readonly { judgeId: string; projectId: string; score: number }[], -) { - const byJudge = new Map(); - for (const vote of votes) { - const list = byJudge.get(vote.judgeId) ?? []; - list.push(vote.score); - byJudge.set(vote.judgeId, list); - } - const raw = [...byJudge.values()].flat(); - const globalMean = raw.length > 0 ? raw.reduce((a, b) => a + b, 0) / raw.length : 0; - const globalVariance = - raw.length > 0 - ? raw.reduce((sum, value) => sum + (value - globalMean) ** 2, 0) / raw.length - : 1; - const globalStd = Math.sqrt(globalVariance) || 1; - - const normalizedByJudge = new Map>(); - for (const [judgeId, scores] of byJudge) { - const normalized = zNormalize(scores, globalMean, globalStd); - const lookup = new Map(); - scores.forEach((score, index) => { - const existing = lookup.get(score); - const value = normalized[index] ?? globalMean; - lookup.set(score, existing !== undefined ? (existing + value) / 2 : value); - }); - normalizedByJudge.set(judgeId, lookup); - } - - const rows = projects.map((project) => { - const mine = votes.filter((vote) => vote.projectId === project.id); - const normalizedScores = mine.map( - (vote) => normalizedByJudge.get(vote.judgeId)?.get(vote.score) ?? vote.score, - ); - const normalizedAvg = - mine.length > 0 - ? round2(normalizedScores.reduce((a, b) => a + b, 0) / mine.length) - : 0; - return { id: project.id, voteCount: mine.length, normalizedAvg }; - }); - - const voted = rows.filter((row) => row.voteCount > 0); - const globalAvg = - voted.length > 0 - ? round2(voted.reduce((sum, row) => sum + row.normalizedAvg, 0) / voted.length) - : 0; - - return rows - .map((row) => ({ - id: row.id, - voteCount: row.voteCount, - score: - row.voteCount > 0 - ? round2(bayesianShrink(row.normalizedAvg, row.voteCount, globalAvg, 2)) - : 0, - })) - .filter((row) => row.voteCount > 0) - .sort((a, b) => b.score - a.score || projects.findIndex((p) => p.id === a.id) - projects.findIndex((p) => p.id === b.id)); -} - -const config: RankConfig = { - bayesianC: 2, - pairwiseWeight: 0, - calibrationWeight: 1, -}; - -const projects: ProjectInput[] = [ - { id: "A", trackIds: ["overall"] }, - { id: "B", trackIds: ["overall"] }, - { id: "C", trackIds: ["overall"] }, -]; - -function vote(judgeId: string, projectId: string, total: number): VoteInput { - return { judgeId, projectId, judgeGroup: "main", total }; -} - -describe("rank matches the current event's pipeline at pairwise weight 0", () => { - const votes = [ - vote("j1", "A", 8), - vote("j1", "B", 5), - vote("j1", "C", 2), - vote("j2", "A", 9), - vote("j2", "B", 9), - vote("j2", "C", 6), - ]; - - it("places the same projects at the same hundredths", () => { - const legacy = legacyRank(projects, votes.map((item) => ({ - judgeId: item.judgeId, - projectId: item.projectId, - score: item.total, - }))); - const ranked = rank(projects, votes, [], config) - .filter((row) => row.placement !== null) - .sort((a, b) => (a.placement ?? 0) - (b.placement ?? 0)); - - expect(ranked.map((row) => [row.projectId, row.score])).toEqual( - legacy.map((row) => [row.id, row.score]), - ); - expect(ranked.map((row) => row.score)).toEqual([7.71, 6.94, 4.85]); - }); - - it("ignores comparisons when the pairwise weight is zero", () => { - const withPairs = rank( - projects, - votes, - [ - { - judgeId: "j1", - judgeGroup: "main", - a: "C", - b: "A", - outcome: "a", - }, - ], - config, - ); - const without = rank(projects, votes, [], config); - expect(withPairs.map((row) => row.score)).toEqual(without.map((row) => row.score)); - }); -}); - -describe("rank", () => { - it("does not place a project nobody scored", () => { - const rows = rank( - [ - { id: "seen", trackIds: ["overall"] }, - { id: "missed", trackIds: ["overall"] }, - ], - [vote("j1", "seen", 8), vote("j2", "seen", 6)], - [], - config, - ); - const seen = rows.find((row) => row.projectId === "seen"); - const missed = rows.find((row) => row.projectId === "missed"); - expect(seen?.placement).toBe(1); - expect(missed?.placement).toBeNull(); - }); - - it("keeps judge groups apart", () => { - const rows = rank( - [{ id: "A", trackIds: ["overall"] }], - [ - { judgeId: "j1", projectId: "A", judgeGroup: "main", total: 10 }, - { judgeId: "j2", projectId: "A", judgeGroup: "sponsor", total: 1 }, - ], - [], - config, - ); - expect(rows).toHaveLength(2); - expect(rows.find((row) => row.judgeGroup === "main")?.voteCount).toBe(1); - expect(rows.find((row) => row.judgeGroup === "sponsor")?.voteCount).toBe(1); - }); - - it("drops a calibration vote whose weight is zero", () => { - const base = rank(projects, [vote("j1", "A", 8), vote("j1", "B", 4)], [], config); - const extra = rank( - projects, - [ - vote("j1", "A", 8), - vote("j1", "B", 4), - { ...vote("j1", "C", 1), isCalibration: true }, - ], - [], - { ...config, calibrationWeight: 0 }, - ); - const score = (rows: typeof base, id: string) => - rows.find((row) => row.projectId === id)?.score; - expect(score(extra, "A")).toBe(score(base, "A")); - expect(score(extra, "B")).toBe(score(base, "B")); - expect(extra.find((row) => row.projectId === "C")?.voteCount).toBe(0); - }); - - it("orders a pairwise-only field by who won, at weight 1", () => { - const rows = rank( - projects, - [], - [ - { judgeId: "j1", judgeGroup: "main", a: "A", b: "B", outcome: "a" }, - { judgeId: "j1", judgeGroup: "main", a: "B", b: "C", outcome: "a" }, - { judgeId: "j1", judgeGroup: "main", a: "A", b: "C", outcome: "a" }, - ], - { ...config, pairwiseWeight: 1 }, - ); - const placed = rows - .filter((row) => row.placement !== null) - .sort((a, b) => (a.placement ?? 0) - (b.placement ?? 0)); - expect(placed.map((row) => row.projectId)).toEqual(["A", "B", "C"]); - }); -}); - -describe("rank at pairwise weight 1", () => { - it("keeps the true order when every comparison agrees", () => { - const ids = ["a", "b", "c", "d", "e"]; - let seed = 11; - const random = () => { - seed = (seed * 1664525 + 1013904223) % 4294967296; - return seed / 4294967296; - }; - for (let trial = 0; trial < 20; trial += 1) { - const truth = [...ids].sort(() => random() - 0.5); - const better = new Map(truth.map((id, index) => [id, index])); - const comparisons: RankComparison[] = []; - for (let left = 0; left < ids.length; left += 1) { - for (let right = left + 1; right < ids.length; right += 1) { - const a = ids[left] ?? ""; - const b = ids[right] ?? ""; - const aWins = (better.get(a) ?? 0) < (better.get(b) ?? 0); - comparisons.push({ - judgeId: "j1", - judgeGroup: "main", - a, - b, - outcome: aWins ? ("a" as const) : ("b" as const), - }); - } - } - const rows = rank( - ids.map((id) => ({ id, trackIds: ["overall"] })), - [], - comparisons, - { ...config, pairwiseWeight: 1 }, - ); - const placed = rows - .filter((row) => row.placement !== null) - .sort((left, right) => (left.placement ?? 0) - (right.placement ?? 0)); - expect(placed.map((row) => row.projectId)).toEqual(truth); - } - }); -}); diff --git a/packages/judging-core/src/aggregate/rank.ts b/packages/judging-core/src/aggregate/rank.ts deleted file mode 100644 index 6f1fa9d8..00000000 --- a/packages/judging-core/src/aggregate/rank.ts +++ /dev/null @@ -1,312 +0,0 @@ -import type { Comparison } from "../comparison"; -import { bayesianShrink, round2 } from "./bayes"; -import { bradleyTerry } from "./bradleyTerry"; -import { blend } from "./blend"; -import { zNormalize } from "./zscore"; - -export type VoteInput = { - judgeId: string; - projectId: string; - judgeGroup: string; - total: number; - isCalibration?: boolean; -}; - -export type RankComparison = Comparison & { - judgeId: string; - judgeGroup: string; -}; - -export type ProjectInput = { - id: string; - trackIds: readonly string[]; -}; - -export type RankConfig = { - bayesianC: number; - pairwiseWeight: number; - /** How much a calibration vote counts, relative to a normal vote of 1. */ - calibrationWeight: number; -}; - -export type RankedRow = { - projectId: string; - trackId: string; - judgeGroup: string; - placement: number | null; - score: number; - rubricComponent: number; - pairwiseComponent: number; - voteCount: number; - comparisonCount: number; -}; - -/** - * Rank each track and judge group. - * - * Rubric side, at `pairwiseWeight = 0` and `calibrationWeight = 1`: z-score - * each judge onto the group's mean and spread, average per project (rounded - * to hundredths), shrink toward the mean of those averages with `bayesianC`, - * round again, and place by that score. That is the pipeline the existing - * event uses, so a replay of its votes lands on the same numbers. - * - * Pairwise side: Bradley-Terry on comparisons in the group, scaled onto the - * rubric scores, then blended. Weight 0 leaves the rubric number untouched. - * A calibration vote is down-weighted in the average and in the shrink's - * sample size. It still informs that judge's own z-score. - */ -export function rank( - projects: readonly ProjectInput[], - votes: readonly VoteInput[], - comparisons: readonly RankComparison[], - config: RankConfig, -): RankedRow[] { - const known = new Set(projects.map((project) => project.id)); - const tracks = trackOrder(projects); - const groups = groupOrder(votes, comparisons); - const rows: RankedRow[] = []; - - for (const judgeGroup of groups) { - const groupVotes = votes.filter( - (vote) => vote.judgeGroup === judgeGroup && known.has(vote.projectId), - ); - const normalized = normalize(groupVotes, config); - for (const trackId of tracks) { - const onTrack = projects.filter((project) => - project.trackIds.includes(trackId), - ); - const onTrackIds = new Set(onTrack.map((project) => project.id)); - const trackVotes = groupVotes - .map((vote, index) => ({ - vote, - normalized: normalized[index] ?? 0, - })) - .filter(({ vote }) => onTrackIds.has(vote.projectId)); - const trackComparisons = comparisons.filter( - (comparison) => - comparison.judgeGroup === judgeGroup && - onTrackIds.has(comparison.a) && - onTrackIds.has(comparison.b), - ); - rows.push( - ...rankTrack( - onTrack, - trackVotes, - trackComparisons, - trackId, - judgeGroup, - config, - ), - ); - } - } - - return rows; -} - -function weightOf(vote: VoteInput, config: RankConfig): number { - return vote.isCalibration ? config.calibrationWeight : 1; -} - -function spread(scores: readonly number[]): { mean: number; std: number } { - if (scores.length === 0) return { mean: 0, std: 1 }; - const mean = scores.reduce((sum, value) => sum + value, 0) / scores.length; - const variance = - scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / scores.length; - return { mean, std: Math.sqrt(variance) || 1 }; -} - -function normalize( - votes: readonly VoteInput[], - config: RankConfig, -): number[] { - const weights = votes.map((vote) => weightOf(vote, config)); - const raw: number[] = []; - for (let index = 0; index < votes.length; index += 1) { - const vote = votes[index]; - if (vote && (weights[index] ?? 0) > 0) raw.push(vote.total); - } - const { mean, std } = spread(raw); - - const byJudge = new Map(); - votes.forEach((vote, index) => { - if ((weights[index] ?? 0) <= 0) return; - const list = byJudge.get(vote.judgeId) ?? []; - list.push(index); - byJudge.set(vote.judgeId, list); - }); - - const out = votes.map(() => 0); - for (const indexes of byJudge.values()) { - const scores = indexes.map((index) => votes[index]?.total ?? 0); - const normalized = zNormalize(scores, mean, std); - indexes.forEach((index, offset) => { - out[index] = normalized[offset] ?? 0; - }); - } - return out; -} - -type CarriedVote = { vote: VoteInput; normalized: number }; - -function rankTrack( - projects: readonly ProjectInput[], - votes: readonly CarriedVote[], - comparisons: readonly RankComparison[], - trackId: string, - judgeGroup: string, - config: RankConfig, -): RankedRow[] { - const stats = new Map(); - for (const project of projects) { - let weightedSum = 0; - let valueSum = 0; - let count = 0; - for (const item of votes) { - if (item.vote.projectId !== project.id) continue; - const weight = weightOf(item.vote, config); - if (weight <= 0) continue; - weightedSum += weight; - valueSum += item.normalized * weight; - count += 1; - } - stats.set(project.id, { - avg: weightedSum > 0 ? round2(valueSum / weightedSum) : 0, - n: weightedSum, - count, - }); - } - - const votedAverages: number[] = []; - for (const row of stats.values()) { - if (row.n > 0) votedAverages.push(row.avg); - } - const globalAvg = - votedAverages.length > 0 - ? round2( - votedAverages.reduce((sum, value) => sum + value, 0) / - votedAverages.length, - ) - : 0; - - const rubricScore = new Map(); - const anchors: number[] = []; - for (const project of projects) { - const row = stats.get(project.id); - const n = row?.n ?? 0; - const score = - n > 0 - ? round2(bayesianShrink(row?.avg ?? 0, n, globalAvg, config.bayesianC)) - : 0; - rubricScore.set(project.id, score); - if (n > 0) anchors.push(score); - } - - const compared = new Set(); - for (const comparison of comparisons) { - compared.add(comparison.a); - compared.add(comparison.b); - } - const scaled = scaleStrengths( - bradleyTerry([...compared], comparisons), - anchors, - compared, - ); - - const draft: RankedRow[] = projects.map((project) => { - const row = stats.get(project.id); - const rubricComponent = rubricScore.get(project.id) ?? 0; - const comparisonCount = comparisons.filter( - (comparison) => - comparison.a === project.id || comparison.b === project.id, - ).length; - const scaledValue = scaled.get(project.id); - const pairwiseForBlend = scaledValue ?? rubricComponent; - const score = - config.pairwiseWeight === 0 - ? rubricComponent - : round2( - blend(rubricComponent, pairwiseForBlend, config.pairwiseWeight), - ); - return { - projectId: project.id, - trackId, - judgeGroup, - placement: null, - score, - rubricComponent, - pairwiseComponent: scaledValue ?? 0, - voteCount: row?.count ?? 0, - comparisonCount, - }; - }); - - const order = new Map(projects.map((project, index) => [project.id, index])); - const placeable = draft.filter( - (row) => - row.voteCount > 0 || - (config.pairwiseWeight > 0 && row.comparisonCount > 0), - ); - placeable.sort((a, b) => { - if (a.score !== b.score) return b.score - a.score; - // Hundredths can tie two neighbours. The unrounded pairwise strength - // still has the order the comparisons produced. - if ( - config.pairwiseWeight > 0 && - a.pairwiseComponent !== b.pairwiseComponent - ) { - return b.pairwiseComponent - a.pairwiseComponent; - } - return (order.get(a.projectId) ?? 0) - (order.get(b.projectId) ?? 0); - }); - placeable.forEach((row, index) => { - row.placement = index + 1; - }); - return draft; -} - -function scaleStrengths( - strength: ReadonlyMap, - anchors: readonly number[], - compared: ReadonlySet, -): Map { - const out = new Map(); - if (compared.size === 0) return out; - const values = [...compared].map((id) => strength.get(id) ?? 0); - const lowStrength = Math.min(...values); - const highStrength = Math.max(...values); - const low = anchors.length > 0 ? Math.min(...anchors) : lowStrength; - const high = anchors.length > 0 ? Math.max(...anchors) : highStrength; - for (const id of compared) { - const value = strength.get(id) ?? 0; - if (anchors.length === 0) out.set(id, value); - else if (highStrength === lowStrength) out.set(id, (low + high) / 2); - else { - out.set( - id, - low + - ((value - lowStrength) / (highStrength - lowStrength)) * (high - low), - ); - } - } - return out; -} - -function trackOrder(projects: readonly ProjectInput[]): string[] { - const seen = new Set(); - for (const project of projects) { - for (const trackId of project.trackIds) seen.add(trackId); - } - return [...seen]; -} - -function groupOrder( - votes: readonly VoteInput[], - comparisons: readonly RankComparison[], -): string[] { - const seen = new Set(); - for (const vote of votes) seen.add(vote.judgeGroup); - for (const comparison of comparisons) seen.add(comparison.judgeGroup); - if (seen.size === 0) seen.add("main"); - return [...seen]; -} diff --git a/packages/judging-core/src/aggregate/zscore.test.ts b/packages/judging-core/src/aggregate/zscore.test.ts deleted file mode 100644 index 29722da2..00000000 --- a/packages/judging-core/src/aggregate/zscore.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { zNormalize } from "./zscore"; - -const mean = (xs: number[]) => xs.reduce((sum, value) => sum + value, 0) / xs.length; -const stddev = (xs: number[]) => { - const center = mean(xs); - return Math.sqrt(mean(xs.map((value) => (value - center) ** 2))); -}; - -describe("zNormalize", () => { - it("returns the global mean when fewer than two scores", () => { - expect(zNormalize([8], 5, 2)).toEqual([5]); - expect(zNormalize([], 5, 2)).toEqual([]); - }); - - it("returns the global mean when a judge scored everything the same", () => { - expect(zNormalize([7, 7, 7], 5, 2)).toEqual([5, 5, 5]); - }); - - it("spreads scores around the global mean", () => { - const normalized = zNormalize([2, 5, 8], 5, 2); - expect(normalized[0] ?? 0).toBeLessThan(5); - expect(normalized[1] ?? 0).toBeCloseTo(5, 1); - expect(normalized[2] ?? 0).toBeGreaterThan(5); - }); - - it("preserves a judge's own ordering", () => { - const normalized = zNormalize([1, 4, 9, 10], 5, 2); - for (let index = 1; index < normalized.length; index += 1) { - expect(normalized[index] ?? 0).toBeGreaterThan(normalized[index - 1] ?? 0); - } - }); - - it("makes a harsh judge and a lenient judge agree", () => { - const harsh = zNormalize([1, 2, 3], 5, 2); - const lenient = zNormalize([8, 9, 10], 5, 2); - expect(harsh[0] ?? 0).toBeCloseTo(lenient[0] ?? 0, 10); - expect(harsh[1] ?? 0).toBeCloseTo(lenient[1] ?? 0, 10); - expect(harsh[2] ?? 0).toBeCloseTo(lenient[2] ?? 0, 10); - }); - - it("makes a wide-spread judge and a narrow-spread judge agree", () => { - const wide = zNormalize([0, 5, 10], 5, 2); - const narrow = zNormalize([4, 5, 6], 5, 2); - expect(wide[0] ?? 0).toBeCloseTo(narrow[0] ?? 0, 10); - expect(wide[2] ?? 0).toBeCloseTo(narrow[2] ?? 0, 10); - }); - - it("centres the normalized scores on the global mean", () => { - expect(mean(zNormalize([1, 4, 9, 10], 7, 2))).toBeCloseTo(7, 10); - }); - - it("gives the normalized scores the global spread", () => { - expect(stddev(zNormalize([1, 4, 9, 10], 5, 3))).toBeCloseTo(3, 10); - }); - - it("never produces NaN or Infinity for degenerate input", () => { - const cases = [ - zNormalize([7, 7], 5, 2), - zNormalize([0, 0, 0], 5, 2), - zNormalize([5], 5, 0), - zNormalize([1, 2], 0, 0), - ]; - for (const result of cases) { - for (const value of result) expect(Number.isFinite(value)).toBe(true); - } - }); - - it("handles negative scores and keeps equals equal", () => { - const negative = zNormalize([-5, 0, 5], 5, 2); - expect(negative[0] ?? 0).toBeLessThan(negative[2] ?? 0); - const tied = zNormalize([3, 3, 9], 5, 2); - expect(tied[0] ?? 0).toBeCloseTo(tied[1] ?? 0, 10); - }); - - it("collapses to the global mean when the global spread is zero", () => { - expect(zNormalize([1, 5, 9], 6, 0)).toEqual([6, 6, 6]); - }); -}); diff --git a/packages/judging-core/src/aggregate/zscore.ts b/packages/judging-core/src/aggregate/zscore.ts deleted file mode 100644 index e6c96bb4..00000000 --- a/packages/judging-core/src/aggregate/zscore.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Z-score a judge's scores against their own mean and spread, then place - * that shape on the global mean and spread. A judge with fewer than two - * scores, or who gave everything the same number, has no spread of their - * own, so every score becomes the global mean. A global spread of zero - * collapses everyone onto the global mean too. - */ -export function zNormalize( - scores: readonly number[], - globalMean: number, - globalStd: number, -): number[] { - if (scores.length < 2) return scores.map(() => globalMean); - const mean = scores.reduce((sum, value) => sum + value, 0) / scores.length; - const variance = - scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / scores.length; - const std = Math.sqrt(variance); - if (std === 0 || globalStd === 0) return scores.map(() => globalMean); - return scores.map( - (value) => globalMean + ((value - mean) / std) * globalStd, - ); -} diff --git a/packages/judging-core/src/comparison.ts b/packages/judging-core/src/comparison.ts deleted file mode 100644 index 7937600b..00000000 --- a/packages/judging-core/src/comparison.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type Comparison = { - a: string; - b: string; - outcome: "a" | "b" | "tie"; -}; diff --git a/packages/judging-core/src/dispatch.sim.test.ts b/packages/judging-core/src/dispatch.sim.test.ts deleted file mode 100644 index fa2f6396..00000000 --- a/packages/judging-core/src/dispatch.sim.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { pickNext } from "./dispatch"; -import type { Candidate, DispatchStrategy } from "./dispatch"; -import { DEFAULT_TIMERS } from "./timers"; -import { scoreUncertainty } from "./uncertainty"; - -/** - * Twenty judges, two hundred projects, three hours at the table. The same - * noise is available to both strategies; only the choice of who is seen - * again changes. The ten truly strongest projects should end up in an order - * closer to the truth when extra looks go to the unsettled ones. - */ -describe("uncertainty dispatch", () => { - it("ranks the true top 10 better than even coverage at the same number of looks", () => { - const projects = 200; - const judges = 20; - const hours = 3; - const budget = judges * Math.floor((hours * 3600) / DEFAULT_TIMERS.targetSeconds); - const truth = Array.from({ length: projects }, (_, index) => index); - const noise = truth.map((_, project) => - Array.from({ length: 40 }, (_, look) => - gaussian(mulberry32(project * 1000 + look + 1)) * noiseScale(project), - ), - ); - - const coverage = simulate("coverage", { projects, judges, budget, truth, noise }); - const uncertainty = simulate("uncertainty", { projects, judges, budget, truth, noise }); - const top = truth.map((_, index) => index).slice(-10); - - expect(kendall(top, uncertainty, truth)).toBeGreaterThan(kendall(top, coverage, truth)); - }); -}); - -function noiseScale(project: number): number { - return project >= 185 ? 18 : 3; -} - -function simulate( - strategy: DispatchStrategy, - input: { - projects: number; - judges: number; - budget: number; - truth: number[]; - noise: number[][]; - }, -): number[] { - const scores = Array.from({ length: input.projects }, () => [] as number[]); - const seen = Array.from({ length: input.judges }, () => new Set()); - const last: (number | null)[] = Array.from({ length: input.judges }, () => null); - let used = 0; - let guard = 0; - while (used < input.budget && guard < input.budget) { - for (let judge = 0; judge < input.judges && used < input.budget; judge += 1) { - const candidates: Candidate[] = []; - for (let project = 0; project < input.projects; project += 1) { - if (seen[judge]?.has(project)) continue; - candidates.push({ - id: String(project), - tableNumber: project, - coverage: scores[project]?.length ?? 0, - claimedAt: null, - uncertainty: scoreUncertainty(scores[project] ?? []), - }); - } - const pick = pickNext( - candidates, - { lastTable: last[judge] ?? null }, - { strategy, minLooksPerProject: 2 }, - () => 0, - ); - if (!pick) continue; - const project = Number(pick.id); - const look = scores[project]?.length ?? 0; - scores[project]?.push((input.truth[project] ?? 0) + (input.noise[project]?.[look] ?? 0)); - seen[judge]?.add(project); - last[judge] = project; - used += 1; - } - guard += 1; - } - return scores.map((row, index) => - row.length === 0 - ? (input.truth[index] ?? 0) - : row.reduce((sum, value) => sum + value, 0) / row.length, - ); -} - -function kendall(ids: number[], estimate: number[], truth: number[]): number { - let concordant = 0; - let discordant = 0; - for (let left = 0; left < ids.length; left += 1) { - for (let right = left + 1; right < ids.length; right += 1) { - const a = ids[left] ?? 0; - const b = ids[right] ?? 0; - const truthSign = Math.sign((truth[a] ?? 0) - (truth[b] ?? 0)); - const estimateSign = Math.sign((estimate[a] ?? 0) - (estimate[b] ?? 0)); - if (truthSign === 0 || estimateSign === 0) continue; - if (truthSign === estimateSign) concordant += 1; - else discordant += 1; - } - } - return (concordant - discordant) / (concordant + discordant); -} - -function gaussian(rng: () => number): number { - const u = Math.max(rng(), 1e-9); - const v = rng(); - return Math.sqrt(-2 * Math.log(u)) * Math.cos(Math.PI * 2 * v); -} - -function mulberry32(seed: number): () => number { - let state = seed; - return () => { - state = (state + 0x6d2b79f5) | 0; - let value = Math.imul(state ^ (state >>> 15), 1 | state); - value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value; - return ((value ^ (value >>> 14)) >>> 0) / 4294967296; - }; -} diff --git a/packages/judging-core/src/dispatch.test.ts b/packages/judging-core/src/dispatch.test.ts deleted file mode 100644 index 9c2f0ec5..00000000 --- a/packages/judging-core/src/dispatch.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { pickNext } from "./dispatch"; -import type { Candidate, DispatchConfig } from "./dispatch"; - -const t0 = new Date("2027-02-28T13:00:00Z"); -const at = (seconds: number) => new Date(t0.getTime() + seconds * 1000); -const first = () => 0; -const coverage: DispatchConfig = { - strategy: "coverage", - minLooksPerProject: 1, -}; - -const c = ( - id: string, - tableNumber: number, - looks = 0, - claimedAt: number | null = null, - extra: Partial = {}, -): Candidate => ({ - id, - tableNumber, - coverage: looks, - claimedAt, - ...extra, -}); - -describe("pickNext coverage", () => { - it("returns nothing when there is nothing left", () => { - expect(pickNext([], { lastTable: null }, coverage, first)).toBeNull(); - }); - - it("takes the least-covered project", () => { - expect( - pickNext( - [c("a", 1, 2), c("b", 2, 0), c("c", 3, 1)], - { lastTable: null }, - coverage, - first, - )?.id, - ).toBe("b"); - }); - - it("breaks a coverage tie by the table nearest the judge's last one", () => { - const pick = pickNext( - [c("far", 90, 1), c("near", 12, 1), c("mid", 40, 1)], - { lastTable: 10 }, - coverage, - first, - ); - expect(pick?.id).toBe("near"); - }); - - it("never trades coverage for distance", () => { - expect( - pickNext( - [c("next-door", 11, 2), c("across", 200, 1)], - { lastTable: 10 }, - coverage, - first, - )?.id, - ).toBe("across"); - }); - - it("passes over a table someone is at while a free one exists", () => { - expect( - pickNext( - [c("taken", 1, 0, at(0).getTime()), c("free", 2, 3)], - { lastTable: null }, - coverage, - first, - )?.id, - ).toBe("free"); - }); - - it("sends the judge to the longest-held table when every one is taken", () => { - const pick = pickNext( - [ - c("recent", 1, 0, at(100).getTime()), - c("oldest", 2, 0, at(0).getTime()), - ], - { lastTable: null }, - coverage, - first, - ); - expect(pick?.id).toBe("oldest"); - }); - - it("chooses among equals at random", () => { - const tier = [c("a", 5, 0), c("b", 15, 0)]; - expect(pickNext(tier, { lastTable: 10 }, coverage, () => 0)?.id).toBe("a"); - expect(pickNext(tier, { lastTable: 10 }, coverage, () => 1)?.id).toBe("b"); - }); - - it("prefers the same zone over a closer table number in another zone", () => { - const pick = pickNext( - [ - c("other-room", 2, 0, null, { zoneId: "atrium" }), - c("same-room", 30, 0, null, { zoneId: "hall" }), - ], - { lastTable: 4, zoneId: "hall" }, - coverage, - first, - ); - expect(pick?.id).toBe("same-room"); - }); -}); - -describe("pickNext uncertainty", () => { - const uncertainty: DispatchConfig = { - strategy: "uncertainty", - minLooksPerProject: 2, - }; - - it("keeps covering while any project is under the minimum", () => { - const pick = pickNext( - [ - c("thin", 1, 0, null, { uncertainty: 0 }), - c("shaky", 2, 5, null, { uncertainty: 9 }), - ], - { lastTable: null }, - uncertainty, - first, - ); - expect(pick?.id).toBe("thin"); - }); - - it("takes the least settled project once coverage is met", () => { - const pick = pickNext( - [ - c("settled", 1, 2, null, { uncertainty: 0.1 }), - c("open", 9, 2, null, { uncertainty: 4 }), - ], - { lastTable: 1 }, - uncertainty, - first, - ); - expect(pick?.id).toBe("open"); - }); -}); diff --git a/packages/judging-core/src/dispatch.ts b/packages/judging-core/src/dispatch.ts deleted file mode 100644 index aa453f98..00000000 --- a/packages/judging-core/src/dispatch.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { distance } from "./distance"; -import type { TablePoint } from "./distance"; - -export type Candidate = { - id: string; - tableNumber: number; - zoneId?: string | null; - x?: number | null; - y?: number | null; - /** Scores plus live claims from judges in the same group. */ - coverage: number; - /** Most recent live claim by another judge, if any (ms epoch). */ - claimedAt: number | null; - /** Used by the uncertainty strategy once every project has its minimum looks. */ - uncertainty?: number; -}; - -export type JudgeSpot = { - lastTable: number | null; - zoneId?: string | null; - x?: number | null; - y?: number | null; -}; - -export type DispatchStrategy = "coverage" | "uncertainty"; - -export type DispatchConfig = { - strategy: DispatchStrategy; - /** Uncertainty waits until every candidate has at least this many looks. */ - minLooksPerProject: number; -}; - -/** - * The next project for one judge, from the projects they are eligible for - * and have not had. - * - * Coverage: fewest looks, then the nearest table, then `random`. A table - * someone is already at is passed over while any other exists. When every - * remaining one is taken, the one claimed longest ago, because an idle - * judge is worse than two at a table. - * - * Uncertainty: the same coverage rule until `minLooksPerProject` is met - * everywhere in `candidates`, then the highest uncertainty, then nearest. - */ -export function pickNext( - candidates: readonly Candidate[], - judge: JudgeSpot, - config: DispatchConfig, - random: (n: number) => number, -): Candidate | null { - if (candidates.length === 0) return null; - - const free = candidates.filter((candidate) => candidate.claimedAt === null); - if (free.length === 0) return oldestClaim(candidates); - - const stillCovering = - config.strategy === "coverage" || - candidates.some( - (candidate) => candidate.coverage < config.minLooksPerProject, - ); - - const tier = stillCovering ? fewestLooks(free) : highestUncertainty(free); - return narrow(tier, judge, random); -} - -function fewestLooks(free: readonly Candidate[]): Candidate[] { - const fewest = Math.min(...free.map((candidate) => candidate.coverage)); - return free.filter((candidate) => candidate.coverage === fewest); -} - -function highestUncertainty(free: readonly Candidate[]): Candidate[] { - const highest = Math.max( - ...free.map((candidate) => candidate.uncertainty ?? 0), - ); - return free.filter( - (candidate) => (candidate.uncertainty ?? 0) === highest, - ); -} - -function oldestClaim(candidates: readonly Candidate[]): Candidate | null { - let best: Candidate | null = null; - for (const candidate of candidates) { - if (!best || (candidate.claimedAt ?? 0) < (best.claimedAt ?? 0)) { - best = candidate; - } - } - return best; -} - -function narrow( - tier: readonly Candidate[], - judge: JudgeSpot, - random: (n: number) => number, -): Candidate | null { - if (tier.length === 0) return null; - if (judge.lastTable === null) return choose(tier, random); - - const here: TablePoint = { - number: judge.lastTable, - zoneId: judge.zoneId ?? null, - x: judge.x ?? null, - y: judge.y ?? null, - }; - const nearest = Math.min( - ...tier.map((candidate) => distance(here, candidatePoint(candidate))), - ); - const closest = tier.filter( - (candidate) => distance(here, candidatePoint(candidate)) === nearest, - ); - return choose(closest, random); -} - -function choose( - tier: readonly Candidate[], - random: (n: number) => number, -): Candidate | null { - if (tier.length === 0) return null; - return tier[random(tier.length)] ?? null; -} - -function candidatePoint(candidate: Candidate): TablePoint { - return { - number: candidate.tableNumber, - zoneId: candidate.zoneId ?? null, - x: candidate.x ?? null, - y: candidate.y ?? null, - }; -} diff --git a/packages/judging-core/src/distance.test.ts b/packages/judging-core/src/distance.test.ts deleted file mode 100644 index 3bd7ee03..00000000 --- a/packages/judging-core/src/distance.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { distance, ZONE_HOP } from "./distance"; - -const table = ( - number: number, - zoneId: string | null = null, - x: number | null = null, - y: number | null = null, -) => ({ number, zoneId, x, y }); - -describe("distance", () => { - it("is the table-number gap inside one zone", () => { - expect(distance(table(4, "hall"), table(11, "hall"))).toBe(7); - }); - - it("is the same gap when neither table has a zone", () => { - expect(distance(table(4), table(11))).toBe(7); - }); - - it("adds a zone hop when the rooms differ", () => { - expect(distance(table(4, "hall"), table(6, "atrium"))).toBe(ZONE_HOP + 2); - }); - - it("uses the floor plan when both tables have coordinates", () => { - expect(distance(table(1, "hall", 0, 0), table(9, "atrium", 3, 4))).toBe(5); - }); - - it("falls back to numbers when only one table is placed", () => { - expect(distance(table(1, "hall", 0, 0), table(4, "hall"))).toBe(3); - }); -}); diff --git a/packages/judging-core/src/distance.ts b/packages/judging-core/src/distance.ts deleted file mode 100644 index 6601a79c..00000000 --- a/packages/judging-core/src/distance.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * How far one table is from another. - * - * Same zone: the difference in table numbers, which is the walk. - * Another zone: a hop larger than any table gap, then the number difference, - * so a same-zone walk always beats changing rooms. - * When both tables have coordinates, the floor plan wins and the distance - * is Euclidean. - */ - -export type TablePoint = { - number: number; - zoneId: string | null; - x: number | null; - y: number | null; -}; - -/** Larger than any table-number gap, so a zone change never sorts as "near". */ -export const ZONE_HOP = 1_000_000; - -export function distance(a: TablePoint, b: TablePoint): number { - if (a.x !== null && a.y !== null && b.x !== null && b.y !== null) { - return Math.hypot(a.x - b.x, a.y - b.y); - } - const diff = Math.abs(a.number - b.number); - if (a.zoneId !== null && b.zoneId !== null && a.zoneId !== b.zoneId) { - return ZONE_HOP + diff; - } - return diff; -} diff --git a/packages/judging-core/src/events.test.ts b/packages/judging-core/src/events.test.ts deleted file mode 100644 index 72a63800..00000000 --- a/packages/judging-core/src/events.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - boardChannel, - isOutboxTopic, - judgeChannel, - leaderboardChannel, - LOG_KINDS, - OUTBOX_TOPICS, -} from "./events"; -import type { LogKind } from "./events"; - -describe("events", () => { - it("only queues topics the log already knows", () => { - for (const topic of OUTBOX_TOPICS) { - expect(LOG_KINDS).toContain(topic); - expect(isOutboxTopic(topic)).toBe(true); - } - }); - - it("does not queue a log row that nobody subscribes to", () => { - expect(isOutboxTopic("project.upserted")).toBe(false); - }); - - it("names the channels the board, the leaderboard, and a judge listen on", () => { - expect(boardChannel("evt")).toBe("event:evt:board"); - expect(leaderboardChannel("evt")).toBe("event:evt:leaderboard"); - expect(judgeChannel("j1")).toBe("judge:j1"); - }); - - it("accepts every log kind as a log kind", () => { - const kind: LogKind = "vote.cast"; - expect(LOG_KINDS).toContain(kind); - }); -}); diff --git a/packages/judging-core/src/events.ts b/packages/judging-core/src/events.ts deleted file mode 100644 index 63c04d3e..00000000 --- a/packages/judging-core/src/events.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * One vocabulary for the append-only log, the outbox, and the websocket. - * The server and the web both import this so a topic cannot drift. - */ - -export const LOG_KINDS = [ - "phase.changed", - "project.upserted", - "project.withdrawn", - "project.table_assigned", - "project.qr_rotated", - "track.saved", - "prize.saved", - "zone.saved", - "rubric.saved", - "table.placed", - "webhook.saved", - "api_key.created", - "config.saved", - "judge.upserted", - "judge.approved", - "judge.suspended", - "visit.handed_out", - "visit.arrived", - "visit.voided", - "visit.skipped", - "vote.cast", - "comparison.cast", - "results.computed", - "results.published", - "results.unpublished", - "judge.recalled", -] as const; - -export type LogKind = (typeof LOG_KINDS)[number]; - -export const OUTBOX_TOPICS = [ - "visit.handed_out", - "visit.arrived", - "visit.voided", - "vote.cast", - "phase.changed", - "results.published", - "results.unpublished", - "judge.recalled", -] as const; - -export type OutboxTopic = (typeof OUTBOX_TOPICS)[number]; - -const OUTBOX: ReadonlySet = new Set(OUTBOX_TOPICS); - -export function isOutboxTopic(kind: LogKind): kind is OutboxTopic { - return OUTBOX.has(kind); -} - -export function boardChannel(eventId: string): string { - return `event:${eventId}:board`; -} - -export function leaderboardChannel(eventId: string): string { - return `event:${eventId}:leaderboard`; -} - -export function judgeChannel(judgeId: string): string { - return `judge:${judgeId}`; -} diff --git a/packages/judging-core/src/index.ts b/packages/judging-core/src/index.ts deleted file mode 100644 index a65497e1..00000000 --- a/packages/judging-core/src/index.ts +++ /dev/null @@ -1,56 +0,0 @@ -export { bayesianShrink, round2 } from "./aggregate/bayes"; -export { bradleyTerry } from "./aggregate/bradleyTerry"; -export { blend } from "./aggregate/blend"; -export { rank } from "./aggregate/rank"; -export { zNormalize } from "./aggregate/zscore"; -export type { - ProjectInput, - RankComparison, - RankConfig, - RankedRow, - VoteInput, -} from "./aggregate/rank"; -export type { Comparison } from "./comparison"; -export { pickNext } from "./dispatch"; -export { assignTables } from "./tables"; -export type { Assignment, Seat, SeatedProject } from "./tables"; -export type { - Candidate, - DispatchConfig, - DispatchStrategy, - JudgeSpot, -} from "./dispatch"; -export { distance, ZONE_HOP } from "./distance"; -export type { TablePoint } from "./distance"; -export { - boardChannel, - isOutboxTopic, - judgeChannel, - leaderboardChannel, - LOG_KINDS, - OUTBOX_TOPICS, -} from "./events"; -export type { LogKind, OutboxTopic } from "./events"; -export { projectMatchesTrack } from "./match"; -export { - assertPhaseAllows, - assertTransition, - canTransition, - phaseAllows, - PHASES, -} from "./phase"; -export type { Phase, PhaseAction } from "./phase"; -export { validateScores, weightedTotal } from "./rubric"; -export type { Criterion, ScoreCheck, ScoreInput } from "./rubric"; -export { - DEFAULT_TIMERS, - isLive, - isOverTarget, - isPastCutoff, -} from "./timers"; -export type { TimerConfig, VisitClock } from "./timers"; -export { - contestBonus, - projectUncertainty, - scoreUncertainty, -} from "./uncertainty"; diff --git a/packages/judging-core/src/match.test.ts b/packages/judging-core/src/match.test.ts deleted file mode 100644 index 9127ca98..00000000 --- a/packages/judging-core/src/match.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { projectMatchesTrack } from "./match"; - -describe("projectMatchesTrack", () => { - const project = { - tracks: ["Finance"], - challenges: ["Sponsor"], - isCreateX: true, - }; - - it("matches every project when the judge has no track", () => { - expect(projectMatchesTrack(project, null)).toBe(true); - }); - - it("matches by track, challenge, and the createx flag", () => { - expect(projectMatchesTrack(project, "Finance")).toBe(true); - expect(projectMatchesTrack(project, "Sponsor")).toBe(true); - expect(projectMatchesTrack(project, "createX")).toBe(true); - expect(projectMatchesTrack(project, "Health")).toBe(false); - }); -}); diff --git a/packages/judging-core/src/match.ts b/packages/judging-core/src/match.ts deleted file mode 100644 index e2abcea4..00000000 --- a/packages/judging-core/src/match.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Label match for an import that still carries track strings on the project. - * The product path is a project_track row. This remains so those strings - * route the same way they did before tracks were rows. - */ -export function projectMatchesTrack( - project: { - tracks: string[] | null; - challenges: string[] | null; - isCreateX?: boolean | null; - }, - track: string | null, -): boolean { - if (!track) return true; - const inTracks = project.tracks?.includes(track) ?? false; - const inChallenges = project.challenges?.includes(track) ?? false; - const matchCreateX = - track.toLowerCase() === "createx" && !!project.isCreateX; - return inTracks || inChallenges || matchCreateX; -} diff --git a/packages/judging-core/src/package.test.ts b/packages/judging-core/src/package.test.ts deleted file mode 100644 index 85a15172..00000000 --- a/packages/judging-core/src/package.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -describe("@query/judging-core package", () => { - it("has no runtime dependencies", () => { - const packageJson = join(dirname(fileURLToPath(import.meta.url)), "../package.json"); - const pkg = JSON.parse(readFileSync(packageJson, "utf8")) as { - dependencies?: Record; - }; - expect(pkg.dependencies ?? {}).toEqual({}); - }); -}); diff --git a/packages/judging-core/src/phase.test.ts b/packages/judging-core/src/phase.test.ts deleted file mode 100644 index a4f885ba..00000000 --- a/packages/judging-core/src/phase.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - assertPhaseAllows, - assertTransition, - canTransition, - phaseAllows, -} from "./phase"; - -describe("phase", () => { - it("walks an event from setup through archive", () => { - const path = [ - "setup", - "submissions_open", - "submissions_closed", - "judging_live", - "judging_closed", - "published", - "archived", - ] as const; - for (let index = 1; index < path.length; index += 1) { - const from = path[index - 1]; - const to = path[index]; - if (!from || !to) throw new Error("path"); - expect(canTransition(from, to)).toBe(true); - } - }); - - it("allows the steps an organizer actually takes backwards", () => { - expect(canTransition("submissions_open", "setup")).toBe(true); - expect(canTransition("submissions_closed", "submissions_open")).toBe(true); - expect(canTransition("judging_closed", "judging_live")).toBe(true); - expect(canTransition("published", "judging_closed")).toBe(true); - }); - - it("refuses a jump and a move out of the archive", () => { - expect(canTransition("setup", "judging_live")).toBe(false); - expect(canTransition("archived", "setup")).toBe(false); - expect(() => assertTransition("setup", "published")).toThrow(/setup/); - }); - - it("permits a vote only while judging is live", () => { - expect(phaseAllows("judging_live", "vote")).toBe(true); - expect(phaseAllows("judging_closed", "vote")).toBe(false); - expect(phaseAllows("submissions_open", "vote")).toBe(false); - expect(() => assertPhaseAllows("setup", "vote")).toThrow(/vote/); - }); - - it("permits dispatch only while judging is live, and publish only after it closes", () => { - expect(phaseAllows("judging_live", "dispatch")).toBe(true); - expect(phaseAllows("judging_closed", "dispatch")).toBe(false); - expect(phaseAllows("judging_closed", "publish")).toBe(true); - expect(phaseAllows("judging_live", "publish")).toBe(false); - expect(phaseAllows("judging_live", "compute")).toBe(true); - expect(phaseAllows("submissions_open", "submit")).toBe(true); - expect(phaseAllows("judging_live", "submit")).toBe(false); - expect(phaseAllows("setup", "apply")).toBe(true); - expect(phaseAllows("judging_live", "apply")).toBe(false); - }); -}); diff --git a/packages/judging-core/src/phase.ts b/packages/judging-core/src/phase.ts deleted file mode 100644 index 7a947a65..00000000 --- a/packages/judging-core/src/phase.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * What an event is allowed to do, and which phase it may move to next. - * The server checks this on every mutation. The core does not persist it. - */ - -export const PHASES = [ - "setup", - "submissions_open", - "submissions_closed", - "judging_live", - "judging_closed", - "published", - "archived", -] as const; - -export type Phase = (typeof PHASES)[number]; - -export type PhaseAction = - | "submit" - | "apply" - | "dispatch" - | "vote" - | "compute" - | "publish"; - -const TRANSITIONS: Record = { - setup: ["submissions_open"], - submissions_open: ["submissions_closed", "setup"], - submissions_closed: ["judging_live", "submissions_open"], - judging_live: ["judging_closed"], - judging_closed: ["published", "judging_live"], - published: ["archived", "judging_closed"], - archived: [], -}; - -const PERMISSIONS: Record = { - submit: ["submissions_open"], - apply: ["setup", "submissions_open", "submissions_closed"], - dispatch: ["judging_live"], - vote: ["judging_live"], - compute: ["judging_live", "judging_closed"], - publish: ["judging_closed"], -}; - -export function canTransition(from: Phase, to: Phase): boolean { - return TRANSITIONS[from].includes(to); -} - -export function assertTransition(from: Phase, to: Phase): void { - if (!canTransition(from, to)) { - throw new Error(`Cannot move an event from ${from} to ${to}`); - } -} - -export function phaseAllows(phase: Phase, action: PhaseAction): boolean { - return PERMISSIONS[action].includes(phase); -} - -export function assertPhaseAllows(phase: Phase, action: PhaseAction): void { - if (!phaseAllows(phase, action)) { - throw new Error(`${action} is not allowed while the event is ${phase}`); - } -} diff --git a/packages/judging-core/src/rubric.test.ts b/packages/judging-core/src/rubric.test.ts deleted file mode 100644 index 589db6f1..00000000 --- a/packages/judging-core/src/rubric.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateScores, weightedTotal } from "./rubric"; -import type { Criterion } from "./rubric"; - -const criteria: Criterion[] = [ - { id: "creativity", min: 0, max: 10, weight: 1 }, - { id: "impact", min: 0, max: 10, weight: 3 }, -]; - -describe("validateScores", () => { - it("accepts one in-range value per criterion", () => { - expect( - validateScores(criteria, [ - { criterionId: "creativity", value: 0 }, - { criterionId: "impact", value: 10 }, - ]), - ).toEqual({ ok: true }); - }); - - it("rejects a missing, duplicate, unknown, or out-of-range score", () => { - expect(validateScores(criteria, []).ok).toBe(false); - expect( - validateScores(criteria, [ - { criterionId: "creativity", value: 1 }, - { criterionId: "creativity", value: 2 }, - { criterionId: "impact", value: 3 }, - ]).ok, - ).toBe(false); - expect( - validateScores(criteria, [ - { criterionId: "creativity", value: 1 }, - { criterionId: "nope", value: 1 }, - ]).ok, - ).toBe(false); - expect( - validateScores(criteria, [ - { criterionId: "creativity", value: 11 }, - { criterionId: "impact", value: 1 }, - ]).ok, - ).toBe(false); - }); -}); - -describe("weightedTotal", () => { - it("is the weighted mean", () => { - expect( - weightedTotal(criteria, [ - { criterionId: "creativity", value: 4 }, - { criterionId: "impact", value: 8 }, - ]), - ).toBe(7); - }); - - it("is zero when every weight is zero", () => { - const flat = criteria.map((criterion) => ({ ...criterion, weight: 0 })); - expect( - weightedTotal(flat, [ - { criterionId: "creativity", value: 4 }, - { criterionId: "impact", value: 8 }, - ]), - ).toBe(0); - }); - - it("throws when the scores are not valid", () => { - expect(() => weightedTotal(criteria, [])).toThrow(/missing/); - }); -}); diff --git a/packages/judging-core/src/rubric.ts b/packages/judging-core/src/rubric.ts deleted file mode 100644 index 14292b8d..00000000 --- a/packages/judging-core/src/rubric.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * A score is one number per criterion, inside that criterion's range. - * The total is the weighted mean, so it stays on the same scale as the - * criteria. The rows are the truth; the total is derived. - */ - -export type Criterion = { - id: string; - min: number; - max: number; - weight: number; -}; - -export type ScoreInput = { - criterionId: string; - value: number; -}; - -export type ScoreCheck = { ok: true } | { ok: false; reason: string }; - -export function validateScores( - criteria: readonly Criterion[], - input: readonly ScoreInput[], -): ScoreCheck { - for (const criterion of criteria) { - if (!Number.isFinite(criterion.weight) || criterion.weight < 0) { - return { - ok: false, - reason: `weight for ${criterion.id} must be zero or more`, - }; - } - if ( - !Number.isFinite(criterion.min) || - !Number.isFinite(criterion.max) || - criterion.min > criterion.max - ) { - return { ok: false, reason: `range for ${criterion.id} is invalid` }; - } - } - - const seen = new Set(); - for (const score of input) { - if (seen.has(score.criterionId)) { - return { ok: false, reason: `duplicate score for ${score.criterionId}` }; - } - seen.add(score.criterionId); - const criterion = criteria.find((item) => item.id === score.criterionId); - if (!criterion) { - return { ok: false, reason: `unknown criterion ${score.criterionId}` }; - } - if ( - !Number.isFinite(score.value) || - score.value < criterion.min || - score.value > criterion.max - ) { - return { - ok: false, - reason: `${score.criterionId} must be between ${criterion.min} and ${criterion.max}`, - }; - } - } - - for (const criterion of criteria) { - if (!seen.has(criterion.id)) { - return { ok: false, reason: `missing score for ${criterion.id}` }; - } - } - - return { ok: true }; -} - -export function weightedTotal( - criteria: readonly Criterion[], - input: readonly ScoreInput[], -): number { - const check = validateScores(criteria, input); - if (!check.ok) throw new Error(check.reason); - const weight = criteria.reduce((sum, criterion) => sum + criterion.weight, 0); - if (weight === 0) return 0; - const byId = new Map(input.map((score) => [score.criterionId, score.value])); - const sum = criteria.reduce((total, criterion) => { - return total + (byId.get(criterion.id) ?? 0) * criterion.weight; - }, 0); - return sum / weight; -} diff --git a/packages/judging-core/src/tables.test.ts b/packages/judging-core/src/tables.test.ts deleted file mode 100644 index b02827c8..00000000 --- a/packages/judging-core/src/tables.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { assignTables } from "./tables"; - -describe("assignTables", () => { - it("alternates tracks across table numbers", () => { - const seated = assignTables( - [ - { id: "a1", trackId: "sponsor" }, - { id: "a2", trackId: "sponsor" }, - { id: "b1", trackId: "general" }, - { id: "b2", trackId: "general" }, - ], - [ - { number: 1, zoneId: "hall" }, - { number: 2, zoneId: "hall" }, - { number: 3, zoneId: "hall" }, - { number: 4, zoneId: "hall" }, - ], - ); - expect(seated.map((row) => row.projectId)).toEqual(["a1", "b1", "a2", "b2"]); - expect(seated.map((row) => row.tableNumber)).toEqual([1, 2, 3, 4]); - }); - - it("leaves extra projects unseated when tables run out", () => { - const seated = assignTables( - [ - { id: "a", trackId: null }, - { id: "b", trackId: null }, - ], - [{ number: 9, zoneId: null }], - ); - expect(seated).toEqual([{ projectId: "a", tableNumber: 9, zoneId: null }]); - }); -}); diff --git a/packages/judging-core/src/tables.ts b/packages/judging-core/src/tables.ts deleted file mode 100644 index af287f1d..00000000 --- a/packages/judging-core/src/tables.ts +++ /dev/null @@ -1,52 +0,0 @@ -export type Seat = { - number: number; - zoneId: string | null; -}; - -export type SeatedProject = { - id: string; - trackId: string | null; -}; - -export type Assignment = { - projectId: string; - tableNumber: number; - zoneId: string | null; -}; - -/** - * Seats projects so tracks take turns. Two sponsor projects do not land on - * neighbouring tables while another track is still waiting for a seat. - */ -export function assignTables( - projects: readonly SeatedProject[], - tables: readonly Seat[], -): Assignment[] { - const queues = new Map(); - for (const project of projects) { - const key = project.trackId ?? ""; - const queue = queues.get(key) ?? []; - queue.push(project); - queues.set(key, queue); - } - const lanes = [...queues.values()]; - const ordered: SeatedProject[] = []; - while (ordered.length < projects.length) { - for (const lane of lanes) { - const next = lane.shift(); - if (next) ordered.push(next); - } - } - const seats = [...tables].sort((a, b) => a.number - b.number); - return ordered.slice(0, seats.length).flatMap((project, index) => { - const seat = seats[index]; - if (!seat) return []; - return [ - { - projectId: project.id, - tableNumber: seat.number, - zoneId: seat.zoneId, - }, - ]; - }); -} diff --git a/packages/judging-core/src/timers.test.ts b/packages/judging-core/src/timers.test.ts deleted file mode 100644 index 24c3a19d..00000000 --- a/packages/judging-core/src/timers.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { DEFAULT_TIMERS, isLive, isOverTarget, isPastCutoff } from "./timers"; - -const t0 = new Date("2027-02-28T13:00:00Z"); -const at = (seconds: number) => new Date(t0.getTime() + seconds * 1000); -const timers = DEFAULT_TIMERS; - -describe("isLive", () => { - const walking = { - handedOutAt: t0, - arrivedAt: null, - completedAt: null, - }; - const atTable = { - handedOutAt: t0, - arrivedAt: at(60), - completedAt: null, - }; - - it("holds a table for the walk limit after hand-out", () => { - expect(isLive(walking, at(timers.walkLimitSeconds), timers)).toBe(true); - expect(isLive(walking, at(timers.walkLimitSeconds + 1), timers)).toBe( - false, - ); - }); - - it("holds it through the hard limit and grace after arrival", () => { - const end = - 60 + timers.hardLimitSeconds + timers.submitGraceSeconds; - expect(isLive(atTable, at(end), timers)).toBe(true); - expect(isLive(atTable, at(end + 1), timers)).toBe(false); - }); - - it("measures from arrival, not hand-out, once the judge has tapped in", () => { - expect(isLive(atTable, at(timers.walkLimitSeconds + 30), timers)).toBe( - true, - ); - }); - - it("is never live once completed, or when never handed out", () => { - expect( - isLive({ ...atTable, completedAt: at(61) }, at(61), timers), - ).toBe(false); - expect( - isLive( - { handedOutAt: null, arrivedAt: null, completedAt: null }, - t0, - timers, - ), - ).toBe(false); - }); - - it("honours a shorter limit from config", () => { - const short = { ...timers, walkLimitSeconds: 30 }; - expect(isLive(walking, at(30), short)).toBe(true); - expect(isLive(walking, at(31), short)).toBe(false); - }); -}); - -describe("isPastCutoff", () => { - it("allows a score at the hard limit and within the grace after it", () => { - expect(isPastCutoff(t0, at(timers.hardLimitSeconds), timers)).toBe(false); - expect( - isPastCutoff( - t0, - at(timers.hardLimitSeconds + timers.submitGraceSeconds), - timers, - ), - ).toBe(false); - }); - - it("refuses one after the grace", () => { - expect( - isPastCutoff( - t0, - at(timers.hardLimitSeconds + timers.submitGraceSeconds + 1), - timers, - ), - ).toBe(true); - }); -}); - -describe("isOverTarget", () => { - it("turns over at the target and not before", () => { - expect(isOverTarget(t0, at(timers.targetSeconds), timers)).toBe(false); - expect(isOverTarget(t0, at(timers.targetSeconds + 1), timers)).toBe(true); - }); -}); diff --git a/packages/judging-core/src/timers.ts b/packages/judging-core/src/timers.ts deleted file mode 100644 index d14bdd5b..00000000 --- a/packages/judging-core/src/timers.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Visit clocks. Every function takes `now` so a replay of an event does not - * depend on when the code happens to run. - * - * The defaults match the timings the first event used: three minutes at a - * table, four minutes hard, four minutes to walk, fifteen seconds of grace - * for a submit that left the phone on the buzzer. - */ - -export type TimerConfig = { - targetSeconds: number; - hardLimitSeconds: number; - walkLimitSeconds: number; - submitGraceSeconds: number; -}; - -export const DEFAULT_TIMERS: TimerConfig = { - targetSeconds: 180, - hardLimitSeconds: 240, - walkLimitSeconds: 240, - submitGraceSeconds: 15, -}; - -export type VisitClock = { - handedOutAt: Date | null; - arrivedAt: Date | null; - completedAt: Date | null; -}; - -/** - * Whether a visit still holds its table. Walking: until the walk limit after - * hand-out. At the table: until the hard limit (plus grace) after arrival. - */ -export function isLive( - slot: VisitClock, - now: Date, - config: TimerConfig, -): boolean { - if (slot.completedAt) return false; - const t = now.getTime(); - if (slot.arrivedAt) { - return ( - t <= - slot.arrivedAt.getTime() + - (config.hardLimitSeconds + config.submitGraceSeconds) * 1000 - ); - } - if (slot.handedOutAt) { - return t <= slot.handedOutAt.getTime() + config.walkLimitSeconds * 1000; - } - return false; -} - -/** Past the hard cutoff, with grace: a score now arrives too late to count. */ -export function isPastCutoff( - arrivedAt: Date, - now: Date, - config: TimerConfig, -): boolean { - return ( - now.getTime() > - arrivedAt.getTime() + - (config.hardLimitSeconds + config.submitGraceSeconds) * 1000 - ); -} - -/** The judge page turns amber here. Measured from arrival, not hand-out. */ -export function isOverTarget( - arrivedAt: Date, - now: Date, - config: TimerConfig, -): boolean { - return now.getTime() > arrivedAt.getTime() + config.targetSeconds * 1000; -} diff --git a/packages/judging-core/src/uncertainty.test.ts b/packages/judging-core/src/uncertainty.test.ts deleted file mode 100644 index d97fc34e..00000000 --- a/packages/judging-core/src/uncertainty.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - contestBonus, - projectUncertainty, - scoreUncertainty, -} from "./uncertainty"; - -describe("scoreUncertainty", () => { - it("is infinite when nobody has scored the project", () => { - expect(scoreUncertainty([])).toBe(Number.POSITIVE_INFINITY); - }); - - it("is zero when every score agrees", () => { - expect(scoreUncertainty([5, 5, 5])).toBe(0); - }); - - it("shrinks as the same spread is seen more times", () => { - const small = scoreUncertainty([0, 10]); - const large = scoreUncertainty([0, 10, 0, 10]); - expect(large).toBeLessThan(small); - }); -}); - -describe("contestBonus", () => { - it("is zero when one project always wins", () => { - expect( - contestBonus( - [ - { a: "a", b: "b", outcome: "a" }, - { a: "a", b: "b", outcome: "a" }, - ], - "a", - ), - ).toBe(0); - }); - - it("is one when the two projects split", () => { - expect( - contestBonus( - [ - { a: "a", b: "b", outcome: "a" }, - { a: "a", b: "b", outcome: "b" }, - ], - "a", - ), - ).toBe(1); - }); - - it("counts a tie as contested", () => { - expect(contestBonus([{ a: "a", b: "b", outcome: "tie" }], "a")).toBe(1); - }); -}); - -describe("projectUncertainty", () => { - it("adds the contest bonus on top of the score spread", () => { - const scores = [0, 10]; - const bonus = contestBonus([{ a: "a", b: "b", outcome: "tie" }], "a"); - expect( - projectUncertainty(scores, [{ a: "a", b: "b", outcome: "tie" }], "a"), - ).toBeCloseTo(scoreUncertainty(scores) + bonus, 10); - }); - - it("stays infinite when there are no scores yet", () => { - expect(projectUncertainty([], [], "a")).toBe(Number.POSITIVE_INFINITY); - }); -}); diff --git a/packages/judging-core/src/uncertainty.ts b/packages/judging-core/src/uncertainty.ts deleted file mode 100644 index 07a5b4b2..00000000 --- a/packages/judging-core/src/uncertainty.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Comparison } from "./comparison"; - -/** - * How unsettled a project's rank is. - * Score spread shrinks as more judges agree and as the sample grows. - * A pairwise neighbour that is split (or tied) adds a bonus, because those - * two projects are the ones another look can still reorder. - */ - -export function scoreUncertainty(scores: readonly number[]): number { - const n = scores.length; - if (n === 0) return Number.POSITIVE_INFINITY; - const mean = scores.reduce((sum, value) => sum + value, 0) / n; - const variance = - scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / n; - return variance / Math.sqrt(n); -} - -export function contestBonus( - comparisons: readonly Comparison[], - projectId: string, -): number { - const against = new Map< - string, - { wins: number; losses: number; ties: number } - >(); - - for (const comparison of comparisons) { - const opponent = - comparison.a === projectId - ? comparison.b - : comparison.b === projectId - ? comparison.a - : null; - if (!opponent) continue; - const row = against.get(opponent) ?? { wins: 0, losses: 0, ties: 0 }; - if (comparison.outcome === "tie") row.ties += 1; - else if ( - (comparison.outcome === "a" && comparison.a === projectId) || - (comparison.outcome === "b" && comparison.b === projectId) - ) { - row.wins += 1; - } else { - row.losses += 1; - } - against.set(opponent, row); - } - - let bonus = 0; - for (const row of against.values()) { - const n = row.wins + row.losses + row.ties; - if (n === 0) continue; - bonus += (n - Math.abs(row.wins - row.losses)) / n; - } - return bonus; -} - -export function projectUncertainty( - scores: readonly number[], - comparisons: readonly Comparison[], - projectId: string, -): number { - const spread = scoreUncertainty(scores); - if (!Number.isFinite(spread)) return spread; - return spread + contestBonus(comparisons, projectId); -} diff --git a/packages/judging-core/tsconfig.build.json b/packages/judging-core/tsconfig.build.json deleted file mode 100644 index 2da00172..00000000 --- a/packages/judging-core/tsconfig.build.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": false, - "declaration": true, - "sourceMap": true, - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts", "node_modules", "dist"] -} diff --git a/packages/judging-core/tsconfig.json b/packages/judging-core/tsconfig.json deleted file mode 100644 index f54bde2c..00000000 --- a/packages/judging-core/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tooling/typescript/base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "types": ["node"] - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/judging-core/vitest.config.ts b/packages/judging-core/vitest.config.ts deleted file mode 100644 index ae847ff6..00000000 --- a/packages/judging-core/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["src/**/*.test.ts"], - }, -}); diff --git a/packages/judging-db/eslint.config.mjs b/packages/judging-db/eslint.config.mjs deleted file mode 100644 index 780d26b6..00000000 --- a/packages/judging-db/eslint.config.mjs +++ /dev/null @@ -1,4 +0,0 @@ -import { config } from "@query/eslint-config/base"; - -/** @type {import("eslint").Linter.Config[]} */ -export default [...config]; diff --git a/packages/judging-db/migrations/0001_init.sql b/packages/judging-db/migrations/0001_init.sql deleted file mode 100644 index 2037f72f..00000000 --- a/packages/judging-db/migrations/0001_init.sql +++ /dev/null @@ -1,309 +0,0 @@ -create extension if not exists pgcrypto; - -create type event_phase as enum ( - 'setup', - 'submissions_open', - 'submissions_closed', - 'judging_live', - 'judging_closed', - 'published', - 'archived' -); - -create type track_kind as enum ('main', 'sponsor', 'special'); - -create type dispatch_strategy as enum ('coverage', 'uncertainty'); - -create type judge_status as enum ('invited', 'applied', 'approved', 'suspended'); - -create type comparison_outcome as enum ('a', 'b', 'tie'); - -create type membership_role as enum ('owner', 'admin', 'organizer', 'volunteer'); - -create table organization ( - id uuid primary key default gen_random_uuid(), - slug text not null unique, - name text not null, - branding jsonb not null default '{}'::jsonb -); - -create table panel_user ( - id uuid primary key default gen_random_uuid(), - email text not null unique, - name text not null -); - -create table panel_event ( - id uuid primary key default gen_random_uuid(), - org_id uuid not null references organization (id) on delete cascade, - slug text not null, - name text not null, - starts_at timestamptz, - ends_at timestamptz, - phase event_phase not null default 'setup', - unique (org_id, slug) -); - -create table event_config ( - event_id uuid primary key references panel_event (id) on delete cascade, - target_seconds integer not null, - hard_limit_seconds integer not null, - walk_limit_seconds integer not null, - submit_grace_seconds integer not null, - min_looks_per_project integer not null, - dispatch_strategy dispatch_strategy not null, - pairwise_enabled boolean not null default false, - pairwise_weight numeric not null default 0, - bayesian_c numeric not null default 2, - calibration_looks integer not null default 0, - calibration_weight numeric not null default 1, - feedback_cards_enabled boolean not null default false, - leaderboard_public boolean not null default false, - board_poll_seconds integer not null default 5 -); - -create table rubric ( - id uuid primary key default gen_random_uuid(), - event_id uuid not null references panel_event (id) on delete cascade, - name text not null, - is_default boolean not null default false -); - -create table criterion ( - id uuid primary key default gen_random_uuid(), - rubric_id uuid not null references rubric (id) on delete cascade, - position integer not null, - key text not null, - label text not null, - description text, - min numeric not null, - max numeric not null, - weight numeric not null, - anchors jsonb not null default '[]'::jsonb, - unique (rubric_id, key) -); - -create table track ( - id uuid primary key default gen_random_uuid(), - event_id uuid not null references panel_event (id) on delete cascade, - slug text not null, - name text not null, - kind track_kind not null, - judge_group text not null, - rubric_id uuid references rubric (id), - position integer not null default 0, - is_active boolean not null default true, - unique (event_id, slug) -); - -create table prize ( - id uuid primary key default gen_random_uuid(), - track_id uuid not null references track (id) on delete cascade, - place integer not null, - title text not null, - amount numeric, - description text, - unique (track_id, place) -); - -create table zone ( - id uuid primary key default gen_random_uuid(), - event_id uuid not null references panel_event (id) on delete cascade, - name text not null, - position integer not null default 0 -); - --- `table` is reserved in SQL, so the floor plan lives in floor_table. -create table floor_table ( - event_id uuid not null references panel_event (id) on delete cascade, - number integer not null, - zone_id uuid references zone (id), - x numeric, - y numeric, - primary key (event_id, number) -); - -create table project ( - id uuid primary key default gen_random_uuid(), - event_id uuid not null references panel_event (id) on delete cascade, - external_id text, - name text not null, - description text, - team_name text, - members jsonb not null default '[]'::jsonb, - links jsonb not null default '{}'::jsonb, - table_number integer, - zone_id uuid references zone (id), - qr_token uuid not null unique default gen_random_uuid(), - arrived_first_at timestamptz, - withdrawn_at timestamptz -); - -create unique index project_event_external_idx - on project (event_id, external_id) - where external_id is not null; - -create table project_track ( - project_id uuid not null references project (id) on delete cascade, - track_id uuid not null references track (id) on delete cascade, - primary key (project_id, track_id) -); - -create table panel_judge ( - id uuid primary key default gen_random_uuid(), - event_id uuid not null references panel_event (id) on delete cascade, - external_id text, - name text not null, - email text not null, - org text, - title text, - status judge_status not null default 'invited', - track_id uuid references track (id), - zone_id uuid references zone (id), - is_lead boolean not null default false, - unique (event_id, email) -); - -create unique index judge_event_external_idx - on panel_judge (event_id, external_id) - where external_id is not null; - -create table visit ( - id uuid primary key default gen_random_uuid(), - event_id uuid not null references panel_event (id) on delete cascade, - judge_id uuid not null references panel_judge (id) on delete cascade, - project_id uuid not null references project (id) on delete cascade, - judge_group text not null, - handed_out_at timestamptz not null, - arrived_at timestamptz, - completed_at timestamptz, - voided_at timestamptz, - void_reason text -); - --- A completed look still counts: the same judge is not handed that project again. -create unique index visit_judge_project_open_idx - on visit (judge_id, project_id) - where voided_at is null; - --- One table in hand at a time. The dispatch lock is the first guard; this is the second. -create unique index visit_one_open_idx - on visit (judge_id) - where voided_at is null and completed_at is null; - -create table vote ( - id uuid primary key default gen_random_uuid(), - visit_id uuid not null unique references visit (id) on delete cascade, - judge_id uuid not null references panel_judge (id) on delete cascade, - project_id uuid not null references project (id) on delete cascade, - event_id uuid not null references panel_event (id) on delete cascade, - total numeric not null, - comment text, - duration_seconds integer, - is_calibration boolean not null default false -); - -create table vote_score ( - vote_id uuid not null references vote (id) on delete cascade, - criterion_id uuid not null references criterion (id), - value numeric not null, - primary key (vote_id, criterion_id) -); - -create table comparison ( - id uuid primary key default gen_random_uuid(), - event_id uuid not null references panel_event (id) on delete cascade, - judge_id uuid not null references panel_judge (id) on delete cascade, - judge_group text not null, - a_project_id uuid not null references project (id), - b_project_id uuid not null references project (id), - outcome comparison_outcome not null, - created_at timestamptz not null default now() -); - -create table result_run ( - id uuid primary key default gen_random_uuid(), - event_id uuid not null references panel_event (id) on delete cascade, - computed_at timestamptz not null default now(), - computed_by uuid references panel_user (id), - config_snapshot jsonb not null, - published_at timestamptz, - notes text -); - -create table result ( - run_id uuid not null references result_run (id) on delete cascade, - project_id uuid not null references project (id) on delete cascade, - track_id uuid not null references track (id) on delete cascade, - placement integer, - score numeric not null, - rubric_component numeric not null, - pairwise_component numeric not null, - vote_count integer not null, - comparison_count integer not null, - flags text[] not null default '{}', - primary key (run_id, project_id, track_id) -); - -create table event_log ( - id uuid primary key default gen_random_uuid(), - event_id uuid not null references panel_event (id) on delete cascade, - kind text not null, - actor jsonb not null default '{}'::jsonb, - subject jsonb not null default '{}'::jsonb, - payload jsonb not null default '{}'::jsonb, - created_at timestamptz not null default now() -); - -create table outbox ( - id uuid primary key default gen_random_uuid(), - event_id uuid not null references panel_event (id) on delete cascade, - topic text not null, - payload jsonb not null, - created_at timestamptz not null default now(), - delivered_at timestamptz, - attempts integer not null default 0 -); - -create table webhook ( - id uuid primary key default gen_random_uuid(), - org_id uuid not null references organization (id) on delete cascade, - url text not null, - secret text not null, - topics text[] not null, - is_active boolean not null default true -); - -create table api_key ( - id uuid primary key default gen_random_uuid(), - org_id uuid not null references organization (id) on delete cascade, - hashed_key text not null, - scopes text[] not null, - created_at timestamptz not null default now(), - revoked_at timestamptz -); - -create table membership ( - user_id uuid not null references panel_user (id) on delete cascade, - org_id uuid not null references organization (id) on delete cascade, - role membership_role not null, - primary key (user_id, org_id) -); - -create table judge_identity ( - judge_id uuid primary key references panel_judge (id) on delete cascade, - user_id uuid not null references panel_user (id) on delete cascade -); - -create table login_code ( - id uuid primary key default gen_random_uuid(), - email text not null, - code_hash text not null, - expires_at timestamptz not null, - consumed_at timestamptz -); - -create index event_log_event_idx on event_log (event_id, created_at); -create index outbox_pending_idx on outbox (created_at) where delivered_at is null; -create index visit_event_idx on visit (event_id); -create index project_event_idx on project (event_id); diff --git a/packages/judging-db/migrations/0002_feedback_token.sql b/packages/judging-db/migrations/0002_feedback_token.sql deleted file mode 100644 index 1fc17747..00000000 --- a/packages/judging-db/migrations/0002_feedback_token.sql +++ /dev/null @@ -1 +0,0 @@ -alter table project add column feedback_token uuid unique; diff --git a/packages/judging-db/migrations/0003_shared_database.sql b/packages/judging-db/migrations/0003_shared_database.sql deleted file mode 100644 index 6a29b730..00000000 --- a/packages/judging-db/migrations/0003_shared_database.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Panel shares the club Postgres database. user, event, and judge already --- belong to that app, so panel's copies use a prefix. Rename only when the --- table is the panel one (uuid user id, event.org_id, judge.event_id). - -do $$ -begin - if exists ( - select 1 - from information_schema.columns - where table_schema = 'public' - and table_name = 'user' - and column_name = 'id' - and data_type = 'uuid' - ) and to_regclass('public.panel_user') is null then - alter table "user" rename to panel_user; - end if; - - if exists ( - select 1 - from information_schema.columns - where table_schema = 'public' - and table_name = 'event' - and column_name = 'org_id' - ) and to_regclass('public.panel_event') is null then - alter table event rename to panel_event; - end if; - - if exists ( - select 1 - from information_schema.columns - where table_schema = 'public' - and table_name = 'judge' - and column_name = 'event_id' - ) and to_regclass('public.panel_judge') is null then - alter table judge rename to panel_judge; - end if; -end $$; diff --git a/packages/judging-db/package.json b/packages/judging-db/package.json deleted file mode 100644 index 87a7eee0..00000000 --- a/packages/judging-db/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@query/judging-db", - "version": "0.0.0", - "private": true, - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "lint": "eslint . --max-warnings 0", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@query/judging-core": "workspace:*", - "drizzle-orm": "0.45.3", - "pg": "8.23.0" - }, - "devDependencies": { - "@query/eslint-config": "workspace:*", - "@query/tsconfig": "workspace:*", - "@types/node": "22.20.4", - "@types/pg": "^8.23.1", - "typescript": "7.0.2" - } -} diff --git a/packages/judging-db/src/client.ts b/packages/judging-db/src/client.ts deleted file mode 100644 index abb74e26..00000000 --- a/packages/judging-db/src/client.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { drizzle } from "drizzle-orm/node-postgres"; -import { Pool } from "pg"; -import * as schema from "./schema"; - -export function createDb(url: string) { - const pool = new Pool({ connectionString: url, max: 10 }); - return createDbFromPool(pool); -} - -/** Judging tables on a pool the host already owns, so one app keeps one set of connections. */ -export function createDbFromPool(pool: Pool) { - pool.on("error", () => { - // The pool drops a dead client on its own. An unhandled "error" event - // would take the process down. - }); - const db = drizzle(pool, { schema }); - return { db, pool }; -} - -export type PanelDb = ReturnType["db"]; -export type PanelTx = Parameters[0]>[0]; diff --git a/packages/judging-db/src/enums.ts b/packages/judging-db/src/enums.ts deleted file mode 100644 index e7bfb4a1..00000000 --- a/packages/judging-db/src/enums.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { pgEnum } from "drizzle-orm/pg-core"; - -// Kept apart from the tables because packages/db's drizzle config loads this -// file too. Judging creates these types with its own SQL migrations; push -// must see them as declared, or it tries to drop them from the shared -// database. Its tables stay hidden from push (tablesFilter there). - -export const eventPhase = pgEnum("event_phase", [ - "setup", - "submissions_open", - "submissions_closed", - "judging_live", - "judging_closed", - "published", - "archived", -]); - -export const trackKind = pgEnum("track_kind", ["main", "sponsor", "special"]); - -export const dispatchStrategy = pgEnum("dispatch_strategy", [ - "coverage", - "uncertainty", -]); - -export const judgeStatus = pgEnum("judge_status", [ - "invited", - "applied", - "approved", - "suspended", -]); - -export const comparisonOutcome = pgEnum("comparison_outcome", ["a", "b", "tie"]); - -export const membershipRole = pgEnum("membership_role", [ - "owner", - "admin", - "organizer", - "volunteer", -]); diff --git a/packages/judging-db/src/env.ts b/packages/judging-db/src/env.ts deleted file mode 100644 index 8966aa59..00000000 --- a/packages/judging-db/src/env.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function databaseUrl(): string | null { - return process.env.DATABASE_URL ?? process.env.PANEL_DATABASE_URL ?? null; -} - -export function requireDatabaseUrl(): string { - const url = databaseUrl(); - if (!url) { - throw new Error("Set DATABASE_URL to the Postgres database"); - } - return url; -} diff --git a/packages/judging-db/src/index.ts b/packages/judging-db/src/index.ts deleted file mode 100644 index 93619fb3..00000000 --- a/packages/judging-db/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { createDb, createDbFromPool } from "./client"; -export type { PanelDb, PanelTx } from "./client"; -export { databaseUrl, requireDatabaseUrl } from "./env"; -export { appliedMigrations, migrate, migrationFiles } from "./migrate"; -export * from "./schema"; -export { seedDemo } from "./seed"; diff --git a/packages/judging-db/src/migrate.ts b/packages/judging-db/src/migrate.ts deleted file mode 100644 index 9a5e8c76..00000000 --- a/packages/judging-db/src/migrate.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { readdir, readFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import type { Pool } from "pg"; - -const migrationsDir = join(dirname(fileURLToPath(import.meta.url)), "../migrations"); - -const MIGRATION_TABLE = ` - create table if not exists panel_migration ( - id text primary key, - applied_at timestamptz not null default now() - ) -`; - -/** Applies committed SQL files in order. Each file runs in one transaction. */ -export async function migrate(pool: Pool): Promise { - const client = await pool.connect(); - try { - await client.query(MIGRATION_TABLE); - const applied = await client.query<{ id: string }>( - "select id from panel_migration", - ); - const done = new Set(applied.rows.map((row) => row.id)); - const files = await migrationFiles(); - const fresh: string[] = []; - - for (const file of files) { - if (done.has(file)) continue; - const body = await readFile(join(migrationsDir, file), "utf8"); - try { - await client.query("begin"); - await client.query(body); - await client.query("insert into panel_migration (id) values ($1)", [ - file, - ]); - await client.query("commit"); - } catch (error) { - await client.query("rollback"); - throw error; - } - fresh.push(file); - } - return fresh; - } finally { - client.release(); - } -} - -export async function appliedMigrations(pool: Pool): Promise { - const client = await pool.connect(); - try { - const exists = await client.query<{ present: boolean }>( - `select to_regclass('panel_migration') is not null as present`, - ); - const row = exists.rows[0]; - if (!row?.present) return []; - const applied = await client.query<{ id: string }>( - "select id from panel_migration order by id", - ); - return applied.rows.map((item) => item.id); - } finally { - client.release(); - } -} - -export async function migrationFiles(): Promise { - const files = await readdir(migrationsDir); - return files.filter((name) => name.endsWith(".sql")).sort(); -} diff --git a/packages/judging-db/src/schema.ts b/packages/judging-db/src/schema.ts deleted file mode 100644 index 365d76a0..00000000 --- a/packages/judging-db/src/schema.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { - boolean, - integer, - jsonb, - numeric, - pgTable, - primaryKey, - text, - timestamp, - uniqueIndex, - uuid, -} from "drizzle-orm/pg-core"; -import { sql } from "drizzle-orm"; -import { - comparisonOutcome, - dispatchStrategy, - eventPhase, - judgeStatus, - membershipRole, - trackKind, -} from "./enums"; - -export { - comparisonOutcome, - dispatchStrategy, - eventPhase, - judgeStatus, - membershipRole, - trackKind, -}; - -export const organization = pgTable("organization", { - id: uuid("id").defaultRandom().primaryKey(), - slug: text("slug").notNull().unique(), - name: text("name").notNull(), - branding: jsonb("branding").notNull().default({}), -}); - -export const user = pgTable("panel_user", { - id: uuid("id").defaultRandom().primaryKey(), - email: text("email").notNull().unique(), - name: text("name").notNull(), -}); - -export const event = pgTable( - "panel_event", - { - id: uuid("id").defaultRandom().primaryKey(), - orgId: uuid("org_id") - .notNull() - .references(() => organization.id, { onDelete: "cascade" }), - slug: text("slug").notNull(), - name: text("name").notNull(), - startsAt: timestamp("starts_at", { withTimezone: true }), - endsAt: timestamp("ends_at", { withTimezone: true }), - phase: eventPhase("phase").notNull().default("setup"), - }, - (table) => [uniqueIndex("event_org_slug_idx").on(table.orgId, table.slug)], -); - -export const eventConfig = pgTable("event_config", { - eventId: uuid("event_id") - .primaryKey() - .references(() => event.id, { onDelete: "cascade" }), - targetSeconds: integer("target_seconds").notNull(), - hardLimitSeconds: integer("hard_limit_seconds").notNull(), - walkLimitSeconds: integer("walk_limit_seconds").notNull(), - submitGraceSeconds: integer("submit_grace_seconds").notNull(), - minLooksPerProject: integer("min_looks_per_project").notNull(), - dispatchStrategy: dispatchStrategy("dispatch_strategy").notNull(), - pairwiseEnabled: boolean("pairwise_enabled").notNull().default(false), - pairwiseWeight: numeric("pairwise_weight", { mode: "number" }).notNull().default(0), - bayesianC: numeric("bayesian_c", { mode: "number" }).notNull().default(2), - calibrationLooks: integer("calibration_looks").notNull().default(0), - calibrationWeight: numeric("calibration_weight", { mode: "number" }) - .notNull() - .default(1), - feedbackCardsEnabled: boolean("feedback_cards_enabled").notNull().default(false), - leaderboardPublic: boolean("leaderboard_public").notNull().default(false), - boardPollSeconds: integer("board_poll_seconds").notNull().default(5), -}); - -export const rubric = pgTable("rubric", { - id: uuid("id").defaultRandom().primaryKey(), - eventId: uuid("event_id") - .notNull() - .references(() => event.id, { onDelete: "cascade" }), - name: text("name").notNull(), - isDefault: boolean("is_default").notNull().default(false), -}); - -export const criterion = pgTable( - "criterion", - { - id: uuid("id").defaultRandom().primaryKey(), - rubricId: uuid("rubric_id") - .notNull() - .references(() => rubric.id, { onDelete: "cascade" }), - position: integer("position").notNull(), - key: text("key").notNull(), - label: text("label").notNull(), - description: text("description"), - min: numeric("min", { mode: "number" }).notNull(), - max: numeric("max", { mode: "number" }).notNull(), - weight: numeric("weight", { mode: "number" }).notNull(), - anchors: jsonb("anchors").notNull().default([]), - }, - (table) => [uniqueIndex("criterion_rubric_key_idx").on(table.rubricId, table.key)], -); - -export const track = pgTable( - "track", - { - id: uuid("id").defaultRandom().primaryKey(), - eventId: uuid("event_id") - .notNull() - .references(() => event.id, { onDelete: "cascade" }), - slug: text("slug").notNull(), - name: text("name").notNull(), - kind: trackKind("kind").notNull(), - judgeGroup: text("judge_group").notNull(), - rubricId: uuid("rubric_id").references(() => rubric.id), - position: integer("position").notNull().default(0), - isActive: boolean("is_active").notNull().default(true), - }, - (table) => [uniqueIndex("track_event_slug_idx").on(table.eventId, table.slug)], -); - -export const prize = pgTable( - "prize", - { - id: uuid("id").defaultRandom().primaryKey(), - trackId: uuid("track_id") - .notNull() - .references(() => track.id, { onDelete: "cascade" }), - place: integer("place").notNull(), - title: text("title").notNull(), - amount: numeric("amount", { mode: "number" }), - description: text("description"), - }, - (table) => [uniqueIndex("prize_track_place_idx").on(table.trackId, table.place)], -); - -export const zone = pgTable("zone", { - id: uuid("id").defaultRandom().primaryKey(), - eventId: uuid("event_id") - .notNull() - .references(() => event.id, { onDelete: "cascade" }), - name: text("name").notNull(), - position: integer("position").notNull().default(0), -}); - -/** Floor plan. The SQL name is floor_table because `table` is reserved. */ -export const floorTable = pgTable( - "floor_table", - { - eventId: uuid("event_id") - .notNull() - .references(() => event.id, { onDelete: "cascade" }), - number: integer("number").notNull(), - zoneId: uuid("zone_id").references(() => zone.id), - x: numeric("x", { mode: "number" }), - y: numeric("y", { mode: "number" }), - }, - (table) => [primaryKey({ columns: [table.eventId, table.number] })], -); - -export const project = pgTable("project", { - id: uuid("id").defaultRandom().primaryKey(), - eventId: uuid("event_id") - .notNull() - .references(() => event.id, { onDelete: "cascade" }), - externalId: text("external_id"), - name: text("name").notNull(), - description: text("description"), - teamName: text("team_name"), - members: jsonb("members").notNull().default([]), - links: jsonb("links").notNull().default({}), - tableNumber: integer("table_number"), - zoneId: uuid("zone_id").references(() => zone.id), - qrToken: uuid("qr_token").notNull().unique().defaultRandom(), - feedbackToken: uuid("feedback_token").unique(), - arrivedFirstAt: timestamp("arrived_first_at", { withTimezone: true }), - withdrawnAt: timestamp("withdrawn_at", { withTimezone: true }), -}); - -export const projectTrack = pgTable( - "project_track", - { - projectId: uuid("project_id") - .notNull() - .references(() => project.id, { onDelete: "cascade" }), - trackId: uuid("track_id") - .notNull() - .references(() => track.id, { onDelete: "cascade" }), - }, - (table) => [primaryKey({ columns: [table.projectId, table.trackId] })], -); - -export const judge = pgTable( - "panel_judge", - { - id: uuid("id").defaultRandom().primaryKey(), - eventId: uuid("event_id") - .notNull() - .references(() => event.id, { onDelete: "cascade" }), - externalId: text("external_id"), - name: text("name").notNull(), - email: text("email").notNull(), - org: text("org"), - title: text("title"), - status: judgeStatus("status").notNull().default("invited"), - trackId: uuid("track_id").references(() => track.id), - zoneId: uuid("zone_id").references(() => zone.id), - isLead: boolean("is_lead").notNull().default(false), - }, - (table) => [uniqueIndex("judge_event_email_idx").on(table.eventId, table.email)], -); - -export const visit = pgTable( - "visit", - { - id: uuid("id").defaultRandom().primaryKey(), - eventId: uuid("event_id") - .notNull() - .references(() => event.id, { onDelete: "cascade" }), - judgeId: uuid("judge_id") - .notNull() - .references(() => judge.id, { onDelete: "cascade" }), - projectId: uuid("project_id") - .notNull() - .references(() => project.id, { onDelete: "cascade" }), - judgeGroup: text("judge_group").notNull(), - handedOutAt: timestamp("handed_out_at", { withTimezone: true }).notNull(), - arrivedAt: timestamp("arrived_at", { withTimezone: true }), - completedAt: timestamp("completed_at", { withTimezone: true }), - voidedAt: timestamp("voided_at", { withTimezone: true }), - voidReason: text("void_reason"), - }, - (table) => [ - uniqueIndex("visit_judge_project_open_idx") - .on(table.judgeId, table.projectId) - .where(sql`voided_at is null`), - uniqueIndex("visit_one_open_idx") - .on(table.judgeId) - .where(sql`voided_at is null and completed_at is null`), - ], -); - -export const vote = pgTable("vote", { - id: uuid("id").defaultRandom().primaryKey(), - visitId: uuid("visit_id") - .notNull() - .unique() - .references(() => visit.id, { onDelete: "cascade" }), - judgeId: uuid("judge_id") - .notNull() - .references(() => judge.id, { onDelete: "cascade" }), - projectId: uuid("project_id") - .notNull() - .references(() => project.id, { onDelete: "cascade" }), - eventId: uuid("event_id") - .notNull() - .references(() => event.id, { onDelete: "cascade" }), - total: numeric("total", { mode: "number" }).notNull(), - comment: text("comment"), - durationSeconds: integer("duration_seconds"), - isCalibration: boolean("is_calibration").notNull().default(false), -}); - -export const voteScore = pgTable( - "vote_score", - { - voteId: uuid("vote_id") - .notNull() - .references(() => vote.id, { onDelete: "cascade" }), - criterionId: uuid("criterion_id") - .notNull() - .references(() => criterion.id), - value: numeric("value", { mode: "number" }).notNull(), - }, - (table) => [primaryKey({ columns: [table.voteId, table.criterionId] })], -); - -export const comparison = pgTable("comparison", { - id: uuid("id").defaultRandom().primaryKey(), - eventId: uuid("event_id") - .notNull() - .references(() => event.id, { onDelete: "cascade" }), - judgeId: uuid("judge_id") - .notNull() - .references(() => judge.id, { onDelete: "cascade" }), - judgeGroup: text("judge_group").notNull(), - aProjectId: uuid("a_project_id") - .notNull() - .references(() => project.id), - bProjectId: uuid("b_project_id") - .notNull() - .references(() => project.id), - outcome: comparisonOutcome("outcome").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), -}); - -export const resultRun = pgTable("result_run", { - id: uuid("id").defaultRandom().primaryKey(), - eventId: uuid("event_id") - .notNull() - .references(() => event.id, { onDelete: "cascade" }), - computedAt: timestamp("computed_at", { withTimezone: true }).notNull().defaultNow(), - computedBy: uuid("computed_by").references(() => user.id), - configSnapshot: jsonb("config_snapshot").notNull(), - publishedAt: timestamp("published_at", { withTimezone: true }), - notes: text("notes"), -}); - -export const result = pgTable( - "result", - { - runId: uuid("run_id") - .notNull() - .references(() => resultRun.id, { onDelete: "cascade" }), - projectId: uuid("project_id") - .notNull() - .references(() => project.id, { onDelete: "cascade" }), - trackId: uuid("track_id") - .notNull() - .references(() => track.id, { onDelete: "cascade" }), - placement: integer("placement"), - score: numeric("score", { mode: "number" }).notNull(), - rubricComponent: numeric("rubric_component", { mode: "number" }).notNull(), - pairwiseComponent: numeric("pairwise_component", { mode: "number" }).notNull(), - voteCount: integer("vote_count").notNull(), - comparisonCount: integer("comparison_count").notNull(), - flags: text("flags").array().notNull().default([]), - }, - (table) => [ - primaryKey({ columns: [table.runId, table.projectId, table.trackId] }), - ], -); - -export const eventLog = pgTable("event_log", { - id: uuid("id").defaultRandom().primaryKey(), - eventId: uuid("event_id") - .notNull() - .references(() => event.id, { onDelete: "cascade" }), - kind: text("kind").notNull(), - actor: jsonb("actor").notNull().default({}), - subject: jsonb("subject").notNull().default({}), - payload: jsonb("payload").notNull().default({}), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), -}); - -export const outbox = pgTable("outbox", { - id: uuid("id").defaultRandom().primaryKey(), - eventId: uuid("event_id") - .notNull() - .references(() => event.id, { onDelete: "cascade" }), - topic: text("topic").notNull(), - payload: jsonb("payload").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - deliveredAt: timestamp("delivered_at", { withTimezone: true }), - attempts: integer("attempts").notNull().default(0), -}); - -export const webhook = pgTable("webhook", { - id: uuid("id").defaultRandom().primaryKey(), - orgId: uuid("org_id") - .notNull() - .references(() => organization.id, { onDelete: "cascade" }), - url: text("url").notNull(), - secret: text("secret").notNull(), - topics: text("topics").array().notNull(), - isActive: boolean("is_active").notNull().default(true), -}); - -export const apiKey = pgTable("api_key", { - id: uuid("id").defaultRandom().primaryKey(), - orgId: uuid("org_id") - .notNull() - .references(() => organization.id, { onDelete: "cascade" }), - hashedKey: text("hashed_key").notNull(), - scopes: text("scopes").array().notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - revokedAt: timestamp("revoked_at", { withTimezone: true }), -}); - -export const membership = pgTable( - "membership", - { - userId: uuid("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - orgId: uuid("org_id") - .notNull() - .references(() => organization.id, { onDelete: "cascade" }), - role: membershipRole("role").notNull(), - }, - (table) => [primaryKey({ columns: [table.userId, table.orgId] })], -); - -export const judgeIdentity = pgTable("judge_identity", { - judgeId: uuid("judge_id") - .primaryKey() - .references(() => judge.id, { onDelete: "cascade" }), - userId: uuid("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), -}); - -export const loginCode = pgTable("login_code", { - id: uuid("id").defaultRandom().primaryKey(), - email: text("email").notNull(), - codeHash: text("code_hash").notNull(), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - consumedAt: timestamp("consumed_at", { withTimezone: true }), -}); diff --git a/packages/judging-db/src/seed.ts b/packages/judging-db/src/seed.ts deleted file mode 100644 index 9a11405a..00000000 --- a/packages/judging-db/src/seed.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { eq, sql } from "drizzle-orm"; -import { DEFAULT_TIMERS } from "@query/judging-core"; -import type { PanelDb, PanelTx } from "./client"; -import { - criterion, - event, - eventConfig, - floorTable, - judge, - membership, - organization, - prize, - project, - projectTrack, - rubric, - track, - user, - zone, -} from "./schema"; - -const CRITERIA = [ - ["creativity", "Creativity"], - ["impact", "Impact"], - ["scope", "Scope"], - ["clarity", "Clarity"], - ["soundness", "Soundness"], -] as const; - -/** - * One event a self-hoster can click through: 40 projects, 6 judges, three - * tracks, and a second rubric on the sponsor track. Re-running replaces the - * demo organization. - */ -export async function seedDemo(db: PanelDb): Promise<{ eventId: string }> { - return db.transaction(async (tx) => { - await tx.execute(sql`select pg_advisory_xact_lock(hashtext('panel:seed:demo'))`); - return insertDemo(tx); - }); -} - -async function insertDemo(db: PanelTx): Promise<{ eventId: string }> { - const existing = await db - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.slug, "demo")); - const previous = existing[0]; - if (previous) { - await db.delete(organization).where(eq(organization.id, previous.id)); - } - - const [org] = await db - .insert(organization) - .values({ - slug: "demo", - name: "Demo", - branding: { tagline: "A practice floor" }, - }) - .returning({ id: organization.id }); - if (!org) throw new Error("demo organization was not created"); - - await db.delete(user).where(eq(user.email, "organizer@demo.panel")); - const [organizer] = await db - .insert(user) - .values({ email: "organizer@demo.panel", name: "Demo organizer" }) - .returning({ id: user.id }); - if (!organizer) throw new Error("demo organizer was not created"); - - await db.insert(membership).values({ - userId: organizer.id, - orgId: org.id, - role: "owner", - }); - - const [demoEvent] = await db - .insert(event) - .values({ - orgId: org.id, - slug: "demo", - name: "Demo", - phase: "judging_live", - }) - .returning({ id: event.id }); - if (!demoEvent) throw new Error("demo event was not created"); - - await db.insert(eventConfig).values({ - eventId: demoEvent.id, - targetSeconds: DEFAULT_TIMERS.targetSeconds, - hardLimitSeconds: DEFAULT_TIMERS.hardLimitSeconds, - walkLimitSeconds: DEFAULT_TIMERS.walkLimitSeconds, - submitGraceSeconds: DEFAULT_TIMERS.submitGraceSeconds, - minLooksPerProject: 1, - dispatchStrategy: "coverage", - pairwiseEnabled: false, - pairwiseWeight: 0, - bayesianC: 2, - calibrationLooks: 0, - calibrationWeight: 1, - feedbackCardsEnabled: false, - leaderboardPublic: false, - boardPollSeconds: 5, - }); - - const [mainRubric] = await db - .insert(rubric) - .values({ eventId: demoEvent.id, name: "Main", isDefault: true }) - .returning({ id: rubric.id }); - const [sponsorRubric] = await db - .insert(rubric) - .values({ eventId: demoEvent.id, name: "Sponsor", isDefault: false }) - .returning({ id: rubric.id }); - if (!mainRubric || !sponsorRubric) throw new Error("demo rubrics were not created"); - - await db.insert(criterion).values( - CRITERIA.map(([key, label], position) => ({ - rubricId: mainRubric.id, - position, - key, - label, - min: 0, - max: 10, - weight: 1, - })), - ); - await db.insert(criterion).values([ - { - rubricId: sponsorRubric.id, - position: 0, - key: "fit", - label: "Fit", - min: 0, - max: 10, - weight: 1, - }, - { - rubricId: sponsorRubric.id, - position: 1, - key: "execution", - label: "Execution", - min: 0, - max: 10, - weight: 1, - }, - ]); - - const tracks = await db - .insert(track) - .values([ - { - eventId: demoEvent.id, - slug: "general", - name: "General", - kind: "main" as const, - judgeGroup: "main", - rubricId: mainRubric.id, - position: 0, - }, - { - eventId: demoEvent.id, - slug: "sponsor", - name: "Sponsor", - kind: "sponsor" as const, - judgeGroup: "sponsor", - rubricId: sponsorRubric.id, - position: 1, - }, - { - eventId: demoEvent.id, - slug: "beginner", - name: "Beginner", - kind: "special" as const, - judgeGroup: "beginner", - rubricId: mainRubric.id, - position: 2, - }, - ]) - .returning({ id: track.id, slug: track.slug }); - - const general = tracks.find((item) => item.slug === "general"); - const sponsor = tracks.find((item) => item.slug === "sponsor"); - const beginner = tracks.find((item) => item.slug === "beginner"); - if (!general || !sponsor || !beginner) throw new Error("demo tracks were not created"); - - await db.insert(prize).values([ - { trackId: general.id, place: 1, title: "First", amount: 1000 }, - { trackId: sponsor.id, place: 1, title: "Sponsor prize", amount: 500 }, - ]); - - const [hall] = await db - .insert(zone) - .values({ eventId: demoEvent.id, name: "Hall", position: 0 }) - .returning({ id: zone.id }); - if (!hall) throw new Error("demo zone was not created"); - - await db.insert(floorTable).values( - Array.from({ length: 40 }, (_, index) => ({ - eventId: demoEvent.id, - number: index + 1, - zoneId: hall.id, - })), - ); - - const projects = await db - .insert(project) - .values( - Array.from({ length: 40 }, (_, index) => ({ - eventId: demoEvent.id, - name: `Project ${index + 1}`, - teamName: `Team ${index + 1}`, - tableNumber: index + 1, - zoneId: hall.id, - })), - ) - .returning({ id: project.id, tableNumber: project.tableNumber }); - - await db.insert(projectTrack).values( - projects.flatMap((item) => { - const links = [{ projectId: item.id, trackId: general.id }]; - const number = item.tableNumber ?? 0; - if (number % 5 === 0) links.push({ projectId: item.id, trackId: sponsor.id }); - if (number % 4 === 0) links.push({ projectId: item.id, trackId: beginner.id }); - return links; - }), - ); - - const sponsorTrack = tracks.find((item) => item.slug === "sponsor"); - await db.insert(judge).values( - Array.from({ length: 6 }, (_, index) => ({ - eventId: demoEvent.id, - name: `Judge ${index + 1}`, - email: `judge${index + 1}@demo.panel`, - status: "approved" as const, - trackId: index === 5 ? sponsorTrack?.id : null, - zoneId: hall.id, - })), - ); - - return { eventId: demoEvent.id }; -} diff --git a/packages/judging-db/tsconfig.json b/packages/judging-db/tsconfig.json deleted file mode 100644 index f54bde2c..00000000 --- a/packages/judging-db/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tooling/typescript/base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "types": ["node"] - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/judging-server/eslint.config.mjs b/packages/judging-server/eslint.config.mjs deleted file mode 100644 index 2d326cc0..00000000 --- a/packages/judging-server/eslint.config.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import { config } from "@query/eslint-config/base"; - -/** @type {import("eslint").Linter.Config[]} */ -export default [ - ...config, - { - files: ["src/log.ts", "src/index.ts"], - rules: { "no-console": "off" }, - }, -]; diff --git a/packages/judging-server/package.json b/packages/judging-server/package.json deleted file mode 100644 index da7cabd3..00000000 --- a/packages/judging-server/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@query/judging-server", - "version": "0.0.0", - "private": true, - "type": "module", - "exports": { - ".": "./src/index.ts", - "./outbox": "./src/services/outbox.ts" - }, - "scripts": { - "lint": "eslint . --max-warnings 0", - "typecheck": "tsc --noEmit", - "test": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.ts" - }, - "dependencies": { - "@query/judging-core": "workspace:*", - "@query/judging-db": "workspace:*", - "@trpc/server": "11.19.0", - "drizzle-orm": "0.45.3", - "hono": "^4.9.0", - "ioredis": "^5.8.2", - "jose": "^6.1.0", - "nodemailer": "^10.0.10", - "prom-client": "15.1.3", - "zod": "4.6.5" - }, - "devDependencies": { - "@query/eslint-config": "workspace:*", - "@query/tsconfig": "workspace:*", - "@types/node": "22.20.4", - "typescript": "7.0.2", - "vitest": "^5.0.1" - } -} diff --git a/packages/judging-server/src/app.test.ts b/packages/judging-server/src/app.test.ts deleted file mode 100644 index d31f2ae7..00000000 --- a/packages/judging-server/src/app.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createApp } from "./app"; -import { createMetrics } from "./metrics"; - -describe("health", () => { - it("answers /healthz without a database", async () => { - const app = createApp({ - db: {} as never, - metrics: createMetrics(), - jwtSecret: "test", - devAuth: false, - busMode: "memory", - }); - const response = await app.request("/healthz"); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ ok: true }); - }); -}); - -describe("session actor", () => { - it("asks resolveActor when there is no bearer token", async () => { - const seen: string[] = []; - const app = createApp({ - db: {} as never, - metrics: createMetrics(), - jwtSecret: "test", - devAuth: false, - busMode: "memory", - resolveActor: async (request) => { - seen.push(new URL(request.url).pathname); - return null; - }, - }); - const response = await app.request( - "/v1/session/progress?eventId=00000000-0000-4000-8000-000000000000", - ); - expect(response.status).toBe(401); - expect(seen).toEqual(["/v1/session/progress"]); - }); -}); diff --git a/packages/judging-server/src/app.ts b/packages/judging-server/src/app.ts deleted file mode 100644 index b72251ca..00000000 --- a/packages/judging-server/src/app.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { Hono } from "hono"; -import { cors } from "hono/cors"; -import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; -import { TRPCError } from "@trpc/server"; -import { and, eq, isNull, sql } from "drizzle-orm"; -import { z } from "zod"; -import type { PanelDb } from "@query/judging-db"; -import { event, loginCode, membership, organization, user, visit } from "@query/judging-db"; -import { hashCode, newLoginCode, roleAtLeast, signActor, verifyActor } from "./auth"; -import { sendLoginCode } from "./mail"; -import type { Actor, Role } from "./auth"; -import type { Bus } from "./bus"; -import { InMemoryBus } from "./bus"; -import { feedbackCard, feedbackTokenForExternal, actorForApiKey } from "./services/catalog"; -import { organizerFloor } from "./services/floor"; -import { liveSnapshot } from "./services/live"; -import { inspectRun, publishedPlacements } from "./services/results"; -import type { Metrics } from "./metrics"; -import { appRouter } from "./router"; -import type { Context } from "./router"; - -const openApi = { - openapi: "3.0.3", - info: { - title: "Panel", - version: "0.0.0", - description: - "REST mirror of the tRPC procedures. Send Authorization: Bearer with a JWT or an API key, except for the public routes.", - }, - paths: { - "/healthz": { get: { summary: "Process is up." } }, - "/readyz": { get: { summary: "Postgres answers. The bus is this process unless Redis is set." } }, - "/metrics": { get: { summary: "Prometheus metrics." } }, - "/openapi.json": { get: { summary: "This document." } }, - "/v1/auth/magic-link": { post: { summary: "Email a sign-in code. Dev auth returns the code." } }, - "/v1/auth/verify": { post: { summary: "Exchange a code for a JWT." } }, - "/v1/judges/apply": { post: { summary: "Apply to judge this event with the signed-in email." } }, - "/v1/public/{orgSlug}/{eventSlug}": { get: { summary: "Event name, phase, and branding." } }, - "/v1/session/next": { post: { summary: "Hand the signed-in judge their next table." } }, - "/v1/session/arrive": { post: { summary: "Mark arrival by table number or QR token." } }, - "/v1/session/vote": { post: { summary: "Store the score for the open visit." } }, - "/v1/session/skip": { post: { summary: "Pass on the open table without a score." } }, - "/v1/session/progress": { get: { summary: "How many projects this judge has scored." } }, - "/v1/session/status": { get: { summary: "Whether the open visit was voided or the judge recalled." } }, - "/v1/session/compare": { post: { summary: "Record which of the last two tables was better." } }, - "/v1/session/rubric": { get: { summary: "Criteria for the event's default rubric." } }, - "/v1/catalog/projects": { get: { summary: "Projects for table cards. Organizer." } }, - "/v1/catalog/logs": { get: { summary: "Recent event log rows. Organizer." } }, - "/v1/catalog/tables": { get: { summary: "Floor tables and coordinates. Organizer." } }, - "/v1/catalog/judges": { get: { summary: "Judges and their status. Organizer." } }, - "/v1/live/{orgSlug}/{eventSlug}": { get: { summary: "Board snapshot: coverage, or placements after publish." } }, - "/v1/floor/{eventId}": { get: { summary: "Organizer floor: looks per table, and idle, walking, or overtime judges." } }, - "/v1/results/run/{runId}": { get: { summary: "One run, with rubric and pairwise components. Organizer." } }, - "/v1/results/{eventId}": { get: { summary: "Published placements. 403 before publish." } }, - "/v1/feedback/{token}": { get: { summary: "A team's card. 403 for an unknown token or before publish." } }, - }, -}; - -export function createApp(options: { - db: PanelDb; - metrics: Metrics; - jwtSecret: string; - jwksUrl?: string; - devAuth: boolean; - smtpUrl?: string; - busMode: "memory" | "redis"; - bus?: Bus; - /** Who is signed in when the request carries no bearer token, e.g. a portal session cookie. */ - resolveActor?: (request: Request) => Promise; -}) { - const bus = options.bus ?? new InMemoryBus(); - const app = new Hono(); - app.use( - "*", - cors({ - origin: "*", - allowHeaders: ["Authorization", "Content-Type"], - allowMethods: ["GET", "POST", "OPTIONS"], - }), - ); - - app.get("/healthz", (c) => c.json({ ok: true })); - - app.get("/readyz", async (c) => { - try { - await options.db.execute(sql`select 1`); - return c.json({ ok: true, bus: options.busMode }); - } catch (error) { - const message = error instanceof Error ? error.message : "database"; - return c.json({ ok: false, message }, 503); - } - }); - - app.get("/metrics", async (c) => { - const [open] = await options.db - .select({ n: sql`count(distinct ${visit.judgeId})::int` }) - .from(visit) - .where(and(isNull(visit.completedAt), isNull(visit.voidedAt))); - options.metrics.liveJudges.set(open?.n ?? 0); - c.header("content-type", options.metrics.register.contentType); - return c.body(await options.metrics.register.metrics()); - }); - - app.get("/openapi.json", (c) => c.json(openApi)); - - app.post("/v1/auth/magic-link", async (c) => { - const body = z.object({ email: z.string().email() }).parse(await c.req.json()); - if (!options.devAuth && !options.smtpUrl) { - return c.json({ ok: false, message: "SMTP is not configured" }, 503); - } - const code = newLoginCode(); - const email = body.email.toLowerCase(); - await options.db.insert(loginCode).values({ - email, - codeHash: hashCode(code), - expiresAt: new Date(Date.now() + 10 * 60 * 1000), - }); - if (options.smtpUrl) { - await sendLoginCode(options.smtpUrl, email, code); - return c.json({ ok: true }); - } - return c.json({ ok: true, code }); - }); - - app.post("/v1/auth/verify", async (c) => { - const body = z - .object({ - email: z.string().email(), - code: z.string().length(6), - org: z.string().optional(), - }) - .parse(await c.req.json()); - const email = body.email.toLowerCase(); - const [row] = await options.db - .select() - .from(loginCode) - .where( - and( - eq(loginCode.email, email), - eq(loginCode.codeHash, hashCode(body.code)), - isNull(loginCode.consumedAt), - ), - ); - if (!row || row.expiresAt.getTime() < Date.now()) { - return c.json({ ok: false, message: "Code is not valid" }, 401); - } - await options.db - .update(loginCode) - .set({ consumedAt: new Date() }) - .where(eq(loginCode.id, row.id)); - - const [person] = await options.db - .select() - .from(user) - .where(eq(user.email, email)); - let role: Role | null = null; - let orgSlug: string | null = body.org ?? null; - if (person) { - const rows = await options.db - .select({ role: membership.role, slug: organization.slug }) - .from(membership) - .innerJoin(organization, eq(organization.id, membership.orgId)) - .where(eq(membership.userId, person.id)); - const chosen = orgSlug - ? rows.find((item) => item.slug === orgSlug) - : rows.length === 1 - ? rows[0] - : undefined; - if (chosen) { - role = chosen.role; - orgSlug = chosen.slug; - } - } - - if (!options.jwtSecret) { - return c.json({ ok: false, message: "PANEL_JWT_SECRET is not set" }, 503); - } - const token = await signActor( - { - sub: person?.id ?? email, - email, - name: person?.name ?? email, - org: orgSlug, - role, - judgeExternalId: null, - }, - options.jwtSecret, - 60 * 60 * 12, - ); - return c.json({ ok: true, token }); - }); - - app.post("/v1/judges/apply", async (c) => { - const body = z - .object({ eventId: z.string().uuid(), name: z.string().min(1) }) - .parse(await c.req.json()); - return rest((caller) => caller.judge.applyToEvent(body))(c); - }); - - app.get("/v1/public/:orgSlug/:eventSlug", async (c) => { - const [row] = await options.db - .select({ - eventId: event.id, - name: event.name, - phase: event.phase, - branding: organization.branding, - orgName: organization.name, - }) - .from(event) - .innerJoin(organization, eq(organization.id, event.orgId)) - .where( - and( - eq(organization.slug, c.req.param("orgSlug")), - eq(event.slug, c.req.param("eventSlug")), - ), - ); - if (!row) return c.json({ ok: false }, 404); - return c.json(row); - }); - - const contextFor = async (c: { - req: { header: (name: string) => string | undefined; raw: Request }; - }): Promise => { - const header = c.req.header("authorization"); - let actor: Context["actor"] = null; - if (header?.startsWith("Bearer ")) { - const token = header.slice("Bearer ".length); - try { - actor = await verifyActor(token, { - secret: options.jwtSecret, - jwksUrl: options.jwksUrl, - }); - } catch { - actor = await actorForApiKey(options.db, token); - } - } else if (options.resolveActor) { - actor = await options.resolveActor(c.req.raw); - } - return { db: options.db, actor, now: new Date(), bus, metrics: options.metrics }; - }; - - const rest = (run: (caller: ReturnType) => Promise) => { - return async (c: Parameters[0] & { json: (body: unknown, status?: number) => Response }) => { - try { - return c.json(await run(appRouter.createCaller(await contextFor(c)))); - } catch (error) { - const message = error instanceof TRPCError ? error.message : "Request failed"; - const status = error instanceof TRPCError && error.code === "UNAUTHORIZED" ? 401 : 400; - return c.json({ message }, status); - } - }; - }; - - app.post("/v1/session/next", async (c) => { - const body = z.object({ eventId: z.string().uuid() }).parse(await c.req.json()); - return rest((caller) => caller.session.next(body))(c); - }); - app.post("/v1/session/arrive", async (c) => { - const body = z - .object({ - eventId: z.string().uuid(), - qrToken: z.string().uuid().optional(), - tableNumber: z.number().int().optional(), - }) - .parse(await c.req.json()); - return rest((caller) => caller.session.arrive(body))(c); - }); - app.post("/v1/session/vote", async (c) => { - const body = z - .object({ - eventId: z.string().uuid(), - visitId: z.string().uuid(), - comment: z.string().nullable().optional(), - scores: z.array(z.object({ criterionId: z.string().uuid(), value: z.number() })), - }) - .parse(await c.req.json()); - return rest((caller) => caller.session.vote({ ...body, comment: body.comment ?? null }))(c); - }); - app.get("/v1/session/rubric", async (c) => { - const eventId = z.string().uuid().parse(c.req.query("eventId")); - return rest((caller) => caller.session.rubric({ eventId }))(c); - }); - app.post("/v1/session/compare", async (c) => { - const body = z - .object({ eventId: z.string().uuid(), outcome: z.enum(["a", "b", "tie"]) }) - .parse(await c.req.json()); - return rest((caller) => caller.session.compare(body))(c); - }); - app.post("/v1/session/skip", async (c) => { - const body = z - .object({ eventId: z.string().uuid(), visitId: z.string().uuid() }) - .parse(await c.req.json()); - return rest((caller) => caller.session.skip(body))(c); - }); - app.get("/v1/session/status", async (c) => { - const eventId = z.string().uuid().parse(c.req.query("eventId")); - const visitId = z.string().uuid().parse(c.req.query("visitId")); - return rest((caller) => caller.session.status({ eventId, visitId }))(c); - }); - app.get("/v1/session/progress", async (c) => { - const eventId = z.string().uuid().parse(c.req.query("eventId")); - return rest((caller) => caller.session.progress({ eventId }))(c); - }); - - app.get("/v1/catalog/projects", async (c) => { - const eventId = z.string().uuid().parse(c.req.query("eventId")); - return rest((caller) => caller.catalog.projects({ eventId }))(c); - }); - app.get("/v1/catalog/logs", async (c) => { - const eventId = z.string().uuid().parse(c.req.query("eventId")); - return rest((caller) => caller.catalog.logs({ eventId }))(c); - }); - app.get("/v1/catalog/tables", async (c) => { - const eventId = z.string().uuid().parse(c.req.query("eventId")); - return rest((caller) => caller.catalog.tables({ eventId }))(c); - }); - app.get("/v1/catalog/judges", async (c) => { - const eventId = z.string().uuid().parse(c.req.query("eventId")); - return rest((caller) => caller.catalog.judges({ eventId }))(c); - }); - - app.get("/v1/floor/:eventId", async (c) => { - const ctx = await contextFor(c); - if (!ctx.actor || !roleAtLeast(ctx.actor.role, "organizer")) { - return c.json({ message: "Unauthorized" }, 401); - } - const eventId = z.string().uuid().parse(c.req.param("eventId")); - const floor = await organizerFloor(options.db, eventId, ctx.now); - if (!floor) return c.json({ message: "Not found" }, 404); - return c.json(floor); - }); - - app.get("/v1/live/:orgSlug/:eventSlug", async (c) => { - const snapshot = await liveSnapshot( - options.db, - c.req.param("orgSlug"), - c.req.param("eventSlug"), - ); - if (!snapshot) return c.json({ ok: false }, 404); - return c.json(snapshot); - }); - - app.get("/v1/results/run/:runId", async (c) => { - if (!(await contextFor(c)).actor) return c.json({ message: "Unauthorized" }, 401); - const runId = z.string().uuid().parse(c.req.param("runId")); - return c.json(await inspectRun(options.db, runId)); - }); - - app.get("/v1/results/:eventId", async (c) => { - if (!(await contextFor(c)).actor) return c.json({ message: "Unauthorized" }, 401); - const eventId = z.string().uuid().parse(c.req.param("eventId")); - const placements = await publishedPlacements(options.db, eventId); - if (!placements) return c.json({ message: "Not found" }, 404); - if (!placements.published) return c.json({ message: "Forbidden" }, 403); - return c.json(placements); - }); - - app.get("/v1/feedback/external/:eventId/:externalId", async (c) => { - const ctx = await contextFor(c); - if (!ctx.actor || !roleAtLeast(ctx.actor.role, "organizer")) { - return c.json({ message: "Unauthorized" }, 401); - } - const eventId = z.string().uuid().parse(c.req.param("eventId")); - const token = await feedbackTokenForExternal(options.db, eventId, c.req.param("externalId")); - if (!token) return c.json({ message: "Forbidden" }, 403); - return c.json({ token }); - }); - - app.get("/v1/feedback/:token", async (c) => { - const card = await feedbackCard(options.db, c.req.param("token")); - if (card.status === "forbidden") return c.json({ message: "Forbidden" }, 403); - return c.json(card); - }); - - app.all("/trpc/*", (c) => - fetchRequestHandler({ - endpoint: "/trpc", - req: c.req.raw, - router: appRouter, - createContext: () => contextFor(c), - }), - ); - - return app; -} diff --git a/packages/judging-server/src/auth.test.ts b/packages/judging-server/src/auth.test.ts deleted file mode 100644 index 33babd47..00000000 --- a/packages/judging-server/src/auth.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { hashApiKey, roleForScopes } from "./auth"; - -describe("api keys", () => { - it("hashes a token the same way every time", () => { - expect(hashApiKey("panel_live_key")).toBe(hashApiKey("panel_live_key")); - expect(hashApiKey("panel_live_key")).not.toBe(hashApiKey("panel_live_other")); - }); - - it("uses the strongest scope as the role", () => { - expect(roleForScopes(["volunteer", "organizer"])).toBe("organizer"); - expect(roleForScopes(["admin"])).toBe("admin"); - expect(roleForScopes(["import"])).toBe("organizer"); - expect(roleForScopes(["read"])).toBe(null); - }); -}); diff --git a/packages/judging-server/src/auth.ts b/packages/judging-server/src/auth.ts deleted file mode 100644 index 8a51e635..00000000 --- a/packages/judging-server/src/auth.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { createHash, randomInt } from "node:crypto"; -import { SignJWT, createRemoteJWKSet, jwtVerify } from "jose"; - -export const ROLES = ["volunteer", "organizer", "admin", "owner"] as const; -export type Role = (typeof ROLES)[number]; - -export type Actor = { - sub: string; - email: string; - name: string; - org: string | null; - role: Role | null; - judgeExternalId: string | null; -}; - -const ROLE_RANK: Record = { - volunteer: 0, - organizer: 1, - admin: 2, - owner: 3, -}; - -export function roleAtLeast(actual: Role | null, minimum: Role): boolean { - if (!actual) return false; - return ROLE_RANK[actual] >= ROLE_RANK[minimum]; -} - -export function hashCode(code: string): string { - return createHash("sha256").update(code).digest("hex"); -} - -export function newLoginCode(): string { - return String(randomInt(0, 1_000_000)).padStart(6, "0"); -} - -export async function signActor( - actor: Actor, - secret: string, - ttlSeconds: number, -): Promise { - return new SignJWT({ - email: actor.email, - name: actor.name, - org: actor.org, - role: actor.role, - judge_external_id: actor.judgeExternalId, - }) - .setProtectedHeader({ alg: "HS256" }) - .setSubject(actor.sub) - .setIssuedAt() - .setExpirationTime(`${ttlSeconds}s`) - .sign(new TextEncoder().encode(secret)); -} - -export async function verifyActor( - token: string, - options: { secret?: string; jwksUrl?: string }, -): Promise { - const verified = options.jwksUrl - ? await jwtVerify(token, createRemoteJWKSet(new URL(options.jwksUrl))) - : await jwtVerify(token, new TextEncoder().encode(options.secret ?? "")); - const payload = verified.payload; - const email = payload.email; - const name = payload.name; - if (typeof email !== "string" || typeof payload.sub !== "string") { - throw new Error("Token is missing sub or email"); - } - const role = ROLES.find((item) => item === payload.role) ?? null; - return { - sub: payload.sub, - email, - name: typeof name === "string" ? name : email, - org: typeof payload.org === "string" ? payload.org : null, - role, - judgeExternalId: - typeof payload.judge_external_id === "string" - ? payload.judge_external_id - : null, - }; -} - -export function hashApiKey(token: string): string { - return createHash("sha256").update(token).digest("hex"); -} - -/** The strongest role named in the key's scopes. Import and export are organizer work. */ -export function roleForScopes(scopes: readonly string[]): Role | null { - if (scopes.includes("owner")) return "owner"; - if (scopes.includes("admin")) return "admin"; - if ( - scopes.includes("organizer") || - scopes.includes("import") || - scopes.includes("export") || - scopes.includes("webhooks") - ) { - return "organizer"; - } - if (scopes.includes("volunteer")) return "volunteer"; - return null; -} diff --git a/packages/judging-server/src/bus.test.ts b/packages/judging-server/src/bus.test.ts deleted file mode 100644 index b35b71f4..00000000 --- a/packages/judging-server/src/bus.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { FallbackBus, InMemoryBus } from "./bus"; -import type { Bus } from "./bus"; - -describe("InMemoryBus", () => { - it("delivers a message only to subscribers of that channel", () => { - const bus = new InMemoryBus(); - const seen: unknown[] = []; - const stop = bus.subscribe("event:1:board", (message) => seen.push(message)); - bus.publish("event:1:board", { kind: "vote.cast" }); - bus.publish("event:2:board", { kind: "other" }); - stop(); - bus.publish("event:1:board", { kind: "again" }); - expect(seen).toEqual([{ kind: "vote.cast" }]); - }); -}); - -class DownableBus implements Bus { - readonly seen: unknown[] = []; - private readonly down: (() => void)[] = []; - - publish(_channel: string, message: unknown) { - this.seen.push(message); - } - - subscribe() { - return () => undefined; - } - - onDown(listener: () => void) { - this.down.push(listener); - } - - stop() { - for (const listener of this.down) listener(); - } -} - -describe("FallbackBus", () => { - it("keeps delivering on this process after Redis reports down", () => { - const redis = new DownableBus(); - const bus = new FallbackBus(redis); - const seen: unknown[] = []; - bus.subscribe("event:1:board", (message) => seen.push(message)); - bus.publish("event:1:board", { kind: "before" }); - redis.stop(); - bus.publish("event:1:board", { kind: "after" }); - expect(redis.seen).toEqual([{ kind: "before" }]); - expect(seen).toEqual([{ kind: "after" }]); - }); -}); diff --git a/packages/judging-server/src/bus.ts b/packages/judging-server/src/bus.ts deleted file mode 100644 index 1cc6fe2b..00000000 --- a/packages/judging-server/src/bus.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Redis } from "ioredis"; -import { log } from "./log"; - -type Listener = (message: unknown) => void; - -export type Bus = { - publish(channel: string, message: unknown): void; - subscribe(channel: string, listener: Listener): () => void; - onDown(listener: () => void): void; -}; - -/** In-process fan-out. Used when REDIS_URL is unset. */ -export class InMemoryBus implements Bus { - private readonly channels = new Map>(); - - publish(channel: string, message: unknown) { - for (const listener of this.channels.get(channel) ?? []) listener(message); - } - - subscribe(channel: string, listener: Listener) { - const listeners = this.channels.get(channel) ?? new Set(); - listeners.add(listener); - this.channels.set(channel, listeners); - return () => { - listeners.delete(listener); - if (listeners.size === 0) this.channels.delete(channel); - }; - } - - onDown() { - // This process is the bus. - } -} - -export function createRedisBus(url: string): Bus { - const options = { maxRetriesPerRequest: null }; - const pub = new Redis(url, options); - const sub = new Redis(url, options); - const listeners = new Map>(); - const downListeners = new Set<() => void>(); - let down = false; - const fail = () => { - if (down) return; - down = true; - for (const listener of downListeners) listener(); - }; - pub.on("error", fail); - sub.on("error", fail); - sub.on("message", (channel: string, raw: string) => { - const message = JSON.parse(raw) as unknown; - for (const listener of listeners.get(channel) ?? []) listener(message); - }); - return { - publish(channel, message) { - void pub.publish(channel, JSON.stringify(message)); - }, - subscribe(channel, listener) { - const set = listeners.get(channel) ?? new Set(); - const first = set.size === 0; - set.add(listener); - listeners.set(channel, set); - if (first) void sub.subscribe(channel); - return () => { - set.delete(listener); - if (set.size === 0) void sub.unsubscribe(channel); - }; - }, - onDown(listener) { - downListeners.add(listener); - }, - }; -} - -/** Redis when REDIS_URL is set, otherwise the in-process bus. */ -export function createBus(redisUrl: string | undefined): Bus { - const url = redisUrl?.trim(); - if (!url) return new InMemoryBus(); - return new FallbackBus(createRedisBus(url)); -} - -/** - * Uses Redis until the connection errors, then this process fans out in memory. - * Clients still reload state from the live snapshot, so a second server does - * not need Redis for correctness. - */ -export class FallbackBus implements Bus { - private readonly memory = new InMemoryBus(); - private redis: Bus | null; - private failed = false; - private readonly subs: { channel: string; listener: Listener; unsubscribe: () => void }[] = []; - - constructor(redis: Bus) { - this.redis = redis; - redis.onDown(() => this.fail()); - } - - private fail() { - if (this.failed) return; - this.failed = true; - for (const sub of this.subs) { - sub.unsubscribe(); - sub.unsubscribe = this.memory.subscribe(sub.channel, sub.listener); - } - this.redis = null; - log({ - level: "warn", - message: "Redis is unavailable. This process is using the in-process bus.", - }); - } - - publish(channel: string, message: unknown) { - const target = this.failed ? this.memory : this.redis; - target?.publish(channel, message); - } - - subscribe(channel: string, listener: Listener) { - const target = this.failed ? this.memory : this.redis; - const unsubscribe = target?.subscribe(channel, listener) ?? (() => undefined); - const sub = { channel, listener, unsubscribe }; - this.subs.push(sub); - return () => { - sub.unsubscribe(); - const index = this.subs.indexOf(sub); - if (index >= 0) this.subs.splice(index, 1); - }; - } - - onDown() { - // Callers keep their sockets. This process still delivers messages. - } -} diff --git a/packages/judging-server/src/index.ts b/packages/judging-server/src/index.ts deleted file mode 100644 index 669d6c96..00000000 --- a/packages/judging-server/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { createApp } from "./app"; -export { appRouter } from "./router"; -export { drainOutbox } from "./services/outbox"; -export type { Context } from "./router"; -export type { Actor, Role } from "./auth"; -export { InMemoryBus } from "./bus"; -export { createMetrics } from "./metrics"; -export { publishedPlacements } from "./services/results"; -export { ensureEvent } from "./services/event"; diff --git a/packages/judging-server/src/log.ts b/packages/judging-server/src/log.ts deleted file mode 100644 index 7f80e178..00000000 --- a/packages/judging-server/src/log.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function log(fields: Record) { - console.log(JSON.stringify({ time: new Date().toISOString(), ...fields })); -} diff --git a/packages/judging-server/src/mail.ts b/packages/judging-server/src/mail.ts deleted file mode 100644 index 89d84954..00000000 --- a/packages/judging-server/src/mail.ts +++ /dev/null @@ -1,12 +0,0 @@ -import nodemailer from "nodemailer"; - -/** Sends the sign-in code when PANEL_SMTP_URL is set. */ -export async function sendLoginCode(smtpUrl: string, to: string, code: string) { - const transport = nodemailer.createTransport(smtpUrl); - await transport.sendMail({ - from: process.env.PANEL_SMTP_FROM ?? "panel@localhost", - to, - subject: "Your panel sign-in code", - text: `Your sign-in code is ${code}. It expires in 10 minutes.`, - }); -} diff --git a/packages/judging-server/src/metrics.ts b/packages/judging-server/src/metrics.ts deleted file mode 100644 index 5089489a..00000000 --- a/packages/judging-server/src/metrics.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Counter, Gauge, Histogram, Registry } from "prom-client"; - -export function createMetrics() { - const register = new Registry(); - const dispatchSeconds = new Histogram({ - name: "panel_dispatch_seconds", - help: "Time to choose and record the next table", - registers: [register], - }); - const votes = new Counter({ - name: "panel_votes_total", - help: "Votes stored", - registers: [register], - }); - const outboxPending = new Gauge({ - name: "panel_outbox_pending", - help: "Outbox rows not yet delivered", - registers: [register], - }); - const wsConnections = new Gauge({ - name: "panel_ws_connections", - help: "Open websocket clients", - registers: [register], - }); - const liveJudges = new Gauge({ - name: "panel_live_judges", - help: "Judges with a visit that is still open", - registers: [register], - }); - const coverage = new Histogram({ - name: "panel_project_looks", - help: "How many scores a project had when the latest one landed", - buckets: [1, 2, 3, 4, 5, 8, 13], - labelNames: ["event_id"], - registers: [register], - }); - return { register, dispatchSeconds, votes, outboxPending, wsConnections, liveJudges, coverage }; -} - -export type Metrics = ReturnType; diff --git a/packages/judging-server/src/router.ts b/packages/judging-server/src/router.ts deleted file mode 100644 index 90e44911..00000000 --- a/packages/judging-server/src/router.ts +++ /dev/null @@ -1,674 +0,0 @@ -import { initTRPC, TRPCError } from "@trpc/server"; -import { and, eq, sql } from "drizzle-orm"; -import { z } from "zod"; -import { assertTransition, boardChannel, judgeChannel, leaderboardChannel } from "@query/judging-core"; -import type { PanelDb } from "@query/judging-db"; -import { event, eventLog, judge, criterion, rubric, visit, vote } from "@query/judging-db"; -import type { Actor, Role } from "./auth"; -import { roleAtLeast } from "./auth"; -import type { Bus } from "./bus"; -import type { Metrics } from "./metrics"; -import { log } from "./log"; -import { arrive } from "./services/arrive"; -import { dispatchNext, judgeProgress, skipVisit, visitStatus } from "./services/dispatch"; -import { - assignJudge, - applyAsJudge, - recallJudge, - setJudgeStatus, - upsertJudge, - upsertProject, - voidVisit, - withdrawProject, - rotateQr, - assignEventTables, -} from "./services/records"; -import { - computeResults, - diffPlacements, - inspectRun, - placementsForRun, - publishedPlacements, - publishResults, - unpublishResults, -} from "./services/results"; -import { castComparison } from "./services/compare"; -import { - issueApiKey, - listProjects, - listLogs, - listJudges, - listTables, - listTracks, - placeTable, - savePrize, - saveRubric, - saveTrack, - saveWebhook, - saveZone, - saveConfig, -} from "./services/catalog"; -import { castVote } from "./services/vote"; -import { drainOutbox } from "./services/outbox"; - -export type Context = { - db: PanelDb; - actor: Actor | null; - now: Date; - bus: Bus; - metrics: Metrics; -}; - -function tell( - ctx: Context, - eventId: string, - judgeId: string | null, - kind: string, - visitId?: string, -) { - log({ - level: "info", - message: kind, - event_id: eventId, - judge_id: judgeId, - visit_id: visitId ?? null, - }); - const message = { kind, eventId }; - ctx.bus.publish(boardChannel(eventId), message); - ctx.bus.publish(leaderboardChannel(eventId), message); - if (judgeId) ctx.bus.publish(judgeChannel(judgeId), message); -} - -const t = initTRPC.context().create(); - -function asTrpc(error: unknown): never { - const message = error instanceof Error ? error.message : "Request failed"; - const code = message.includes("not allowed") - ? "PRECONDITION_FAILED" - : message.includes("not found") || message.includes("not approved") - ? "NOT_FOUND" - : message.includes("hard limit") - ? "CONFLICT" - : "BAD_REQUEST"; - throw new TRPCError({ code, message }); -} - -const authed = t.procedure.use(({ ctx, next }) => { - if (!ctx.actor) throw new TRPCError({ code: "UNAUTHORIZED" }); - return next({ ctx: { ...ctx, actor: ctx.actor } }); -}); - -function atLeast(role: Role) { - return authed.use(({ ctx, next }) => { - if (!roleAtLeast(ctx.actor.role, role)) { - throw new TRPCError({ code: "FORBIDDEN" }); - } - return next(); - }); -} - -async function requireJudge(ctx: Context, eventId: string) { - if (!ctx.actor) throw new TRPCError({ code: "UNAUTHORIZED" }); - const rows = await ctx.db - .select() - .from(judge) - .where(eq(judge.eventId, eventId)); - const match = rows.find( - (row) => - (ctx.actor?.judgeExternalId && - row.externalId === ctx.actor.judgeExternalId) || - row.email.toLowerCase() === ctx.actor?.email.toLowerCase(), - ); - if (!match || match.status !== "approved") { - throw new TRPCError({ code: "FORBIDDEN", message: "This judge is not approved" }); - } - return match; -} - -const uuid = z.string().uuid(); - -export const appRouter = t.router({ - session: t.router({ - next: authed - .input(z.object({ eventId: uuid })) - .mutation(async ({ ctx, input }) => { - const row = await requireJudge(ctx, input.eventId); - const stop = ctx.metrics.dispatchSeconds.startTimer(); - try { - const outcome = await dispatchNext(ctx.db, { - eventId: input.eventId, - judgeId: row.id, - now: ctx.now, - }); - tell( - ctx, - input.eventId, - row.id, - "visit.handed_out", - outcome.done ? undefined : outcome.visitId, - ); - return outcome; - } catch (error) { - asTrpc(error); - } finally { - stop(); - } - }), - arrive: authed - .input( - z.object({ - eventId: uuid, - qrToken: uuid.optional(), - tableNumber: z.number().int().optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - const row = await requireJudge(ctx, input.eventId); - try { - const outcome = await arrive(ctx.db, { - eventId: input.eventId, - judgeId: row.id, - qrToken: input.qrToken, - tableNumber: input.tableNumber, - now: ctx.now, - }); - tell(ctx, input.eventId, row.id, "visit.arrived", outcome.visitId); - return outcome; - } catch (error) { - asTrpc(error); - } - }), - vote: authed - .input( - z.object({ - eventId: uuid, - visitId: uuid, - comment: z.string().nullable().default(null), - scores: z.array( - z.object({ criterionId: uuid, value: z.number() }), - ), - }), - ) - .mutation(async ({ ctx, input }) => { - const row = await requireJudge(ctx, input.eventId); - try { - const outcome = await castVote(ctx.db, { - eventId: input.eventId, - judgeId: row.id, - visitId: input.visitId, - scores: input.scores, - comment: input.comment, - now: ctx.now, - }); - tell(ctx, input.eventId, row.id, "vote.cast", input.visitId); - ctx.metrics.votes.inc(); - const [current] = await ctx.db - .select({ projectId: visit.projectId }) - .from(visit) - .where(eq(visit.id, input.visitId)); - if (current) { - const [looks] = await ctx.db - .select({ n: sql`count(*)::int` }) - .from(vote) - .where( - and(eq(vote.eventId, input.eventId), eq(vote.projectId, current.projectId)), - ); - ctx.metrics.coverage.observe({ event_id: input.eventId }, looks?.n ?? 1); - } - return outcome; - } catch (error) { - asTrpc(error); - } - }), - rubric: authed - .input(z.object({ eventId: uuid })) - .query(async ({ ctx, input }) => { - await requireJudge(ctx, input.eventId); - const [rub] = await ctx.db - .select({ id: rubric.id }) - .from(rubric) - .where(and(eq(rubric.eventId, input.eventId), eq(rubric.isDefault, true))); - if (!rub) return []; - return ctx.db - .select({ - id: criterion.id, - label: criterion.label, - min: criterion.min, - max: criterion.max, - anchors: criterion.anchors, - }) - .from(criterion) - .where(eq(criterion.rubricId, rub.id)); - }), - compare: authed - .input(z.object({ eventId: uuid, outcome: z.enum(["a", "b", "tie"]) })) - .mutation(async ({ ctx, input }) => { - const row = await requireJudge(ctx, input.eventId); - try { - const outcome = await castComparison(ctx.db, { - eventId: input.eventId, - judgeId: row.id, - outcome: input.outcome, - now: ctx.now, - }); - tell(ctx, input.eventId, row.id, "comparison.cast"); - return outcome; - } catch (error) { - asTrpc(error); - } - }), - skip: authed - .input(z.object({ eventId: uuid, visitId: uuid })) - .mutation(async ({ ctx, input }) => { - const row = await requireJudge(ctx, input.eventId); - try { - return await skipVisit(ctx.db, { - eventId: input.eventId, - judgeId: row.id, - visitId: input.visitId, - now: ctx.now, - }); - } catch (error) { - asTrpc(error); - } - }), - progress: authed - .input(z.object({ eventId: uuid })) - .query(async ({ ctx, input }) => { - const row = await requireJudge(ctx, input.eventId); - try { - return await judgeProgress(ctx.db, { eventId: input.eventId, judgeId: row.id }); - } catch (error) { - asTrpc(error); - } - }), - status: authed - .input(z.object({ eventId: uuid, visitId: uuid })) - .query(async ({ ctx, input }) => { - const row = await requireJudge(ctx, input.eventId); - try { - return await visitStatus(ctx.db, { - eventId: input.eventId, - judgeId: row.id, - visitId: input.visitId, - }); - } catch (error) { - asTrpc(error); - } - }), - }), - event: t.router({ - setPhase: atLeast("organizer") - .input(z.object({ eventId: uuid, phase: z.enum([ - "setup", - "submissions_open", - "submissions_closed", - "judging_live", - "judging_closed", - "published", - "archived", - ]) })) - .mutation(async ({ ctx, input }) => { - const [current] = await ctx.db - .select({ phase: event.phase, id: event.id }) - .from(event) - .where(eq(event.id, input.eventId)); - if (!current) throw new TRPCError({ code: "NOT_FOUND" }); - try { - assertTransition(current.phase, input.phase); - } catch (error) { - asTrpc(error); - } - await ctx.db.transaction(async (tx) => { - await tx - .update(event) - .set({ phase: input.phase }) - .where(eq(event.id, input.eventId)); - await tx.insert(eventLog).values({ - eventId: input.eventId, - kind: "phase.changed", - actor: { email: ctx.actor.email }, - subject: { eventId: input.eventId }, - payload: { from: current.phase, to: input.phase }, - }); - }); - tell(ctx, input.eventId, null, "phase.changed"); - return { phase: input.phase }; - }), - }), - project: t.router({ - upsert: atLeast("organizer") - .input( - z.object({ - eventId: uuid, - externalId: z.string().min(1), - name: z.string().min(1), - teamName: z.string().nullable().default(null), - tableNumber: z.number().int().nullable().default(null), - }), - ) - .mutation(async ({ ctx, input }) => { - try { - return await upsertProject(ctx.db, { ...input, actorEmail: ctx.actor.email }); - } catch (error) { - asTrpc(error); - } - }), - withdraw: atLeast("organizer") - .input(z.object({ eventId: uuid, projectId: uuid })) - .mutation(async ({ ctx, input }) => { - try { - await withdrawProject(ctx.db, { ...input, now: ctx.now, actorEmail: ctx.actor.email }); - return { ok: true }; - } catch (error) { - asTrpc(error); - } - }), - rotateQr: atLeast("organizer") - .input(z.object({ eventId: uuid, projectId: uuid })) - .mutation(async ({ ctx, input }) => { - try { - return await rotateQr(ctx.db, { ...input, actorEmail: ctx.actor.email }); - } catch (error) { - asTrpc(error); - } - }), - assignTables: atLeast("organizer") - .input(z.object({ eventId: uuid })) - .mutation(async ({ ctx, input }) => { - return assignEventTables(ctx.db, { - eventId: input.eventId, - actorEmail: ctx.actor.email, - }); - }), - }), - judge: t.router({ - applyToEvent: authed - .input(z.object({ eventId: uuid, name: z.string().min(1) })) - .mutation(async ({ ctx, input }) => { - try { - return await applyAsJudge(ctx.db, { - eventId: input.eventId, - email: ctx.actor.email, - name: input.name, - }); - } catch (error) { - asTrpc(error); - } - }), - upsert: atLeast("organizer") - .input( - z.object({ - eventId: uuid, - email: z.string().email(), - name: z.string().min(1), - externalId: z.string().nullable().default(null), - }), - ) - .mutation(async ({ ctx, input }) => { - try { - return await upsertJudge(ctx.db, { ...input, actorEmail: ctx.actor.email }); - } catch (error) { - asTrpc(error); - } - }), - assign: atLeast("organizer") - .input( - z.object({ - eventId: uuid, - judgeId: uuid, - trackId: uuid.nullable(), - zoneId: uuid.nullable(), - }), - ) - .mutation(async ({ ctx, input }) => { - try { - return await assignJudge(ctx.db, { ...input, actorEmail: ctx.actor.email }); - } catch (error) { - asTrpc(error); - } - }), - setStatus: atLeast("organizer") - .input( - z.object({ - eventId: uuid, - judgeId: uuid, - status: z.enum(["approved", "suspended"]), - }), - ) - .mutation(async ({ ctx, input }) => { - await setJudgeStatus(ctx.db, { ...input, actorEmail: ctx.actor.email }); - return { ok: true }; - }), - recall: atLeast("organizer") - .input(z.object({ eventId: uuid, judgeId: uuid })) - .mutation(async ({ ctx, input }) => { - await recallJudge(ctx.db, { ...input, actorEmail: ctx.actor.email }); - tell(ctx, input.eventId, input.judgeId, "judge.recalled"); - return { ok: true }; - }), - voidVisit: atLeast("organizer") - .input(z.object({ eventId: uuid, visitId: uuid, reason: z.string().min(1) })) - .mutation(async ({ ctx, input }) => { - try { - const voided = await voidVisit(ctx.db, { ...input, now: ctx.now, actorEmail: ctx.actor.email }); - tell(ctx, input.eventId, voided.judgeId, "visit.voided", input.visitId); - return { ok: true }; - } catch (error) { - asTrpc(error); - } - }), - }), - results: t.router({ - compute: atLeast("organizer") - .input(z.object({ eventId: uuid })) - .mutation(async ({ ctx, input }) => { - try { - return await computeResults(ctx.db, { - eventId: input.eventId, - actorEmail: ctx.actor.email, - now: ctx.now, - }); - } catch (error) { - asTrpc(error); - } - }), - publish: atLeast("organizer") - .input(z.object({ eventId: uuid, runId: uuid })) - .mutation(async ({ ctx, input }) => { - try { - const outcome = await publishResults(ctx.db, { - ...input, - actorEmail: ctx.actor.email, - now: ctx.now, - }); - tell(ctx, input.eventId, null, "results.published"); - // Nothing drains the queue on a timer, and publish is the one step - // every event takes. Webhooks go out here; rows nobody listens for - // are cleared. A failed delivery stays queued for `panel outbox drain`. - for (let batch = 0; batch < 20; batch += 1) { - const delivered = await drainOutbox(ctx.db).catch(() => 0); - if (delivered === 0) break; - } - return outcome; - } catch (error) { - asTrpc(error); - } - }), - inspect: atLeast("organizer") - .input(z.object({ runId: uuid })) - .query(({ ctx, input }) => inspectRun(ctx.db, input.runId)), - diff: atLeast("organizer") - .input(z.object({ runA: uuid, runB: uuid })) - .query(async ({ ctx, input }) => { - const [left, right] = await Promise.all([ - placementsForRun(ctx.db, input.runA), - placementsForRun(ctx.db, input.runB), - ]); - return diffPlacements(left, right); - }), - unpublish: atLeast("organizer") - .input(z.object({ eventId: uuid })) - .mutation(async ({ ctx, input }) => { - try { - const outcome = await unpublishResults(ctx.db, { - eventId: input.eventId, - actorEmail: ctx.actor.email, - }); - tell(ctx, input.eventId, null, "results.unpublished"); - return outcome; - } catch (error) { - asTrpc(error); - } - }), - export: atLeast("organizer") - .input(z.object({ eventId: uuid })) - .query(({ ctx, input }) => publishedPlacements(ctx.db, input.eventId)), - }), - catalog: t.router({ - tracks: atLeast("organizer") - .input(z.object({ eventId: uuid })) - .query(({ ctx, input }) => listTracks(ctx.db, input.eventId)), - projects: atLeast("organizer") - .input(z.object({ eventId: uuid })) - .query(({ ctx, input }) => listProjects(ctx.db, input.eventId)), - logs: atLeast("organizer") - .input(z.object({ eventId: uuid })) - .query(({ ctx, input }) => listLogs(ctx.db, input.eventId)), - tables: atLeast("organizer") - .input(z.object({ eventId: uuid })) - .query(({ ctx, input }) => listTables(ctx.db, input.eventId)), - judges: atLeast("organizer") - .input(z.object({ eventId: uuid })) - .query(({ ctx, input }) => listJudges(ctx.db, input.eventId)), - saveTrack: atLeast("organizer") - .input( - z.object({ - eventId: uuid, - slug: z.string().min(1), - name: z.string().min(1), - kind: z.enum(["main", "sponsor", "special"]), - judgeGroup: z.string().min(1), - }), - ) - .mutation(async ({ ctx, input }) => { - try { - return await saveTrack(ctx.db, { ...input, actorEmail: ctx.actor.email }); - } catch (error) { - asTrpc(error); - } - }), - savePrize: atLeast("organizer") - .input( - z.object({ - trackId: uuid, - place: z.number().int(), - title: z.string().min(1), - amount: z.number().nullable().default(null), - }), - ) - .mutation(async ({ ctx, input }) => { - try { - return await savePrize(ctx.db, { ...input, actorEmail: ctx.actor.email }); - } catch (error) { - asTrpc(error); - } - }), - saveZone: atLeast("organizer") - .input( - z.object({ - eventId: uuid, - name: z.string().min(1), - position: z.number().int().default(0), - }), - ) - .mutation(async ({ ctx, input }) => { - try { - return await saveZone(ctx.db, { ...input, actorEmail: ctx.actor.email }); - } catch (error) { - asTrpc(error); - } - }), - placeTable: atLeast("organizer") - .input( - z.object({ - eventId: uuid, - number: z.number().int(), - zoneId: uuid.nullable().default(null), - x: z.number().nullable().default(null), - y: z.number().nullable().default(null), - }), - ) - .mutation(async ({ ctx, input }) => { - await placeTable(ctx.db, { ...input, actorEmail: ctx.actor.email }); - return { ok: true }; - }), - saveRubric: atLeast("organizer") - .input( - z.object({ - eventId: uuid, - name: z.string().min(1), - isDefault: z.boolean().default(false), - criteria: z.array( - z.object({ - key: z.string().min(1), - label: z.string().min(1), - min: z.number(), - max: z.number(), - weight: z.number(), - }), - ), - }), - ) - .mutation(async ({ ctx, input }) => { - try { - return await saveRubric(ctx.db, { ...input, actorEmail: ctx.actor.email }); - } catch (error) { - asTrpc(error); - } - }), - saveWebhook: atLeast("organizer") - .input( - z.object({ - eventId: uuid, - url: z.string().url(), - secret: z.string().min(8), - topics: z.array(z.string()).min(1), - }), - ) - .mutation(async ({ ctx, input }) => { - try { - return await saveWebhook(ctx.db, { ...input, actorEmail: ctx.actor.email }); - } catch (error) { - asTrpc(error); - } - }), - issueApiKey: atLeast("admin") - .input(z.object({ eventId: uuid, scopes: z.array(z.string()).min(1) })) - .mutation(async ({ ctx, input }) => { - try { - return await issueApiKey(ctx.db, { ...input, actorEmail: ctx.actor.email }); - } catch (error) { - asTrpc(error); - } - }), - saveConfig: atLeast("organizer") - .input( - z.object({ - eventId: uuid, - hardLimitSeconds: z.number().int().positive(), - dispatchStrategy: z.enum(["coverage", "uncertainty"]), - pairwiseEnabled: z.boolean(), - pairwiseWeight: z.number().min(0).max(1), - leaderboardPublic: z.boolean(), - boardPollSeconds: z.number().int().positive(), - }), - ) - .mutation(async ({ ctx, input }) => { - await saveConfig(ctx.db, { ...input, actorEmail: ctx.actor.email }); - return { ok: true }; - }), - }), -}); - -export type AppRouter = typeof appRouter; diff --git a/packages/judging-server/src/services/arrive.ts b/packages/judging-server/src/services/arrive.ts deleted file mode 100644 index 616e98f7..00000000 --- a/packages/judging-server/src/services/arrive.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { and, eq, isNull } from "drizzle-orm"; -import type { PanelDb } from "@query/judging-db"; -import { event, eventLog, outbox, project, visit } from "@query/judging-db"; -import { phaseAllows } from "@query/judging-core"; - -export async function arrive( - db: PanelDb, - input: { - eventId: string; - judgeId: string; - qrToken?: string; - tableNumber?: number; - now: Date; - }, -): Promise<{ visitId: string; projectId: string }> { - return db.transaction(async (tx) => { - const [evt] = await tx - .select({ phase: event.phase }) - .from(event) - .where(eq(event.id, input.eventId)); - if (!evt) throw new Error("Event not found"); - if (!phaseAllows(evt.phase, "dispatch")) { - throw new Error(`dispatch is not allowed while the event is ${evt.phase}`); - } - - const [row] = await tx - .select() - .from(visit) - .where( - and( - eq(visit.judgeId, input.judgeId), - eq(visit.eventId, input.eventId), - isNull(visit.completedAt), - isNull(visit.voidedAt), - ), - ); - if (!row) throw new Error("No table is in hand"); - - const [current] = await tx - .select() - .from(project) - .where(eq(project.id, row.projectId)); - if (!current) throw new Error("Project not found"); - - const qrOk = input.qrToken !== undefined && input.qrToken === current.qrToken; - const tableOk = - input.tableNumber !== undefined && input.tableNumber === current.tableNumber; - if (!qrOk && !tableOk) throw new Error("That is not the table you were sent to"); - - if (row.arrivedAt) return { visitId: row.id, projectId: row.projectId }; - - await tx - .update(visit) - .set({ arrivedAt: input.now }) - .where(eq(visit.id, row.id)); - if (!current.arrivedFirstAt) { - await tx - .update(project) - .set({ arrivedFirstAt: input.now }) - .where(eq(project.id, current.id)); - } - await tx.insert(eventLog).values({ - eventId: input.eventId, - kind: "visit.arrived", - actor: { judgeId: input.judgeId }, - subject: { visitId: row.id, projectId: row.projectId }, - payload: {}, - }); - await tx.insert(outbox).values({ - eventId: input.eventId, - topic: "visit.arrived", - payload: { visitId: row.id, judgeId: input.judgeId }, - }); - return { visitId: row.id, projectId: row.projectId }; - }); -} diff --git a/packages/judging-server/src/services/catalog.ts b/packages/judging-server/src/services/catalog.ts deleted file mode 100644 index 8f1e3996..00000000 --- a/packages/judging-server/src/services/catalog.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; -import type { PanelDb } from "@query/judging-db"; -import { - apiKey, - criterion, - event, - eventConfig, - eventLog, - floorTable, - judge, - organization, - prize, - project, - projectTrack, - result, - resultRun, - rubric, - track, - vote, - voteScore, - webhook, - zone, -} from "@query/judging-db"; -import { randomBytes } from "node:crypto"; -import type { Actor } from "../auth"; -import { hashApiKey, roleForScopes } from "../auth"; -import { feedbackStatus, median } from "./feedback"; - -async function placementsForProject(db: PanelDb, projectId: string) { - const links = await db - .select({ trackId: track.id, track: track.name }) - .from(projectTrack) - .innerJoin(track, eq(track.id, projectTrack.trackId)) - .where(eq(projectTrack.projectId, projectId)); - const placed = await db - .select({ trackId: result.trackId, placement: result.placement }) - .from(result) - .innerJoin(resultRun, eq(resultRun.id, result.runId)) - .where(and(eq(result.projectId, projectId), isNotNull(resultRun.publishedAt))); - const placeOf = new Map(placed.map((row) => [row.trackId, row.placement])); - return links.map((link) => ({ - track: link.track, - placement: placeOf.get(link.trackId) ?? null, - })); -} - -/** The published card token for a project the club already knows by id. */ -export async function feedbackTokenForExternal(db: PanelDb, eventId: string, externalId: string) { - const [row] = await db - .select({ token: project.feedbackToken, phase: event.phase }) - .from(project) - .innerJoin(event, eq(event.id, project.eventId)) - .where(and(eq(project.eventId, eventId), eq(project.externalId, externalId))); - if (!row?.token || row.phase !== "published") return null; - return row.token; -} - -export async function feedbackCard(db: PanelDb, token: string) { - const [row] = await db - .select({ - projectId: project.id, - name: project.name, - eventId: project.eventId, - phase: event.phase, - stored: project.feedbackToken, - }) - .from(project) - .innerJoin(event, eq(event.id, project.eventId)) - .where(eq(project.feedbackToken, token)); - if ( - feedbackStatus({ - published: row?.phase === "published", - tokenMatches: row?.stored === token, - }) === "forbidden" || - !row - ) { - return { status: "forbidden" as const }; - } - - const scores = await db - .select({ - projectId: vote.projectId, - criterionId: voteScore.criterionId, - label: criterion.label, - value: voteScore.value, - }) - .from(voteScore) - .innerJoin(vote, eq(vote.id, voteScore.voteId)) - .innerJoin(criterion, eq(criterion.id, voteScore.criterionId)) - .where(eq(vote.eventId, row.eventId)); - - const byCriterion = new Map(); - for (const score of scores) { - const bucket = byCriterion.get(score.criterionId) ?? { - label: score.label, - mine: [], - all: [], - }; - bucket.all.push(score.value); - if (score.projectId === row.projectId) bucket.mine.push(score.value); - byCriterion.set(score.criterionId, bucket); - } - - return { - status: "ok" as const, - name: row.name, - criteria: [...byCriterion.values()].map((bucket) => ({ - label: bucket.label, - mean: - bucket.mine.length === 0 - ? 0 - : bucket.mine.reduce((sum, value) => sum + value, 0) / bucket.mine.length, - median: median(bucket.all), - })), - places: await placementsForProject(db, row.projectId), - comments: ( - await db - .select({ comment: vote.comment }) - .from(vote) - .where(and(eq(vote.projectId, row.projectId), isNotNull(vote.comment))) - ) - .map((item) => item.comment) - .filter((comment): comment is string => Boolean(comment)), - }; -} - -export async function saveTrack( - db: PanelDb, - input: { - eventId: string; - slug: string; - name: string; - kind: "main" | "sponsor" | "special"; - judgeGroup: string; - actorEmail: string; - }, -) { - const [created] = await db - .insert(track) - .values(input) - .returning({ id: track.id }); - if (!created) throw new Error("Track was not stored"); - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "track.saved", - actor: { email: input.actorEmail }, - subject: { trackId: created.id }, - payload: { slug: input.slug }, - }); - return created; -} - -export async function listTracks(db: PanelDb, eventId: string) { - return db.select().from(track).where(eq(track.eventId, eventId)); -} - -export async function savePrize( - db: PanelDb, - input: { - trackId: string; - place: number; - title: string; - amount: number | null; - actorEmail: string; - }, -) { - const [created] = await db - .insert(prize) - .values({ - trackId: input.trackId, - place: input.place, - title: input.title, - amount: input.amount, - }) - .returning({ id: prize.id }); - if (!created) throw new Error("Prize was not stored"); - const [owner] = await db - .select({ eventId: track.eventId }) - .from(track) - .where(eq(track.id, input.trackId)); - if (owner) { - await db.insert(eventLog).values({ - eventId: owner.eventId, - kind: "prize.saved", - actor: { email: input.actorEmail }, - subject: { prizeId: created.id, trackId: input.trackId }, - payload: { title: input.title, place: input.place }, - }); - } - return created; -} - -export async function saveZone( - db: PanelDb, - input: { eventId: string; name: string; position: number; actorEmail: string }, -) { - const [created] = await db - .insert(zone) - .values({ eventId: input.eventId, name: input.name, position: input.position }) - .returning({ id: zone.id }); - if (!created) throw new Error("Zone was not stored"); - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "zone.saved", - actor: { email: input.actorEmail }, - subject: { zoneId: created.id }, - payload: { name: input.name }, - }); - return created; -} - -export async function placeTable( - db: PanelDb, - input: { - eventId: string; - number: number; - zoneId: string | null; - x: number | null; - y: number | null; - actorEmail: string; - }, -) { - await db - .insert(floorTable) - .values(input) - .onConflictDoUpdate({ - target: [floorTable.eventId, floorTable.number], - set: { zoneId: input.zoneId, x: input.x, y: input.y }, - }); - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "table.placed", - actor: { email: input.actorEmail }, - subject: { number: input.number }, - payload: { x: input.x, y: input.y }, - }); -} - -export async function saveRubric( - db: PanelDb, - input: { - eventId: string; - name: string; - isDefault: boolean; - criteria: { key: string; label: string; min: number; max: number; weight: number }[]; - actorEmail: string; - }, -) { - const [created] = await db - .insert(rubric) - .values({ - eventId: input.eventId, - name: input.name, - isDefault: input.isDefault, - }) - .returning({ id: rubric.id }); - if (!created) throw new Error("Rubric was not stored"); - if (input.criteria.length > 0) { - await db.insert(criterion).values( - input.criteria.map((item, position) => ({ - rubricId: created.id, - position, - key: item.key, - label: item.label, - min: item.min, - max: item.max, - weight: item.weight, - })), - ); - } - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "rubric.saved", - actor: { email: input.actorEmail }, - subject: { rubricId: created.id }, - payload: {}, - }); - return created; -} - -export async function saveWebhook( - db: PanelDb, - input: { - eventId: string; - url: string; - secret: string; - topics: string[]; - actorEmail: string; - }, -) { - const [evt] = await db - .select({ orgId: event.orgId }) - .from(event) - .where(eq(event.id, input.eventId)); - if (!evt) throw new Error("Event not found"); - const [created] = await db - .insert(webhook) - .values({ - orgId: evt.orgId, - url: input.url, - secret: input.secret, - topics: input.topics, - }) - .returning({ id: webhook.id }); - if (!created) throw new Error("Webhook was not stored"); - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "webhook.saved", - actor: { email: input.actorEmail }, - subject: { webhookId: created.id }, - payload: { url: input.url }, - }); - return created; -} - -export async function issueApiKey( - db: PanelDb, - input: { eventId: string; scopes: string[]; actorEmail: string }, -) { - const [evt] = await db - .select({ orgId: organization.id }) - .from(event) - .innerJoin(organization, eq(organization.id, event.orgId)) - .where(eq(event.id, input.eventId)); - if (!evt) throw new Error("Event not found"); - const token = randomBytes(24).toString("hex"); - const [created] = await db - .insert(apiKey) - .values({ - orgId: evt.orgId, - hashedKey: hashApiKey(token), - scopes: input.scopes, - }) - .returning({ id: apiKey.id }); - if (!created) throw new Error("API key was not stored"); - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "api_key.created", - actor: { email: input.actorEmail }, - subject: { apiKeyId: created.id }, - payload: {}, - }); - return { id: created.id, token }; -} - -export async function actorForApiKey(db: PanelDb, token: string): Promise { - const [row] = await db - .select() - .from(apiKey) - .where(and(eq(apiKey.hashedKey, hashApiKey(token)), isNull(apiKey.revokedAt))); - if (!row) return null; - const granted = roleForScopes(row.scopes); - if (!granted) return null; - const [org] = await db - .select({ slug: organization.slug }) - .from(organization) - .where(eq(organization.id, row.orgId)); - if (!org) return null; - return { - sub: row.id, - email: `key:${row.id}`, - name: "API key", - org: org.slug, - role: granted, - judgeExternalId: null, - }; -} - -export async function listTables(db: PanelDb, eventId: string) { - return db - .select({ - number: floorTable.number, - zoneId: floorTable.zoneId, - x: floorTable.x, - y: floorTable.y, - }) - .from(floorTable) - .where(eq(floorTable.eventId, eventId)); -} - -export async function listJudges(db: PanelDb, eventId: string) { - return db - .select({ - id: judge.id, - name: judge.name, - email: judge.email, - status: judge.status, - }) - .from(judge) - .where(eq(judge.eventId, eventId)); -} - -export async function listProjects(db: PanelDb, eventId: string) { - return db - .select({ - id: project.id, - name: project.name, - tableNumber: project.tableNumber, - zoneId: project.zoneId, - zoneName: zone.name, - }) - .from(project) - .leftJoin(zone, eq(zone.id, project.zoneId)) - .where(eq(project.eventId, eventId)); -} - -export async function listLogs(db: PanelDb, eventId: string) { - return db - .select({ - id: eventLog.id, - kind: eventLog.kind, - createdAt: eventLog.createdAt, - }) - .from(eventLog) - .where(eq(eventLog.eventId, eventId)) - .orderBy(desc(eventLog.createdAt)) - .limit(40); -} - -export async function saveConfig( - db: PanelDb, - input: { - eventId: string; - hardLimitSeconds: number; - dispatchStrategy: "coverage" | "uncertainty"; - pairwiseEnabled: boolean; - pairwiseWeight: number; - leaderboardPublic: boolean; - boardPollSeconds: number; - actorEmail: string; - }, -) { - await db - .update(eventConfig) - .set({ - hardLimitSeconds: input.hardLimitSeconds, - dispatchStrategy: input.dispatchStrategy, - pairwiseEnabled: input.pairwiseEnabled, - pairwiseWeight: input.pairwiseWeight, - leaderboardPublic: input.leaderboardPublic, - boardPollSeconds: input.boardPollSeconds, - }) - .where(eq(eventConfig.eventId, input.eventId)); - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "config.saved", - actor: { email: input.actorEmail }, - subject: { eventId: input.eventId }, - payload: { - dispatchStrategy: input.dispatchStrategy, - hardLimitSeconds: input.hardLimitSeconds, - }, - }); -} diff --git a/packages/judging-server/src/services/compare.ts b/packages/judging-server/src/services/compare.ts deleted file mode 100644 index f1220ce7..00000000 --- a/packages/judging-server/src/services/compare.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { and, desc, eq, isNull } from "drizzle-orm"; -import { phaseAllows } from "@query/judging-core"; -import type { PanelDb } from "@query/judging-db"; -import { comparison, event, eventLog, visit } from "@query/judging-db"; - -/** The judge's previous table against the one they are at. */ -export async function castComparison( - db: PanelDb, - input: { - eventId: string; - judgeId: string; - outcome: "a" | "b" | "tie"; - now: Date; - }, -) { - const [evt] = await db - .select({ phase: event.phase }) - .from(event) - .where(eq(event.id, input.eventId)); - if (!evt) throw new Error("Event not found"); - if (!phaseAllows(evt.phase, "vote")) { - throw new Error(`vote is not allowed while the event is ${evt.phase}`); - } - - const visits = await db - .select() - .from(visit) - .where( - and( - eq(visit.eventId, input.eventId), - eq(visit.judgeId, input.judgeId), - isNull(visit.voidedAt), - ), - ) - .orderBy(desc(visit.handedOutAt)); - const current = visits.find((row) => !row.completedAt); - const earlier = visits.find((row) => row.completedAt); - if (!current) throw new Error("No table is in hand"); - if (!earlier) throw new Error("Compare after a finished table"); - - const [row] = await db - .insert(comparison) - .values({ - eventId: input.eventId, - judgeId: input.judgeId, - judgeGroup: current.judgeGroup, - aProjectId: earlier.projectId, - bProjectId: current.projectId, - outcome: input.outcome, - createdAt: input.now, - }) - .returning({ id: comparison.id }); - if (!row) throw new Error("Comparison was not stored"); - - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "comparison.cast", - actor: { judgeId: input.judgeId }, - subject: { comparisonId: row.id }, - payload: { outcome: input.outcome }, - }); - return { id: row.id }; -} diff --git a/packages/judging-server/src/services/dispatch.concurrent.test.ts b/packages/judging-server/src/services/dispatch.concurrent.test.ts deleted file mode 100644 index e39c0584..00000000 --- a/packages/judging-server/src/services/dispatch.concurrent.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { dispatchNext } from "./dispatch"; -import { createDb, databaseUrl, migrate } from "@query/judging-db"; - -const url = databaseUrl(); - -describe.skipIf(!url)("concurrent dispatch", () => { - it("does not hand two judges the same project", async () => { - const { db, pool } = createDb(url as string); - try { - await migrate(pool); - const { seedDemo, judge, project } = await import("@query/judging-db"); - const { eq } = await import("drizzle-orm"); - const { eventId } = await seedDemo(db); - const judges = await db.select().from(judge).where(eq(judge.eventId, eventId)); - const first = judges[0]; - const second = judges[1]; - if (!first || !second) throw new Error("demo judges missing"); - const now = new Date(); - const [a, b] = await Promise.all([ - dispatchNext(db, { eventId, judgeId: first.id, now }), - dispatchNext(db, { eventId, judgeId: second.id, now }), - ]); - expect(a.done).toBe(false); - expect(b.done).toBe(false); - if (!a.done && !b.done) expect(a.projectId).not.toBe(b.projectId); - const rows = await db.select().from(project).where(eq(project.eventId, eventId)); - expect(rows.length).toBeGreaterThan(1); - } finally { - await pool.end(); - } - }); -}); diff --git a/packages/judging-server/src/services/dispatch.ts b/packages/judging-server/src/services/dispatch.ts deleted file mode 100644 index 97d21031..00000000 --- a/packages/judging-server/src/services/dispatch.ts +++ /dev/null @@ -1,455 +0,0 @@ -import { randomInt } from "node:crypto"; -import { and, desc, eq, gte, isNull, sql } from "drizzle-orm"; -import { isLive, pickNext } from "@query/judging-core"; -import type { TimerConfig } from "@query/judging-core"; -import type { PanelDb, PanelTx } from "@query/judging-db"; -import { - event, - eventConfig, - eventLog, - judge, - outbox, - project, - projectTrack, - track, - visit, - vote, -} from "@query/judging-db"; -import { isDeliveredTopic, logsForHandout } from "./logs"; - -export type DispatchOutcome = - | { done: true } - | { - done: false; - reused: boolean; - visitId: string; - projectId: string; - projectName: string; - tableNumber: number | null; - zoneId: string | null; - handedOutAt: Date; - arrivedAt: Date | null; - }; - -type VisitRow = typeof visit.$inferSelect; -type ProjectRow = typeof project.$inferSelect; - -export async function dispatchNext( - db: PanelDb, - input: { eventId: string; judgeId: string; now: Date }, -): Promise { - return db.transaction(async (tx) => { - await tx.execute( - sql`select pg_advisory_xact_lock(hashtext(${`dispatch:${input.eventId}`}))`, - ); - - const phase = await loadPhase(tx, input.eventId); - if (phase !== "judging_live") { - throw new Error(`dispatch is not allowed while the event is ${phase}`); - } - const config = await loadConfig(tx, input.eventId); - const judgeRow = await loadJudge(tx, input.judgeId, input.eventId); - if (judgeRow.status !== "approved") { - throw new Error("This judge is not approved"); - } - - const projects = await loadPool(tx, input.eventId, judgeRow.trackId); - const poolIds = new Set(projects.map((item) => item.id)); - const mine = await tx - .select() - .from(visit) - .where( - and(eq(visit.judgeId, input.judgeId), eq(visit.eventId, input.eventId)), - ); - - const lapsed = mine.filter( - (row) => - !row.completedAt && - !row.voidedAt && - (row.handedOutAt || row.arrivedAt) && - (!isLive(clock(row), input.now, config) || !poolIds.has(row.projectId)), - ); - if (lapsed.length > 0) { - for (const row of lapsed) { - await tx - .update(visit) - .set({ - voidedAt: input.now, - voidReason: poolIds.has(row.projectId) ? "lapsed" : "withdrawn", - }) - .where(eq(visit.id, row.id)); - } - } - - const open = mine.find( - (row) => - !lapsed.includes(row) && - !row.completedAt && - !row.voidedAt && - isLive(clock(row), input.now, config), - ); - if (open) { - const current = projects.find((item) => item.id === open.projectId); - return { - done: false as const, - reused: true, - visitId: open.id, - projectId: open.projectId, - projectName: current?.name ?? "", - tableNumber: current?.tableNumber ?? null, - zoneId: current?.zoneId ?? null, - handedOutAt: open.handedOutAt, - arrivedAt: open.arrivedAt, - }; - } - - const seen = new Set( - mine.filter((row) => !row.voidedAt).map((row) => row.projectId), - ); - for (const row of lapsed) seen.add(row.projectId); - const unseen = projects.filter((item) => !seen.has(item.id)); - const myGroup = await groupOf(tx, judgeRow.trackId); - if (unseen.length === 0) { - await record( - tx, - input, - lapsed.map((row) => row.id), - null, - ); - return { done: true as const }; - } - - const picked = await choose( - tx, - input, - unseen, - mine, - projects, - myGroup, - config, - ); - const choice = picked ? unseen.find((item) => item.id === picked.id) : undefined; - if (!choice) { - await record( - tx, - input, - lapsed.map((row) => row.id), - null, - ); - return { done: true as const }; - } - - const [created] = await tx - .insert(visit) - .values({ - eventId: input.eventId, - judgeId: input.judgeId, - projectId: choice.id, - judgeGroup: myGroup, - handedOutAt: input.now, - }) - .returning(); - if (!created) throw new Error("visit was not created"); - - await record(tx, input, lapsed.map((row) => row.id), { - visitId: created.id, - projectId: choice.id, - }); - - return { - done: false as const, - reused: false, - visitId: created.id, - projectId: choice.id, - projectName: choice.name, - tableNumber: choice.tableNumber, - zoneId: choice.zoneId, - handedOutAt: created.handedOutAt, - arrivedAt: null, - }; - }); -} - -async function record( - tx: PanelTx, - input: { eventId: string; judgeId: string }, - voidedVisitIds: readonly string[], - handout: { visitId: string; projectId: string } | null, -) { - const drafts = handout - ? logsForHandout(voidedVisitIds) - : voidedVisitIds.map((visitId) => ({ - kind: "visit.voided" as const, - subject: { visitId } as Record, - payload: {} as Record, - })); - if (handout) { - const handed = drafts[drafts.length - 1]; - if (handed) handed.subject = handout; - } - if (drafts.length === 0) return; - - await tx.insert(eventLog).values( - drafts.map((draft) => ({ - eventId: input.eventId, - kind: draft.kind, - actor: { judgeId: input.judgeId }, - subject: draft.subject, - payload: draft.payload, - })), - ); - const topics = drafts.filter((draft) => isDeliveredTopic(draft.kind)); - if (topics.length === 0) return; - await tx.insert(outbox).values( - topics.map((draft) => ({ - eventId: input.eventId, - topic: draft.kind, - payload: { ...draft.subject, judgeId: input.judgeId }, - })), - ); -} - -function clock(row: VisitRow) { - return { - handedOutAt: row.handedOutAt, - arrivedAt: row.arrivedAt, - completedAt: row.completedAt ?? row.voidedAt, - }; -} - -async function loadPhase(tx: PanelDb | PanelTx, eventId: string) { - const [row] = await tx - .select({ phase: event.phase }) - .from(event) - .where(eq(event.id, eventId)); - if (!row) throw new Error("Event not found"); - return row.phase; -} - -async function loadConfig(tx: PanelTx, eventId: string): Promise { - const [row] = await tx - .select() - .from(eventConfig) - .where(eq(eventConfig.eventId, eventId)); - if (!row) throw new Error("Event config not found"); - return { - targetSeconds: row.targetSeconds, - hardLimitSeconds: row.hardLimitSeconds, - walkLimitSeconds: row.walkLimitSeconds, - submitGraceSeconds: row.submitGraceSeconds, - strategy: row.dispatchStrategy, - minLooksPerProject: row.minLooksPerProject, - }; -} - -async function loadJudge(tx: PanelDb | PanelTx, judgeId: string, eventId: string) { - const [row] = await tx - .select() - .from(judge) - .where(and(eq(judge.id, judgeId), eq(judge.eventId, eventId))); - if (!row) throw new Error("Judge not found"); - return row; -} - -async function loadPool(tx: PanelDb | PanelTx, eventId: string, trackId: string | null) { - const projects = await tx - .select() - .from(project) - .where(and(eq(project.eventId, eventId), isNull(project.withdrawnAt))); - if (!trackId) return projects; - const links = await tx - .select({ projectId: projectTrack.projectId }) - .from(projectTrack) - .where(eq(projectTrack.trackId, trackId)); - const allowed = new Set(links.map((link) => link.projectId)); - return projects.filter((item) => allowed.has(item.id)); -} - -async function groupOf(tx: PanelTx, trackId: string | null) { - if (!trackId) return "main"; - const [row] = await tx - .select({ judgeGroup: track.judgeGroup }) - .from(track) - .where(eq(track.id, trackId)); - return row?.judgeGroup ?? "main"; -} - -async function choose( - tx: PanelTx, - input: { eventId: string; judgeId: string; now: Date }, - unseen: ProjectRow[], - mine: VisitRow[], - projects: ProjectRow[], - myGroup: string, - config: TimerConfig & { - strategy: "coverage" | "uncertainty"; - minLooksPerProject: number; - }, -) { - const judges = await tx - .select({ id: judge.id, trackId: judge.trackId }) - .from(judge) - .where(eq(judge.eventId, input.eventId)); - const tracks = await tx - .select({ id: track.id, judgeGroup: track.judgeGroup }) - .from(track) - .where(eq(track.eventId, input.eventId)); - const groupByJudge = new Map( - judges.map((item) => [ - item.id, - tracks.find((entry) => entry.id === item.trackId)?.judgeGroup ?? "main", - ]), - ); - - const coverage = new Map(); - const bump = (projectId: string) => - coverage.set(projectId, (coverage.get(projectId) ?? 0) + 1); - - const votes = await tx - .select({ judgeId: vote.judgeId, projectId: vote.projectId }) - .from(vote) - .where(eq(vote.eventId, input.eventId)); - for (const row of votes) { - if (groupByJudge.get(row.judgeId) === myGroup) bump(row.projectId); - } - - const open = await tx - .select() - .from(visit) - .where(and(eq(visit.eventId, input.eventId), isNull(visit.completedAt), isNull(visit.voidedAt))); - const claimedAt = new Map(); - for (const row of open) { - if (row.judgeId === input.judgeId) continue; - if (!isLive(clock(row), input.now, config)) continue; - if (groupByJudge.get(row.judgeId) !== myGroup) continue; - bump(row.projectId); - const at = (row.arrivedAt ?? row.handedOutAt).getTime(); - claimedAt.set(row.projectId, Math.max(claimedAt.get(row.projectId) ?? 0, at)); - } - - const byId = new Map(projects.map((item) => [item.id, item])); - const last = [...mine].sort( - (a, b) => b.handedOutAt.getTime() - a.handedOutAt.getTime(), - )[0]; - const lastProject = last ? byId.get(last.projectId) : undefined; - - return pickNext( - unseen.map((item) => ({ - id: item.id, - tableNumber: item.tableNumber ?? 0, - zoneId: item.zoneId, - coverage: coverage.get(item.id) ?? 0, - claimedAt: claimedAt.get(item.id) ?? null, - })), - { - lastTable: lastProject?.tableNumber ?? null, - zoneId: lastProject?.zoneId ?? null, - }, - { - strategy: config.strategy, - minLooksPerProject: config.minLooksPerProject, - }, - (n) => randomInt(0, n), - ); -} - -/** Pass on the open table without a score. It stays seen, so this judge is not sent back. */ -export async function skipVisit( - db: PanelDb, - input: { eventId: string; judgeId: string; visitId: string; now: Date }, -) { - return db.transaction(async (tx) => { - const phase = await loadPhase(tx, input.eventId); - if (phase !== "judging_live") { - throw new Error(`skip is not allowed while the event is ${phase}`); - } - const [row] = await tx - .select() - .from(visit) - .where( - and( - eq(visit.id, input.visitId), - eq(visit.judgeId, input.judgeId), - eq(visit.eventId, input.eventId), - ), - ); - if (!row || row.voidedAt || row.completedAt) { - throw new Error("This visit cannot be skipped"); - } - await tx.update(visit).set({ completedAt: input.now }).where(eq(visit.id, row.id)); - await tx.insert(eventLog).values({ - eventId: input.eventId, - kind: "visit.skipped", - actor: { judgeId: input.judgeId }, - subject: { visitId: row.id, projectId: row.projectId }, - payload: {}, - }); - return { projectId: row.projectId }; - }); -} - -/** - * Whether the judge's open visit still stands: an organizer may have voided - * it, or called the judge back to the desk since it was handed out. The - * recall lookup reads only log rows written since the hand-out - * (event_log_event_idx), so a phone can ask while it walks. - */ -export async function visitStatus( - db: PanelDb, - input: { eventId: string; judgeId: string; visitId: string }, -) { - const [row] = await db - .select({ handedOutAt: visit.handedOutAt, voidedAt: visit.voidedAt }) - .from(visit) - .where( - and( - eq(visit.id, input.visitId), - eq(visit.eventId, input.eventId), - eq(visit.judgeId, input.judgeId), - ), - ); - if (!row) throw new Error("Visit not found"); - const [recall] = await db - .select({ at: eventLog.createdAt }) - .from(eventLog) - .where( - and( - eq(eventLog.eventId, input.eventId), - gte(eventLog.createdAt, row.handedOutAt), - eq(eventLog.kind, "judge.recalled"), - sql`${eventLog.subject}->>'judgeId' = ${input.judgeId}`, - ), - ) - .orderBy(desc(eventLog.createdAt)) - .limit(1); - // A recall does not void the visit, so the same visit can come back from - // dispatch. The time lets the phone act once per recall, not once per poll. - return { - voided: row.voidedAt !== null, - recalledAt: recall ? recall.at.toISOString() : null, - }; -} - -export async function judgeProgress( - db: PanelDb, - input: { eventId: string; judgeId: string }, -) { - const person = await loadJudge(db, input.judgeId, input.eventId); - if (person.status !== "approved") throw new Error("This judge is not approved"); - const pool = await loadPool(db, input.eventId, person.trackId); - const [scored] = await db - .select({ n: sql`count(*)::int` }) - .from(vote) - .where(and(eq(vote.judgeId, input.judgeId), eq(vote.eventId, input.eventId))); - const total = pool.length; - const completed = scored?.n ?? 0; - return { - judgeId: input.judgeId, - total, - completed, - percentage: total > 0 ? Math.round((completed / total) * 100) : 0, - }; -} diff --git a/packages/judging-server/src/services/event.integration.test.ts b/packages/judging-server/src/services/event.integration.test.ts deleted file mode 100644 index 46c2aea4..00000000 --- a/packages/judging-server/src/services/event.integration.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { and, eq } from "drizzle-orm"; -import { describe, expect, it } from "vitest"; -import { createDb, criterion, databaseUrl, event, migrate, rubric } from "@query/judging-db"; -import { ensureEvent } from "./event"; - -const url = databaseUrl(); - -describe.skipIf(!url)("ensureEvent", () => { - it("creates an edition's event once, with the default rubric", async () => { - const { db, pool } = createDb(url as string); - try { - await migrate(pool); - const input = { - orgSlug: "test-org", - orgName: "Test", - eventSlug: randomUUID(), - name: "Test edition", - }; - const first = await ensureEvent(db, input); - const again = await ensureEvent(db, input); - expect(again.eventId).toBe(first.eventId); - - const [row] = await db.select().from(event).where(eq(event.id, first.eventId)); - expect(row?.phase).toBe("setup"); - const criteria = await db - .select({ key: criterion.key }) - .from(criterion) - .innerJoin(rubric, eq(rubric.id, criterion.rubricId)) - .where(and(eq(rubric.eventId, first.eventId), eq(rubric.isDefault, true))); - expect(criteria.map((item) => item.key).sort()).toEqual( - ["clarity", "creativity", "impact", "scope", "soundness"], - ); - } finally { - await pool.end(); - } - }); -}); diff --git a/packages/judging-server/src/services/event.ts b/packages/judging-server/src/services/event.ts deleted file mode 100644 index 30fae295..00000000 --- a/packages/judging-server/src/services/event.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { and, eq, sql } from "drizzle-orm"; -import { DEFAULT_TIMERS } from "@query/judging-core"; -import type { PanelDb } from "@query/judging-db"; -import { criterion, event, eventConfig, organization, rubric } from "@query/judging-db"; - -/** The five scores the club's judges have always given, each out of ten. */ -const DEFAULT_CRITERIA = [ - ["creativity", "Creativity & Originality"], - ["impact", "Impact & Relevance"], - ["scope", "Scope & Technical Depth"], - ["clarity", "Clarity & Engagement"], - ["soundness", "Soundness & Accuracy"], -] as const; - -/** - * The event for one hackathon edition, created the first time it is asked - * for: organization, event in setup, default config, and the default rubric. - * Safe to call on every sync; an existing event is returned untouched. - */ -export async function ensureEvent( - db: PanelDb, - input: { orgSlug: string; orgName: string; eventSlug: string; name: string }, -): Promise<{ eventId: string }> { - return db.transaction(async (tx) => { - await tx.execute( - sql`select pg_advisory_xact_lock(hashtext(${`panel:event:${input.orgSlug}:${input.eventSlug}`}))`, - ); - await tx - .insert(organization) - .values({ slug: input.orgSlug, name: input.orgName }) - .onConflictDoNothing({ target: organization.slug }); - const [org] = await tx - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.slug, input.orgSlug)); - if (!org) throw new Error("Organization was not stored"); - - const [existing] = await tx - .select({ id: event.id }) - .from(event) - .where(and(eq(event.orgId, org.id), eq(event.slug, input.eventSlug))); - if (existing) return { eventId: existing.id }; - - const [created] = await tx - .insert(event) - .values({ orgId: org.id, slug: input.eventSlug, name: input.name, phase: "setup" }) - .returning({ id: event.id }); - if (!created) throw new Error("Event was not stored"); - - await tx.insert(eventConfig).values({ - eventId: created.id, - targetSeconds: DEFAULT_TIMERS.targetSeconds, - hardLimitSeconds: DEFAULT_TIMERS.hardLimitSeconds, - walkLimitSeconds: DEFAULT_TIMERS.walkLimitSeconds, - submitGraceSeconds: DEFAULT_TIMERS.submitGraceSeconds, - minLooksPerProject: 1, - dispatchStrategy: "coverage", - }); - - const [main] = await tx - .insert(rubric) - .values({ eventId: created.id, name: "Main", isDefault: true }) - .returning({ id: rubric.id }); - if (!main) throw new Error("Rubric was not stored"); - await tx.insert(criterion).values( - DEFAULT_CRITERIA.map(([key, label], position) => ({ - rubricId: main.id, - position, - key, - label, - min: 1, - max: 10, - weight: 1, - })), - ); - return { eventId: created.id }; - }); -} diff --git a/packages/judging-server/src/services/feedback.test.ts b/packages/judging-server/src/services/feedback.test.ts deleted file mode 100644 index 6f897277..00000000 --- a/packages/judging-server/src/services/feedback.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { feedbackStatus, median } from "./feedback"; - -describe("feedbackStatus", () => { - it("refuses before publish and for the wrong token", () => { - expect(feedbackStatus({ published: false, tokenMatches: true })).toBe("forbidden"); - expect(feedbackStatus({ published: true, tokenMatches: false })).toBe("forbidden"); - }); - - it("allows the project's token after publish", () => { - expect(feedbackStatus({ published: true, tokenMatches: true })).toBe("ok"); - }); - - it("puts the event median between the middle scores", () => { - expect(median([1, 3, 9])).toBe(3); - expect(median([1, 2, 8, 9])).toBe(5); - }); -}); diff --git a/packages/judging-server/src/services/feedback.ts b/packages/judging-server/src/services/feedback.ts deleted file mode 100644 index d12107fc..00000000 --- a/packages/judging-server/src/services/feedback.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function feedbackStatus(input: { - published: boolean; - tokenMatches: boolean; -}): "ok" | "forbidden" { - if (!input.published || !input.tokenMatches) return "forbidden"; - return "ok"; -} - -export function median(values: readonly number[]): number { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - const upper = sorted[mid] ?? 0; - if (sorted.length % 2 === 1) return upper; - const lower = sorted[mid - 1] ?? upper; - return (lower + upper) / 2; -} diff --git a/packages/judging-server/src/services/floor.test.ts b/packages/judging-server/src/services/floor.test.ts deleted file mode 100644 index aabba96b..00000000 --- a/packages/judging-server/src/services/floor.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { DEFAULT_TIMERS } from "@query/judging-core"; -import { judgeFloorState } from "./floor"; - -const arrived = new Date("2027-02-28T13:00:00Z"); - -describe("judgeFloorState", () => { - it("marks a judge with no table as idle", () => { - expect(judgeFloorState(null, arrived, DEFAULT_TIMERS)).toEqual({ - state: "idle", - overtime: false, - }); - }); - - it("marks a judge who has not arrived as walking", () => { - expect(judgeFloorState({ arrivedAt: null }, arrived, DEFAULT_TIMERS).state).toBe("walking"); - }); - - it("marks overtime only after the target time at the table", () => { - const early = judgeFloorState({ arrivedAt: arrived }, new Date(arrived.getTime() + 1000), DEFAULT_TIMERS); - const late = judgeFloorState( - { arrivedAt: arrived }, - new Date(arrived.getTime() + DEFAULT_TIMERS.targetSeconds * 1000 + 1000), - DEFAULT_TIMERS, - ); - expect(early).toEqual({ state: "at table", overtime: false }); - expect(late).toEqual({ state: "at table", overtime: true }); - }); -}); diff --git a/packages/judging-server/src/services/floor.ts b/packages/judging-server/src/services/floor.ts deleted file mode 100644 index 5dfe8c0a..00000000 --- a/packages/judging-server/src/services/floor.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { and, eq, isNull, sql } from "drizzle-orm"; -import { isOverTarget } from "@query/judging-core"; -import type { TimerConfig } from "@query/judging-core"; -import type { PanelDb } from "@query/judging-db"; -import { eventConfig, judge, project, visit, vote } from "@query/judging-db"; - -export type FloorState = "idle" | "walking" | "at table"; - -export function judgeFloorState( - open: { arrivedAt: Date | null } | null, - now: Date, - config: TimerConfig, -): { state: FloorState; overtime: boolean } { - if (!open) return { state: "idle", overtime: false }; - if (!open.arrivedAt) return { state: "walking", overtime: false }; - return { - state: "at table", - overtime: isOverTarget(open.arrivedAt, now, config), - }; -} - -export async function organizerFloor(db: PanelDb, eventId: string, now: Date) { - const [config] = await db - .select({ - targetSeconds: eventConfig.targetSeconds, - hardLimitSeconds: eventConfig.hardLimitSeconds, - walkLimitSeconds: eventConfig.walkLimitSeconds, - submitGraceSeconds: eventConfig.submitGraceSeconds, - }) - .from(eventConfig) - .where(eq(eventConfig.eventId, eventId)); - if (!config) return null; - - const projects = await db - .select({ id: project.id, tableNumber: project.tableNumber }) - .from(project) - .where(and(eq(project.eventId, eventId), isNull(project.withdrawnAt))); - const looks = await db - .select({ projectId: vote.projectId, count: sql`count(*)::int` }) - .from(vote) - .where(eq(vote.eventId, eventId)) - .groupBy(vote.projectId); - const lookOf = new Map(looks.map((item) => [item.projectId, item.count])); - - const open = await db - .select({ - judgeId: visit.judgeId, - arrivedAt: visit.arrivedAt, - tableNumber: project.tableNumber, - }) - .from(visit) - .innerJoin(project, eq(project.id, visit.projectId)) - .where(and(eq(visit.eventId, eventId), isNull(visit.completedAt), isNull(visit.voidedAt))); - const openOf = new Map(open.map((item) => [item.judgeId, item])); - - const people = await db - .select({ id: judge.id, name: judge.name }) - .from(judge) - .where(and(eq(judge.eventId, eventId), eq(judge.status, "approved"))); - - return { - tables: projects - .map((item) => ({ - tableNumber: item.tableNumber, - looks: lookOf.get(item.id) ?? 0, - })) - .sort((a, b) => (a.tableNumber ?? 0) - (b.tableNumber ?? 0)), - judges: people.map((person) => { - const current = openOf.get(person.id) ?? null; - const state = judgeFloorState(current, now, config); - return { - name: person.name, - state: state.state, - overtime: state.overtime, - tableNumber: current?.tableNumber ?? null, - }; - }), - }; -} diff --git a/packages/judging-server/src/services/flow.integration.test.ts b/packages/judging-server/src/services/flow.integration.test.ts deleted file mode 100644 index 58156201..00000000 --- a/packages/judging-server/src/services/flow.integration.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import { describe, expect, it } from "vitest"; -import { - createDb, - criterion, - databaseUrl, - event, - eventLog, - migrate, - project, - rubric, - seedDemo, -} from "@query/judging-db"; -import { arrive } from "./arrive"; -import { dispatchNext } from "./dispatch"; -import { feedbackCard } from "./catalog"; -import { computeResults, publishResults } from "./results"; -import { castVote } from "./vote"; - -const url = databaseUrl(); - -describe.skipIf(!url)("a full visit", () => { - it("scores a table, publishes, and opens that project's feedback", async () => { - const { db, pool } = createDb(url as string); - try { - await migrate(pool); - const { eventId } = await seedDemo(db); - const { judge } = await import("@query/judging-db"); - const [person] = await db.select().from(judge).where(eq(judge.eventId, eventId)); - if (!person) throw new Error("demo judge missing"); - const now = new Date("2027-02-28T13:00:00Z"); - - const handed = await dispatchNext(db, { eventId, judgeId: person.id, now }); - expect(handed.done).toBe(false); - if (handed.done) return; - - await arrive(db, { - eventId, - judgeId: person.id, - tableNumber: handed.tableNumber ?? undefined, - now: new Date(now.getTime() + 30_000), - }); - - const [rub] = await db - .select({ id: rubric.id }) - .from(rubric) - .where(and(eq(rubric.eventId, eventId), eq(rubric.isDefault, true))); - if (!rub) throw new Error("demo rubric missing"); - const criteria = await db - .select() - .from(criterion) - .where(eq(criterion.rubricId, rub.id)); - - await castVote(db, { - eventId, - judgeId: person.id, - visitId: handed.visitId, - scores: criteria.map((item) => ({ criterionId: item.id, value: 8 })), - comment: null, - now: new Date(now.getTime() + 60_000), - }); - - await db.update(event).set({ phase: "judging_closed" }).where(eq(event.id, eventId)); - const run = await computeResults(db, { - eventId, - actorEmail: "organizer@demo.panel", - now: new Date(now.getTime() + 120_000), - }); - await publishResults(db, { - eventId, - runId: run.runId, - actorEmail: "organizer@demo.panel", - now: new Date(now.getTime() + 130_000), - }); - - const [scored] = await db - .select({ token: project.feedbackToken }) - .from(project) - .where(eq(project.id, handed.projectId)); - if (!scored?.token) throw new Error("feedback token was not minted"); - - const card = await feedbackCard(db, scored.token); - expect(card.status).toBe("ok"); - if (card.status === "ok") { - expect(card.criteria.length).toBeGreaterThan(0); - expect(card.criteria[0]?.mean).toBe(8); - } - expect((await feedbackCard(db, "00000000-0000-0000-0000-000000000000")).status).toBe( - "forbidden", - ); - - const kinds = [ - "visit.handed_out", - "visit.arrived", - "vote.cast", - "results.computed", - "results.published", - ] as const; - for (const kind of kinds) { - const rows = await db - .select({ id: eventLog.id }) - .from(eventLog) - .where(and(eq(eventLog.eventId, eventId), eq(eventLog.kind, kind))); - expect(rows, kind).toHaveLength(1); - } - } finally { - await pool.end(); - } - }); -}); diff --git a/packages/judging-server/src/services/live.ts b/packages/judging-server/src/services/live.ts deleted file mode 100644 index 3e75fbe2..00000000 --- a/packages/judging-server/src/services/live.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"; -import type { PanelDb } from "@query/judging-db"; -import { event, eventConfig, judge, organization, prize, project, result, resultRun, track, visit, vote } from "@query/judging-db"; - -export async function liveSnapshot(db: PanelDb, orgSlug: string, eventSlug: string) { - const [row] = await db - .select({ - eventId: event.id, - phase: event.phase, - name: event.name, - leaderboardPublic: eventConfig.leaderboardPublic, - pollSeconds: eventConfig.boardPollSeconds, - }) - .from(event) - .innerJoin(organization, eq(organization.id, event.orgId)) - .innerJoin(eventConfig, eq(eventConfig.eventId, event.id)) - .where(and(eq(organization.slug, orgSlug), eq(event.slug, eventSlug))); - if (!row) return null; - - const prizes = await db - .select({ - title: prize.title, - place: prize.place, - amount: prize.amount, - track: track.name, - }) - .from(prize) - .innerJoin(track, eq(track.id, prize.trackId)) - .where(eq(track.eventId, row.eventId)) - .orderBy(track.position, prize.place); - - const published = row.phase === "published"; - const showFloor = published || row.leaderboardPublic; - if (!showFloor) { - return { - eventId: row.eventId, - name: row.name, - phase: row.phase, - pollSeconds: row.pollSeconds, - projects: [], - placements: [], - prizes, - }; - } - - const projects = await db - .select({ - id: project.id, - name: project.name, - tableNumber: project.tableNumber, - }) - .from(project) - .where(and(eq(project.eventId, row.eventId), isNull(project.withdrawnAt))); - - const looks = await db - .select({ - projectId: vote.projectId, - count: sql`count(*)::int`, - }) - .from(vote) - .where(eq(vote.eventId, row.eventId)) - .groupBy(vote.projectId); - const lookOf = new Map(looks.map((item) => [item.projectId, item.count])); - - const open = await db - .select({ judgeId: visit.judgeId, projectId: visit.projectId, arrivedAt: visit.arrivedAt }) - .from(visit) - .where( - and(eq(visit.eventId, row.eventId), isNull(visit.completedAt), isNull(visit.voidedAt)), - ); - const judges = await db - .select({ id: judge.id, name: judge.name }) - .from(judge) - .where(eq(judge.eventId, row.eventId)); - const nameOf = new Map(judges.map((item) => [item.id, item.name])); - - let placements: { - projectId: string; - projectName: string; - trackId: string; - placement: number | null; - }[] = []; - if (published) { - const [run] = await db - .select({ id: resultRun.id }) - .from(resultRun) - .where(and(eq(resultRun.eventId, row.eventId), isNotNull(resultRun.publishedAt))); - if (run) { - placements = await db - .select({ - projectId: result.projectId, - projectName: project.name, - trackId: result.trackId, - placement: result.placement, - }) - .from(result) - .innerJoin(project, eq(project.id, result.projectId)) - .where(eq(result.runId, run.id)); - } - } - - return { - eventId: row.eventId, - name: row.name, - phase: row.phase, - pollSeconds: row.pollSeconds, - projects: projects.map((item) => ({ - id: item.id, - name: published ? item.name : null, - tableNumber: item.tableNumber, - looks: lookOf.get(item.id) ?? 0, - })), - floor: open.map((item) => ({ - judgeId: item.judgeId, - judgeName: nameOf.get(item.judgeId) ?? "", - projectId: item.projectId, - arrived: item.arrivedAt !== null, - })), - placements, - prizes, - }; -} diff --git a/packages/judging-server/src/services/logs.test.ts b/packages/judging-server/src/services/logs.test.ts deleted file mode 100644 index 2be074f6..00000000 --- a/packages/judging-server/src/services/logs.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { logsForHandout } from "./logs"; - -describe("logsForHandout", () => { - it("writes exactly one row when nothing has lapsed", () => { - expect(logsForHandout([]).map((row) => row.kind)).toEqual(["visit.handed_out"]); - }); - - it("writes one void row per lapsed visit, then the hand-out", () => { - expect(logsForHandout(["a", "b"]).map((row) => row.kind)).toEqual([ - "visit.voided", - "visit.voided", - "visit.handed_out", - ]); - }); -}); diff --git a/packages/judging-server/src/services/logs.ts b/packages/judging-server/src/services/logs.ts deleted file mode 100644 index b7d54c73..00000000 --- a/packages/judging-server/src/services/logs.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { isOutboxTopic } from "@query/judging-core"; -import type { LogKind } from "@query/judging-core"; - -export type LogDraft = { - kind: LogKind; - subject: Record; - payload: Record; -}; - -/** One log row per void, then one for the hand-out. A reload writes none. */ -export function logsForHandout(voidedVisitIds: readonly string[]): LogDraft[] { - return [ - ...voidedVisitIds.map((visitId) => ({ - kind: "visit.voided" as const, - subject: { visitId }, - payload: {}, - })), - { - kind: "visit.handed_out" as const, - subject: {}, - payload: {}, - }, - ]; -} - -export function isDeliveredTopic(kind: LogKind): boolean { - return isOutboxTopic(kind); -} diff --git a/packages/judging-server/src/services/outbox.ts b/packages/judging-server/src/services/outbox.ts deleted file mode 100644 index 1c43acc6..00000000 --- a/packages/judging-server/src/services/outbox.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createHmac } from "node:crypto"; -import { eq, isNull } from "drizzle-orm"; -import type { PanelDb } from "@query/judging-db"; -import { outbox, webhook } from "@query/judging-db"; - -/** - * Delivers pending outbox rows. A topic with no active webhook is delivered. - * A delivered row is deleted: event_log already keeps the history, and on a - * 0.5 GB database a second copy of every vote is not worth keeping. - */ -export async function drainOutbox(db: PanelDb): Promise { - const pending = await db - .select() - .from(outbox) - .where(isNull(outbox.deliveredAt)) - .limit(50); - if (pending.length === 0) return 0; - - const hooks = await db.select().from(webhook).where(eq(webhook.isActive, true)); - let delivered = 0; - - for (const row of pending) { - const targets = hooks.filter((hook) => hook.topics?.includes(row.topic)); - let ok = true; - for (const hook of targets) { - const body = JSON.stringify(row.payload); - const signature = createHmac("sha256", hook.secret).update(body).digest("hex"); - try { - const response = await fetch(hook.url, { - method: "POST", - headers: { - "content-type": "application/json", - "x-panel-signature": signature, - }, - body, - }); - if (!response.ok) ok = false; - } catch { - ok = false; - } - } - if (!ok) { - await db - .update(outbox) - .set({ attempts: row.attempts + 1 }) - .where(eq(outbox.id, row.id)); - continue; - } - await db.delete(outbox).where(eq(outbox.id, row.id)); - delivered += 1; - } - return delivered; -} diff --git a/packages/judging-server/src/services/overtime.test.ts b/packages/judging-server/src/services/overtime.test.ts deleted file mode 100644 index 35fdb143..00000000 --- a/packages/judging-server/src/services/overtime.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { DEFAULT_TIMERS } from "@query/judging-core"; -import { visitsOverTarget } from "./overtime"; - -const arrived = new Date("2027-02-28T13:00:00Z"); - -describe("visitsOverTarget", () => { - it("includes a judge who has stayed past the target", () => { - const rows = visitsOverTarget( - [ - { - visitId: "late", - eventId: "event", - judgeId: "judge", - arrivedAt: arrived, - config: DEFAULT_TIMERS, - }, - { - visitId: "early", - eventId: "event", - judgeId: "other", - arrivedAt: new Date(arrived.getTime() + 10_000), - config: DEFAULT_TIMERS, - }, - ], - new Date(arrived.getTime() + DEFAULT_TIMERS.targetSeconds * 1000 + 1000), - ); - expect(rows.map((row) => row.visitId)).toEqual(["late"]); - }); -}); diff --git a/packages/judging-server/src/services/overtime.ts b/packages/judging-server/src/services/overtime.ts deleted file mode 100644 index db7fc2a0..00000000 --- a/packages/judging-server/src/services/overtime.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { and, eq, isNotNull, isNull } from "drizzle-orm"; -import { isOverTarget } from "@query/judging-core"; -import type { TimerConfig } from "@query/judging-core"; -import type { PanelDb } from "@query/judging-db"; -import { eventConfig, visit } from "@query/judging-db"; - -export type OpenVisit = { - visitId: string; - eventId: string; - judgeId: string; - arrivedAt: Date | null; - config: TimerConfig; -}; - -/** Visits where the judge has arrived and is past the target time. */ -export function visitsOverTarget(rows: readonly OpenVisit[], now: Date): OpenVisit[] { - return rows.filter( - (row) => row.arrivedAt !== null && isOverTarget(row.arrivedAt, now, row.config), - ); -} - -export async function loadOpenVisits(db: PanelDb): Promise { - const rows = await db - .select({ - visitId: visit.id, - eventId: visit.eventId, - judgeId: visit.judgeId, - arrivedAt: visit.arrivedAt, - targetSeconds: eventConfig.targetSeconds, - hardLimitSeconds: eventConfig.hardLimitSeconds, - walkLimitSeconds: eventConfig.walkLimitSeconds, - submitGraceSeconds: eventConfig.submitGraceSeconds, - }) - .from(visit) - .innerJoin(eventConfig, eq(eventConfig.eventId, visit.eventId)) - .where(and(isNull(visit.completedAt), isNull(visit.voidedAt), isNotNull(visit.arrivedAt))); - return rows.map((row) => ({ - visitId: row.visitId, - eventId: row.eventId, - judgeId: row.judgeId, - arrivedAt: row.arrivedAt, - config: { - targetSeconds: row.targetSeconds, - hardLimitSeconds: row.hardLimitSeconds, - walkLimitSeconds: row.walkLimitSeconds, - submitGraceSeconds: row.submitGraceSeconds, - }, - })); -} diff --git a/packages/judging-server/src/services/records.ts b/packages/judging-server/src/services/records.ts deleted file mode 100644 index 7000de5b..00000000 --- a/packages/judging-server/src/services/records.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { and, eq, isNull } from "drizzle-orm"; -import { assignTables, phaseAllows } from "@query/judging-core"; -import type { PanelDb } from "@query/judging-db"; -import { event, eventLog, floorTable, judge, outbox, project, projectTrack, visit } from "@query/judging-db"; - -async function phaseOf(db: PanelDb, eventId: string) { - const [row] = await db - .select({ phase: event.phase }) - .from(event) - .where(eq(event.id, eventId)); - if (!row) throw new Error("Event not found"); - return row.phase; -} - -export async function upsertProject( - db: PanelDb, - input: { - eventId: string; - externalId: string; - name: string; - teamName: string | null; - tableNumber: number | null; - actorEmail: string; - }, -) { - const phase = await phaseOf(db, input.eventId); - if (phase === "archived" || phase === "published") { - throw new Error(`submit is not allowed while the event is ${phase}`); - } - const [existing] = await db - .select({ id: project.id }) - .from(project) - .where(and(eq(project.eventId, input.eventId), eq(project.externalId, input.externalId))); - - if (existing) { - await db - .update(project) - .set({ - name: input.name, - teamName: input.teamName, - tableNumber: input.tableNumber, - }) - .where(eq(project.id, existing.id)); - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "project.upserted", - actor: { email: input.actorEmail }, - subject: { projectId: existing.id }, - payload: { externalId: input.externalId }, - }); - return { id: existing.id }; - } - - const [created] = await db - .insert(project) - .values({ - eventId: input.eventId, - externalId: input.externalId, - name: input.name, - teamName: input.teamName, - tableNumber: input.tableNumber, - }) - .returning({ id: project.id }); - if (!created) throw new Error("Project was not stored"); - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "project.upserted", - actor: { email: input.actorEmail }, - subject: { projectId: created.id }, - payload: { externalId: input.externalId }, - }); - return { id: created.id }; -} - -export async function withdrawProject( - db: PanelDb, - input: { eventId: string; projectId: string; now: Date; actorEmail: string }, -) { - const phase = await phaseOf(db, input.eventId); - if (!phaseAllows(phase, "submit") && phase !== "judging_live" && phase !== "submissions_closed") { - throw new Error(`submit is not allowed while the event is ${phase}`); - } - await db - .update(project) - .set({ withdrawnAt: input.now }) - .where(and(eq(project.id, input.projectId), eq(project.eventId, input.eventId))); - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "project.withdrawn", - actor: { email: input.actorEmail }, - subject: { projectId: input.projectId }, - payload: {}, - }); -} - -export async function upsertJudge( - db: PanelDb, - input: { - eventId: string; - email: string; - name: string; - externalId: string | null; - actorEmail: string; - }, -) { - const phase = await phaseOf(db, input.eventId); - if (!phaseAllows(phase, "apply") && phase !== "judging_live") { - throw new Error(`apply is not allowed while the event is ${phase}`); - } - const email = input.email.toLowerCase(); - const [existing] = await db - .select({ id: judge.id }) - .from(judge) - .where(and(eq(judge.eventId, input.eventId), eq(judge.email, email))); - if (existing) { - await db - .update(judge) - .set({ name: input.name, externalId: input.externalId }) - .where(eq(judge.id, existing.id)); - return { id: existing.id }; - } - const [created] = await db - .insert(judge) - .values({ - eventId: input.eventId, - email, - name: input.name, - externalId: input.externalId, - status: "invited", - }) - .returning({ id: judge.id }); - if (!created) throw new Error("Judge was not stored"); - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "judge.upserted", - actor: { email: input.actorEmail }, - subject: { judgeId: created.id }, - payload: {}, - }); - return { id: created.id }; -} - -export async function applyAsJudge( - db: PanelDb, - input: { eventId: string; email: string; name: string }, -) { - const phase = await phaseOf(db, input.eventId); - if (!phaseAllows(phase, "apply")) { - throw new Error(`apply is not allowed while the event is ${phase}`); - } - const email = input.email.toLowerCase(); - const [existing] = await db - .select({ id: judge.id, status: judge.status }) - .from(judge) - .where(and(eq(judge.eventId, input.eventId), eq(judge.email, email))); - if (existing) { - if (existing.status === "invited") { - await db.update(judge).set({ status: "applied", name: input.name }).where(eq(judge.id, existing.id)); - } - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "judge.upserted", - actor: { email }, - subject: { judgeId: existing.id }, - payload: { status: existing.status === "invited" ? "applied" : existing.status }, - }); - return { id: existing.id, status: existing.status === "invited" ? "applied" : existing.status }; - } - const [created] = await db - .insert(judge) - .values({ - eventId: input.eventId, - email, - name: input.name, - status: "applied", - }) - .returning({ id: judge.id }); - if (!created) throw new Error("Judge was not stored"); - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "judge.upserted", - actor: { email }, - subject: { judgeId: created.id }, - payload: { status: "applied" }, - }); - return { id: created.id, status: "applied" as const }; -} - -export async function setJudgeStatus( - db: PanelDb, - input: { - eventId: string; - judgeId: string; - status: "approved" | "suspended"; - actorEmail: string; - }, -) { - await db - .update(judge) - .set({ status: input.status }) - .where(and(eq(judge.id, input.judgeId), eq(judge.eventId, input.eventId))); - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: input.status === "approved" ? "judge.approved" : "judge.suspended", - actor: { email: input.actorEmail }, - subject: { judgeId: input.judgeId }, - payload: { status: input.status }, - }); -} - -export async function voidVisit( - db: PanelDb, - input: { eventId: string; visitId: string; now: Date; actorEmail: string; reason: string }, -) { - return db.transaction(async (tx) => { - const [row] = await tx - .select({ id: visit.id, judgeId: visit.judgeId }) - .from(visit) - .where(and(eq(visit.id, input.visitId), eq(visit.eventId, input.eventId), isNull(visit.voidedAt))); - if (!row) throw new Error("Visit not found"); - await tx - .update(visit) - .set({ voidedAt: input.now, voidReason: input.reason }) - .where(eq(visit.id, row.id)); - await tx.insert(eventLog).values({ - eventId: input.eventId, - kind: "visit.voided", - actor: { email: input.actorEmail }, - subject: { visitId: row.id }, - payload: { reason: input.reason }, - }); - await tx.insert(outbox).values({ - eventId: input.eventId, - topic: "visit.voided", - payload: { visitId: row.id, judgeId: row.judgeId }, - }); - return { judgeId: row.judgeId }; - }); -} - -export async function recallJudge( - db: PanelDb, - input: { eventId: string; judgeId: string; actorEmail: string }, -) { - await db.transaction(async (tx) => { - await tx.insert(eventLog).values({ - eventId: input.eventId, - kind: "judge.recalled", - actor: { email: input.actorEmail }, - subject: { judgeId: input.judgeId }, - payload: {}, - }); - await tx.insert(outbox).values({ - eventId: input.eventId, - topic: "judge.recalled", - payload: { judgeId: input.judgeId }, - }); - }); -} - -export async function assignJudge( - db: PanelDb, - input: { - eventId: string; - judgeId: string; - trackId: string | null; - zoneId: string | null; - actorEmail: string; - }, -) { - const [row] = await db - .update(judge) - .set({ trackId: input.trackId, zoneId: input.zoneId }) - .where(and(eq(judge.id, input.judgeId), eq(judge.eventId, input.eventId))) - .returning({ id: judge.id }); - if (!row) throw new Error("Judge not found"); - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "judge.upserted", - actor: { email: input.actorEmail }, - subject: { judgeId: input.judgeId }, - payload: { trackId: input.trackId, zoneId: input.zoneId }, - }); - return { id: row.id }; -} - -export async function assignProjectTrack( - db: PanelDb, - input: { projectId: string; trackId: string }, -) { - await db - .insert(projectTrack) - .values(input) - .onConflictDoNothing(); -} - -export async function rotateQr( - db: PanelDb, - input: { eventId: string; projectId: string; actorEmail: string }, -) { - const token = randomUUID(); - const [row] = await db - .update(project) - .set({ qrToken: token }) - .where(and(eq(project.id, input.projectId), eq(project.eventId, input.eventId))) - .returning({ qrToken: project.qrToken }); - if (!row) throw new Error("Project not found"); - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "project.qr_rotated", - actor: { email: input.actorEmail }, - subject: { projectId: input.projectId }, - payload: {}, - }); - return { qrToken: row.qrToken }; -} - -export async function assignEventTables( - db: PanelDb, - input: { eventId: string; actorEmail: string }, -) { - const projects = await db - .select({ id: project.id }) - .from(project) - .where(and(eq(project.eventId, input.eventId), isNull(project.withdrawnAt))); - const links = await db - .select({ projectId: projectTrack.projectId, trackId: projectTrack.trackId }) - .from(projectTrack) - .innerJoin(project, eq(project.id, projectTrack.projectId)) - .where(eq(project.eventId, input.eventId)); - const trackOf = new Map(); - for (const link of links) { - if (!trackOf.has(link.projectId)) trackOf.set(link.projectId, link.trackId); - } - const tables = await db - .select({ number: floorTable.number, zoneId: floorTable.zoneId }) - .from(floorTable) - .where(eq(floorTable.eventId, input.eventId)); - const seated = assignTables( - projects.map((row) => ({ id: row.id, trackId: trackOf.get(row.id) ?? null })), - tables.map((row) => ({ number: row.number, zoneId: row.zoneId })), - ); - for (const seat of seated) { - await db - .update(project) - .set({ tableNumber: seat.tableNumber, zoneId: seat.zoneId }) - .where(eq(project.id, seat.projectId)); - } - await db.insert(eventLog).values({ - eventId: input.eventId, - kind: "project.table_assigned", - actor: { email: input.actorEmail }, - subject: { eventId: input.eventId }, - payload: { assigned: seated.length }, - }); - return { assigned: seated.length }; -} diff --git a/packages/judging-server/src/services/redis.integration.test.ts b/packages/judging-server/src/services/redis.integration.test.ts deleted file mode 100644 index 561545b7..00000000 --- a/packages/judging-server/src/services/redis.integration.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Redis } from "ioredis"; -import { describe, expect, it } from "vitest"; -import { createRedisBus } from "../bus"; - -const url = process.env.REDIS_URL; - -async function redisAnswers(address: string): Promise { - const client = new Redis(address, { - maxRetriesPerRequest: 0, - connectTimeout: 500, - lazyConnect: true, - retryStrategy: () => null, - }); - client.on("error", () => undefined); - try { - await client.connect(); - return (await client.ping()) === "PONG"; - } catch { - return false; - } finally { - client.disconnect(); - } -} - -describe.skipIf(!url)("redis bus", () => { - it("delivers a message from one connection to another", async (ctx) => { - if (!(await redisAnswers(url as string))) ctx.skip(); - const left = createRedisBus(url as string); - const right = createRedisBus(url as string); - const seen = new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("no redis message")), 4000); - right.subscribe("event:test:board", (message) => { - clearTimeout(timer); - resolve(message); - }); - }); - await new Promise((resolve) => setTimeout(resolve, 300)); - left.publish("event:test:board", { kind: "vote.cast" }); - await expect(seen).resolves.toEqual({ kind: "vote.cast" }); - }); -}); diff --git a/packages/judging-server/src/services/results.test.ts b/packages/judging-server/src/services/results.test.ts deleted file mode 100644 index 552c88d4..00000000 --- a/packages/judging-server/src/services/results.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { diffPlacements } from "./results"; - -const quiet = { rubricComponent: 8, pairwiseComponent: 0 }; - -describe("diffPlacements", () => { - it("reports a place move with both blend components", () => { - const left = [ - { projectId: "a", trackId: "t", placement: 1, score: 9, ...quiet }, - { projectId: "b", trackId: "t", placement: 2, score: 8, ...quiet }, - ]; - const right = [ - { projectId: "a", trackId: "t", placement: 2, score: 7, rubricComponent: 7, pairwiseComponent: 1 }, - { projectId: "b", trackId: "t", placement: 1, score: 9, ...quiet }, - ]; - expect(diffPlacements(left, right)).toEqual([ - { - projectId: "a", - trackId: "t", - from: 1, - to: 2, - score: 7, - rubricFrom: 8, - rubricTo: 7, - pairwiseFrom: 0, - pairwiseTo: 1, - }, - { - projectId: "b", - trackId: "t", - from: 2, - to: 1, - score: 9, - rubricFrom: 8, - rubricTo: 8, - pairwiseFrom: 0, - pairwiseTo: 0, - }, - ]); - }); - - it("reports a pairwise change when the place stays put", () => { - const left = [{ projectId: "a", trackId: "t", placement: 1, score: 8, ...quiet }]; - const right = [ - { projectId: "a", trackId: "t", placement: 1, score: 8.4, rubricComponent: 8, pairwiseComponent: 2 }, - ]; - expect(diffPlacements(left, right)).toEqual([ - { - projectId: "a", - trackId: "t", - from: 1, - to: 1, - score: 8.4, - rubricFrom: 8, - rubricTo: 8, - pairwiseFrom: 0, - pairwiseTo: 2, - }, - ]); - }); -}); diff --git a/packages/judging-server/src/services/results.ts b/packages/judging-server/src/services/results.ts deleted file mode 100644 index addcd6f1..00000000 --- a/packages/judging-server/src/services/results.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { and, desc, eq, isNotNull, isNull, sql } from "drizzle-orm"; -import { phaseAllows, rank } from "@query/judging-core"; -import type { PanelDb } from "@query/judging-db"; -import { - comparison, - event, - eventConfig, - eventLog, - judge, - outbox, - project, - projectTrack, - result, - resultRun, - track, - vote, -} from "@query/judging-db"; - -export type Placement = { - projectId: string; - trackId: string; - placement: number | null; - score: number; - rubricComponent: number; - pairwiseComponent: number; -}; - -/** Projects whose place or blend components changed between two runs. */ -export function diffPlacements(left: readonly Placement[], right: readonly Placement[]) { - const later = new Map(right.map((row) => [`${row.trackId}:${row.projectId}`, row])); - return left.flatMap((row) => { - const other = later.get(`${row.trackId}:${row.projectId}`); - const to = other?.placement ?? null; - const score = other?.score ?? row.score; - const rubricTo = other?.rubricComponent ?? row.rubricComponent; - const pairwiseTo = other?.pairwiseComponent ?? row.pairwiseComponent; - if ( - row.placement === to && - row.score === score && - row.rubricComponent === rubricTo && - row.pairwiseComponent === pairwiseTo - ) { - return []; - } - return [ - { - projectId: row.projectId, - trackId: row.trackId, - from: row.placement, - to, - score, - rubricFrom: row.rubricComponent, - rubricTo, - pairwiseFrom: row.pairwiseComponent, - pairwiseTo, - }, - ]; - }); -} - -export async function computeResults( - db: PanelDb, - input: { eventId: string; actorEmail: string; now: Date }, -) { - return db.transaction(async (tx) => { - const [evt] = await tx - .select({ phase: event.phase }) - .from(event) - .where(eq(event.id, input.eventId)); - const [config] = await tx - .select() - .from(eventConfig) - .where(eq(eventConfig.eventId, input.eventId)); - if (!evt || !config) throw new Error("Event not found"); - if (!phaseAllows(evt.phase, "compute")) { - throw new Error(`compute is not allowed while the event is ${evt.phase}`); - } - - const projects = await tx - .select() - .from(project) - .where(and(eq(project.eventId, input.eventId), isNull(project.withdrawnAt))); - const links = await tx - .select({ projectId: projectTrack.projectId, trackId: projectTrack.trackId }) - .from(projectTrack) - .innerJoin(project, eq(project.id, projectTrack.projectId)) - .where(eq(project.eventId, input.eventId)); - const tracks = await tx - .select({ id: track.id, judgeGroup: track.judgeGroup }) - .from(track) - .where(eq(track.eventId, input.eventId)); - const judges = await tx - .select({ id: judge.id, trackId: judge.trackId }) - .from(judge) - .where(eq(judge.eventId, input.eventId)); - const groupOf = new Map( - judges.map((row) => [ - row.id, - tracks.find((item) => item.id === row.trackId)?.judgeGroup ?? "main", - ]), - ); - const votes = await tx - .select() - .from(vote) - .where(eq(vote.eventId, input.eventId)); - const comparisons = await tx - .select() - .from(comparison) - .where(eq(comparison.eventId, input.eventId)); - - const byProject = new Map(); - for (const link of links) { - const list = byProject.get(link.projectId) ?? []; - list.push(link.trackId); - byProject.set(link.projectId, list); - } - - const ranked = rank( - projects.map((row) => ({ - id: row.id, - trackIds: byProject.get(row.id) ?? [], - })), - votes.map((row) => ({ - judgeId: row.judgeId, - projectId: row.projectId, - judgeGroup: groupOf.get(row.judgeId) ?? "main", - total: row.total, - isCalibration: row.isCalibration, - })), - comparisons.map((row) => ({ - judgeId: row.judgeId, - judgeGroup: row.judgeGroup, - a: row.aProjectId, - b: row.bProjectId, - outcome: row.outcome, - })), - { - bayesianC: config.bayesianC, - pairwiseWeight: config.pairwiseEnabled ? config.pairwiseWeight : 0, - calibrationWeight: config.calibrationWeight, - }, - ); - - const [run] = await tx - .insert(resultRun) - .values({ - eventId: input.eventId, - computedAt: input.now, - configSnapshot: { - bayesianC: config.bayesianC, - pairwiseWeight: config.pairwiseWeight, - pairwiseEnabled: config.pairwiseEnabled, - calibrationWeight: config.calibrationWeight, - }, - }) - .returning({ id: resultRun.id }); - if (!run) throw new Error("Result run was not stored"); - - const placed = ranked.filter((row) => row.placement !== null); - if (placed.length > 0) { - await tx.insert(result).values( - placed.map((row) => ({ - runId: run.id, - projectId: row.projectId, - trackId: row.trackId, - placement: row.placement, - score: row.score, - rubricComponent: row.rubricComponent, - pairwiseComponent: row.pairwiseComponent, - voteCount: row.voteCount, - comparisonCount: row.comparisonCount, - flags: [], - })), - ); - } - - await tx.insert(eventLog).values({ - eventId: input.eventId, - kind: "results.computed", - actor: { email: input.actorEmail }, - subject: { runId: run.id }, - payload: { rows: placed.length }, - }); - - return { runId: run.id, rows: placed.length }; - }); -} - -export async function publishResults( - db: PanelDb, - input: { eventId: string; runId: string; actorEmail: string; now: Date }, -) { - return db.transaction(async (tx) => { - const [evt] = await tx - .select({ phase: event.phase }) - .from(event) - .where(eq(event.id, input.eventId)); - if (!evt) throw new Error("Event not found"); - if (!phaseAllows(evt.phase, "publish")) { - throw new Error(`publish is not allowed while the event is ${evt.phase}`); - } - const [run] = await tx - .select({ id: resultRun.id }) - .from(resultRun) - .where(and(eq(resultRun.id, input.runId), eq(resultRun.eventId, input.eventId))); - if (!run) throw new Error("Result run not found"); - - await tx - .update(resultRun) - .set({ publishedAt: input.now }) - .where(eq(resultRun.id, run.id)); - await tx.update(event).set({ phase: "published" }).where(eq(event.id, input.eventId)); - await tx.insert(eventLog).values({ - eventId: input.eventId, - kind: "results.published", - actor: { email: input.actorEmail }, - subject: { runId: run.id }, - payload: { from: evt.phase, to: "published" }, - }); - await tx.insert(outbox).values({ - eventId: input.eventId, - topic: "results.published", - payload: { runId: run.id }, - }); - await tx.execute(sql` - update project - set feedback_token = gen_random_uuid() - where event_id = ${input.eventId} and feedback_token is null - `); - return { runId: run.id }; - }); -} - -export async function unpublishResults( - db: PanelDb, - input: { eventId: string; actorEmail: string }, -) { - return db.transaction(async (tx) => { - const [evt] = await tx - .select({ phase: event.phase }) - .from(event) - .where(eq(event.id, input.eventId)); - if (!evt) throw new Error("Event not found"); - if (evt.phase !== "published") { - throw new Error(`unpublish is not allowed while the event is ${evt.phase}`); - } - const [run] = await tx - .select({ id: resultRun.id }) - .from(resultRun) - .where(and(eq(resultRun.eventId, input.eventId), isNotNull(resultRun.publishedAt))) - .orderBy(desc(resultRun.publishedAt)) - .limit(1); - if (!run) throw new Error("Nothing is published"); - await tx.update(resultRun).set({ publishedAt: null }).where(eq(resultRun.id, run.id)); - await tx.update(event).set({ phase: "judging_closed" }).where(eq(event.id, input.eventId)); - await tx.insert(eventLog).values({ - eventId: input.eventId, - kind: "results.unpublished", - actor: { email: input.actorEmail }, - subject: { runId: run.id }, - payload: { from: "published", to: "judging_closed" }, - }); - await tx.insert(outbox).values({ - eventId: input.eventId, - topic: "results.unpublished", - payload: { runId: run.id }, - }); - return { runId: run.id }; - }); -} - -export async function placementsForRun(db: PanelDb, runId: string): Promise { - const rows = await db - .select({ - projectId: result.projectId, - trackId: result.trackId, - placement: result.placement, - score: result.score, - rubricComponent: result.rubricComponent, - pairwiseComponent: result.pairwiseComponent, - }) - .from(result) - .where(eq(result.runId, runId)) - .orderBy(desc(result.score)); - return rows; -} - -export async function inspectRun(db: PanelDb, runId: string) { - return db - .select({ - projectId: result.projectId, - projectName: project.name, - trackId: result.trackId, - placement: result.placement, - score: result.score, - rubricComponent: result.rubricComponent, - pairwiseComponent: result.pairwiseComponent, - voteCount: result.voteCount, - comparisonCount: result.comparisonCount, - }) - .from(result) - .innerJoin(project, eq(project.id, result.projectId)) - .where(eq(result.runId, runId)) - .orderBy(desc(result.score)); -} - -export async function publishedPlacements(db: PanelDb, eventId: string) { - const [evt] = await db - .select({ phase: event.phase }) - .from(event) - .where(eq(event.id, eventId)); - if (!evt) return null; - if (evt.phase !== "published") return { published: false as const, rows: [] }; - const rows = await db - .select({ - externalId: project.externalId, - placement: result.placement, - score: result.score, - track: track.slug, - voteCount: result.voteCount, - }) - .from(result) - .innerJoin(resultRun, eq(resultRun.id, result.runId)) - .innerJoin(project, eq(project.id, result.projectId)) - .innerJoin(track, eq(track.id, result.trackId)) - .where(and(eq(resultRun.eventId, eventId), isNotNull(resultRun.publishedAt))); - return { published: true as const, rows }; -} diff --git a/packages/judging-server/src/services/visit-status.integration.test.ts b/packages/judging-server/src/services/visit-status.integration.test.ts deleted file mode 100644 index 6aaf3ea7..00000000 --- a/packages/judging-server/src/services/visit-status.integration.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { eq } from "drizzle-orm"; -import { describe, expect, it } from "vitest"; -import { createDb, databaseUrl, judge, migrate, seedDemo } from "@query/judging-db"; -import { dispatchNext, visitStatus } from "./dispatch"; -import { recallJudge, voidVisit } from "./records"; - -const url = databaseUrl(); - -describe.skipIf(!url)("visit status", () => { - it("reports a recall since the hand-out, then a void", async () => { - const { db, pool } = createDb(url as string); - try { - await migrate(pool); - const { eventId } = await seedDemo(db); - const [person] = await db.select().from(judge).where(eq(judge.eventId, eventId)); - if (!person) throw new Error("demo judge missing"); - - const handed = await dispatchNext(db, { eventId, judgeId: person.id, now: new Date() }); - if (handed.done) throw new Error("demo floor is empty"); - const ask = () => visitStatus(db, { eventId, judgeId: person.id, visitId: handed.visitId }); - - expect(await ask()).toEqual({ voided: false, recalledAt: null }); - - await recallJudge(db, { eventId, judgeId: person.id, actorEmail: "organizer@example.com" }); - const recalled = await ask(); - expect(recalled.voided).toBe(false); - expect(recalled.recalledAt).not.toBeNull(); - - await voidVisit(db, { - eventId, - visitId: handed.visitId, - now: new Date(), - actorEmail: "organizer@example.com", - reason: "test", - }); - expect((await ask()).voided).toBe(true); - } finally { - await pool.end(); - } - }); -}); diff --git a/packages/judging-server/src/services/vote.test.ts b/packages/judging-server/src/services/vote.test.ts deleted file mode 100644 index 43dd458b..00000000 --- a/packages/judging-server/src/services/vote.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { DEFAULT_TIMERS } from "@query/judging-core"; -import { assessVote } from "./vote"; - -const criteria = [{ id: "creativity", min: 0, max: 10, weight: 1 }]; -const arrived = new Date("2027-02-28T13:00:00Z"); -const at = (seconds: number) => new Date(arrived.getTime() + seconds * 1000); - -describe("assessVote", () => { - it("refuses a vote outside judging_live", () => { - const decision = assessVote({ - phase: "setup", - arrivedAt: arrived, - now: at(10), - config: DEFAULT_TIMERS, - criteria, - scores: [{ criterionId: "creativity", value: 5 }], - }); - expect(decision.ok).toBe(false); - if (!decision.ok) expect(decision.message).toMatch(/setup/); - }); - - it("names the configured hard limit when the score is late", () => { - const config = { ...DEFAULT_TIMERS, hardLimitSeconds: 90, submitGraceSeconds: 0 }; - const decision = assessVote({ - phase: "judging_live", - arrivedAt: arrived, - now: at(91), - config, - criteria, - scores: [{ criterionId: "creativity", value: 5 }], - }); - expect(decision.ok).toBe(false); - if (!decision.ok) expect(decision.message).toContain("90s"); - }); - - it("accepts a score inside the window and derives the total", () => { - const decision = assessVote({ - phase: "judging_live", - arrivedAt: arrived, - now: at(10), - config: DEFAULT_TIMERS, - criteria, - scores: [{ criterionId: "creativity", value: 8 }], - }); - expect(decision).toEqual({ ok: true, total: 8 }); - }); -}); diff --git a/packages/judging-server/src/services/vote.ts b/packages/judging-server/src/services/vote.ts deleted file mode 100644 index bb1d8f0d..00000000 --- a/packages/judging-server/src/services/vote.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import { isPastCutoff, phaseAllows, validateScores, weightedTotal } from "@query/judging-core"; -import type { Criterion, Phase, ScoreInput, TimerConfig } from "@query/judging-core"; -import type { PanelDb } from "@query/judging-db"; -import { - criterion, - event, - eventConfig, - eventLog, - outbox, - rubric, - track, - visit, - vote, - voteScore, -} from "@query/judging-db"; - -export type VoteDecision = - | { ok: true; total: number } - | { ok: false; message: string }; - -/** - * Whether this score can be stored. The cutoff message names the configured - * hard limit so the phone can show the number the event actually used. - */ -export function assessVote(input: { - phase: Phase; - arrivedAt: Date | null; - now: Date; - config: TimerConfig; - criteria: readonly Criterion[]; - scores: readonly ScoreInput[]; -}): VoteDecision { - if (!phaseAllows(input.phase, "vote")) { - return { - ok: false, - message: `vote is not allowed while the event is ${input.phase}`, - }; - } - if (!input.arrivedAt) { - return { ok: false, message: "Arrive at the table before scoring" }; - } - if (isPastCutoff(input.arrivedAt, input.now, input.config)) { - return { - ok: false, - message: `Vote is past the ${input.config.hardLimitSeconds}s hard limit`, - }; - } - const check = validateScores(input.criteria, input.scores); - if (!check.ok) return { ok: false, message: check.reason }; - return { ok: true, total: weightedTotal(input.criteria, input.scores) }; -} - -export async function castVote( - db: PanelDb, - input: { - eventId: string; - judgeId: string; - visitId: string; - scores: readonly ScoreInput[]; - comment: string | null; - now: Date; - }, -): Promise<{ total: number }> { - return db.transaction(async (tx) => { - const [evt] = await tx - .select({ phase: event.phase }) - .from(event) - .where(eq(event.id, input.eventId)); - const [config] = await tx - .select() - .from(eventConfig) - .where(eq(eventConfig.eventId, input.eventId)); - const [row] = await tx - .select() - .from(visit) - .where(and(eq(visit.id, input.visitId), eq(visit.judgeId, input.judgeId))); - if (!evt || !config || !row) throw new Error("Visit not found"); - if (row.eventId !== input.eventId) throw new Error("Visit not found"); - if (row.voidedAt || row.completedAt) throw new Error("This visit is closed"); - - const criteria = await loadCriteria(tx, input.eventId, row.judgeGroup); - const decision = assessVote({ - phase: evt.phase, - arrivedAt: row.arrivedAt, - now: input.now, - config: { - targetSeconds: config.targetSeconds, - hardLimitSeconds: config.hardLimitSeconds, - walkLimitSeconds: config.walkLimitSeconds, - submitGraceSeconds: config.submitGraceSeconds, - }, - criteria, - scores: input.scores, - }); - if (!decision.ok) throw new Error(decision.message); - - const duration = - row.arrivedAt === null - ? null - : Math.round((input.now.getTime() - row.arrivedAt.getTime()) / 1000); - - const [stored] = await tx - .insert(vote) - .values({ - visitId: row.id, - judgeId: input.judgeId, - projectId: row.projectId, - eventId: input.eventId, - total: decision.total, - comment: input.comment, - durationSeconds: duration, - isCalibration: false, - }) - .returning({ id: vote.id }); - if (!stored) throw new Error("Vote was not stored"); - - if (input.scores.length > 0) { - await tx.insert(voteScore).values( - input.scores.map((score) => ({ - voteId: stored.id, - criterionId: score.criterionId, - value: score.value, - })), - ); - } - await tx - .update(visit) - .set({ completedAt: input.now }) - .where(eq(visit.id, row.id)); - await tx.insert(eventLog).values({ - eventId: input.eventId, - kind: "vote.cast", - actor: { judgeId: input.judgeId }, - subject: { visitId: row.id, projectId: row.projectId }, - payload: { total: decision.total }, - }); - await tx.insert(outbox).values({ - eventId: input.eventId, - topic: "vote.cast", - payload: { visitId: row.id, judgeId: input.judgeId, total: decision.total }, - }); - return { total: decision.total }; - }); -} - -async function loadCriteria( - tx: Parameters[0]>[0], - eventId: string, - judgeGroup: string, -): Promise { - const [groupTrack] = await tx - .select({ rubricId: track.rubricId }) - .from(track) - .where(and(eq(track.eventId, eventId), eq(track.judgeGroup, judgeGroup))); - const rubricId = groupTrack?.rubricId - ? groupTrack.rubricId - : ( - await tx - .select({ id: rubric.id }) - .from(rubric) - .where(and(eq(rubric.eventId, eventId), eq(rubric.isDefault, true))) - )[0]?.id; - if (!rubricId) return []; - const rows = await tx - .select() - .from(criterion) - .where(eq(criterion.rubricId, rubricId)); - return rows.map((row) => ({ - id: row.id, - min: row.min, - max: row.max, - weight: row.weight, - })); -} diff --git a/packages/judging-server/tsconfig.json b/packages/judging-server/tsconfig.json deleted file mode 100644 index f54bde2c..00000000 --- a/packages/judging-server/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tooling/typescript/base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "types": ["node"] - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/judging-server/vitest.config.ts b/packages/judging-server/vitest.config.ts deleted file mode 100644 index f62398d7..00000000 --- a/packages/judging-server/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["src/**/*.test.ts"], - fileParallelism: false, - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecbd1840..8907bf4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,12 +49,6 @@ importers: '@query/db': specifier: workspace:* version: link:../db - '@query/judging-db': - specifier: workspace:* - version: link:../judging-db - '@query/judging-server': - specifier: workspace:* - version: link:../judging-server '@tanstack/react-query': specifier: 5.103.2 version: 5.103.2(react@19.3.0) @@ -92,9 +86,6 @@ importers: specifier: 4.6.5 version: 4.6.5 devDependencies: - '@query/judging-core': - specifier: workspace:* - version: link:../judging-core '@query/tsconfig': specifier: workspace:* version: link:../../tooling/typescript @@ -197,129 +188,6 @@ importers: specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz - packages/judging-cli: - dependencies: - '@query/judging-db': - specifier: workspace:* - version: link:../judging-db - '@query/judging-server': - specifier: workspace:* - version: link:../judging-server - drizzle-orm: - specifier: 0.45.3 - version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) - tsx: - specifier: ^4.23.15 - version: 4.23.15 - devDependencies: - '@query/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@query/tsconfig': - specifier: workspace:* - version: link:../../tooling/typescript - '@types/node': - specifier: 22.20.4 - version: 22.20.4 - typescript: - specifier: 7.0.2 - version: 7.0.2 - - packages/judging-core: - devDependencies: - '@query/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@query/tsconfig': - specifier: workspace:* - version: link:../../tooling/typescript - '@types/node': - specifier: 22.20.4 - version: 22.20.4 - typescript: - specifier: 7.0.2 - version: 7.0.2 - vitest: - specifier: ^5.0.1 - version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) - - packages/judging-db: - dependencies: - '@query/judging-core': - specifier: workspace:* - version: link:../judging-core - drizzle-orm: - specifier: 0.45.3 - version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) - pg: - specifier: 8.23.0 - version: 8.23.0 - devDependencies: - '@query/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@query/tsconfig': - specifier: workspace:* - version: link:../../tooling/typescript - '@types/node': - specifier: 22.20.4 - version: 22.20.4 - '@types/pg': - specifier: ^8.23.1 - version: 8.23.1 - typescript: - specifier: 7.0.2 - version: 7.0.2 - - packages/judging-server: - dependencies: - '@query/judging-core': - specifier: workspace:* - version: link:../judging-core - '@query/judging-db': - specifier: workspace:* - version: link:../judging-db - '@trpc/server': - specifier: 11.19.0 - version: 11.19.0(typescript@7.0.2) - drizzle-orm: - specifier: 0.45.3 - version: 0.45.3(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0)(postgres@3.4.9) - hono: - specifier: ^4.9.0 - version: 4.13.13 - ioredis: - specifier: ^5.8.2 - version: 5.11.1 - jose: - specifier: ^6.1.0 - version: 6.2.3 - nodemailer: - specifier: ^10.0.10 - version: 10.0.10 - prom-client: - specifier: 15.1.3 - version: 15.1.3 - zod: - specifier: 4.6.5 - version: 4.6.5 - devDependencies: - '@query/eslint-config': - specifier: workspace:* - version: link:../../tooling/eslint - '@query/tsconfig': - specifier: workspace:* - version: link:../../tooling/typescript - '@types/node': - specifier: 22.20.4 - version: 22.20.4 - typescript: - specifier: 7.0.2 - version: 7.0.2 - vitest: - specifier: ^5.0.1 - version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(vite@7.3.6(@types/node@22.20.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)) - packages/ui: dependencies: minimatch: @@ -1294,9 +1162,6 @@ packages: cpu: [x64] os: [win32] - '@ioredis/commands@1.10.0': - resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2786,10 +2651,6 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - cluster-key-slot@1.1.1: - resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} - engines: {node: '>=0.10.0'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2897,10 +2758,6 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} - denque@2.1.0: - resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} - engines: {node: '>=0.10'} - detect-libc@1.0.3: resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} engines: {node: '>=0.10'} @@ -3500,10 +3357,6 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.13.13: - resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} - engines: {node: '>=16.9.0'} - hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -3558,10 +3411,6 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - ioredis@5.11.1: - resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} - engines: {node: '>=12.22.0'} - is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -4355,14 +4204,6 @@ packages: resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==} engines: {node: '>=18'} - redis-errors@1.2.0: - resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} - engines: {node: '>=4'} - - redis-parser@3.0.0: - resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} - engines: {node: '>=4'} - reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4521,9 +4362,6 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - standard-as-callback@2.1.0: - resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} - std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -5488,8 +5326,6 @@ snapshots: '@img/sharp-win32-x64@0.35.4': optional: true - '@ioredis/commands@1.10.0': {} - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -6912,8 +6748,6 @@ snapshots: clsx@2.1.1: {} - cluster-key-slot@1.1.1: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -7008,8 +6842,6 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - denque@2.1.0: {} - detect-libc@1.0.3: {} detect-libc@2.1.2: {} @@ -7694,8 +7526,6 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.13.13: {} - hookified@1.15.1: {} hookified@2.2.0: {} @@ -7750,18 +7580,6 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.1 - ioredis@5.11.1: - dependencies: - '@ioredis/commands': 1.10.0 - cluster-key-slot: 1.1.1 - debug: 4.4.3 - denque: 2.1.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -8439,12 +8257,6 @@ snapshots: dependencies: minimatch: 10.2.6 - redis-errors@1.2.0: {} - - redis-parser@3.0.0: - dependencies: - redis-errors: 1.2.0 - reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -8683,8 +8495,6 @@ snapshots: stackback@0.0.2: {} - standard-as-callback@2.1.0: {} - std-env@4.2.0: {} stop-iteration-iterator@1.1.0: diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/events/[eventId]/code/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/events/[eventId]/code/page.tsx new file mode 100644 index 00000000..5d56945e --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/hackathons/[id]/events/[eventId]/code/page.tsx @@ -0,0 +1,193 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { QRCodeSVG } from "qrcode.react"; +import { Printer } from "lucide-react"; +import { trpc } from "@/lib/trpc"; +import { trpcErrorMessage } from "@/lib/trpc-error"; +import { decodeHackathonParam } from "@/lib/hackathon-slug"; +import { useIsClient } from "@/lib/use-is-client"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { body, btnSecondary, status, textLink } from "@/components/portal/ui"; +import { + pointsLabel, + venueRange, + venueTime, +} from "@/components/hackathon/points/format"; + +// Self check-in opens this long before the start (services/points.ts). +const LEAD_MS = 15 * 60 * 1000; + +const first = (value: string | string[] | undefined) => + Array.isArray(value) ? (value[0] ?? "") : (value ?? ""); + +// The portal's sidebar sits under this screen, and browsers drop background +// colours when printing, so a printout would show it through. Printing keeps +// only the door screen. +const PRINT_ONLY_THIS = `@media print { + body * { visibility: hidden; } + #event-door-screen, #event-door-screen * { visibility: visible; } + #event-door-screen { position: absolute; inset: 0; overflow: visible; } +}`; + +/** + * The door screen for one event: its code, very large, and the poster QR that + * opens the hacker's check-in with the code filled in. Meant for a projector + * or a printout, so it covers the portal chrome. Polls so a rotation from the + * Events tab replaces what is on the wall within seconds. + */ +export default function EventCodeDisplayPage() { + const params = useParams(); + const hackathonParam = decodeHackathonParam(first(params?.id)); + const eventId = first(params?.eventId); + const isClient = useIsClient(); + + const hackathon = trpc.hackathon.getById.useQuery({ id: hackathonParam }); + const display = trpc.hackathon.getEventCheckInCode.useQuery( + { hackathonId: hackathon.data?.id ?? "", eventId }, + { enabled: !!hackathon.data, refetchInterval: 10_000 }, + ); + + const backHref = `/admin/hackathons/${hackathonParam}`; + + if (hackathon.isLoading || (hackathon.data && display.isLoading)) { + return ; + } + + const event = display.data; + if (!event) { + const error = hackathon.error ?? display.error; + return ( +
+

+ Door screen unavailable +

+

+ {trpcErrorMessage(error, "This event could not be found.")} +

+ + Back to the dashboard + +
+ ); + } + + // The last poll's time, not the render's: it moves every 10s with the poll + // and keeps the render pure. + const now = display.dataUpdatedAt; + const opensAt = new Date(event.startTime).getTime() - LEAD_MS; + const phase = + now < opensAt + ? "early" + : now > new Date(event.endTime).getTime() + ? "ended" + : "open"; + + const code = event.checkInCode; + const checkInUrl = + code && isClient + ? `${window.location.origin}/hackathons/${event.hackathonId}/checkin?code=${code}` + : null; + + return ( + // Paper is white and ink is black whatever the theme, so the tokens are + // redefined for print rather than every element getting a print colour. +
+ +
+ + +
+

+ {venueRange(event.startTime, event.endTime)} · {event.location} +

+

+ {event.name} +

+ + {code ? ( +
+
+

+ Event code +

+

+ {code.slice(0, 3)} + {code.slice(3)} +

+

+ Type it on the Points tab of your hackathon page, or scan the + QR with your phone camera. +

+
+ {checkInUrl && ( + /* literal white: the QR needs its quiet zone in both themes */ +
+ +
+ )} +
+ ) : ( +

+ This event has no check-in code yet. Make one from the event on + the Events tab; it appears here within ten seconds. +

+ )} + +

+ {event.points > 0 && ( + + Worth {pointsLabel(event.points)} + + )} + {!event.selfCheckIn ? ( + + Self check-in is off, so this code is refused. Turn it on in + the Events tab. + + ) : phase === "early" ? ( + + Opens at {venueTime(new Date(opensAt))} + + ) : phase === "ended" ? ( + + This event has ended; the code no longer works. + + ) : ( + + Taking codes until {venueTime(event.endTime)} + + )} +

+
+
+
+ ); +} diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx index e086198f..fa5182e5 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx @@ -14,7 +14,8 @@ import { AnalyticsTab } from "@/components/admin/hackathons/AnalyticsTab"; import { EventsTab } from "@/components/admin/hackathons/EventsTab"; import { JudgesTab } from "@/components/admin/hackathons/JudgesTab"; import { AnnouncementsTab } from "@/components/admin/hackathons/AnnouncementsTab"; -import { TableCards } from "@/components/admin/hackathons/TableCards"; +import { PointsAdminTab } from "@/components/admin/hackathons/points/PointsAdminTab"; +import { StoreTab } from "@/components/admin/hackathons/points/StoreTab"; import { body, btnPrimary, @@ -26,10 +27,11 @@ import { import { BarChart3, Calendar, + Coins, Gavel, Megaphone, - QrCode, ScanLine, + Store, Users, } from "lucide-react"; @@ -39,8 +41,9 @@ type Tab = | "attendees" | "analytics" | "judges" - | "announcements" - | "tables"; + | "points" + | "store" + | "announcements"; export default function AdminHackathonDashboard() { const { status } = useSession(); @@ -111,11 +114,8 @@ export default function AdminHackathonDashboard() { icon: , }, { id: "judges", label: "Judges", icon: }, - { - id: "tables", - label: "Table cards", - icon: , - }, + { id: "points", label: "Points", icon: }, + { id: "store", label: "Store", icon: }, { id: "announcements", label: "Email", @@ -188,7 +188,10 @@ export default function AdminHackathonDashboard() { )} {activeTab === "judges" && } - {activeTab === "tables" && } + {activeTab === "points" && ( + + )} + {activeTab === "store" && } {activeTab === "announcements" && ( )} @@ -197,8 +200,8 @@ export default function AdminHackathonDashboard() { {/* MOBILE BOTTOM NAVIGATION */} {/* Scrolls horizontally rather than a fixed 5-column grid: there are - seven tabs, so Table Cards and Email were laid out off the right edge - of the phone an organiser actually carries — unreachable, with nothing + eight tabs, so the last ones would be laid out off the right edge of + the phone an organiser actually carries — unreachable, with nothing to suggest they existed. */}
{tabs.map((tab) => ( diff --git a/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/page.tsx b/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/page.tsx new file mode 100644 index 00000000..7df81e48 --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/page.tsx @@ -0,0 +1,208 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect, useRef, useSyncExternalStore } from "react"; +import { trpc } from "@/lib/trpc"; +import { trpcErrorMessage } from "@/lib/trpc-error"; +import { + body, + btnSecondary, + kicker, + meta, + status, + tab, + tabList, +} from "@/components/portal/ui"; +import type { Tone } from "@/components/portal/ui"; +import { + OFFICER_POLL_MS, + SECTIONS, + plural, +} from "@/components/admin/judging/shared"; +import type { Overview, SectionId } from "@/components/admin/judging/shared"; +import { ChecklistSection } from "@/components/admin/judging/ChecklistSection"; +import { ProjectsSection } from "@/components/admin/judging/ProjectsSection"; +import { ChallengesSection } from "@/components/admin/judging/ChallengesSection"; +import { RoomsSection } from "@/components/admin/judging/RoomsSection"; +import { RubricSection } from "@/components/admin/judging/RubricSection"; +import { LaunchSection } from "@/components/admin/judging/LaunchSection"; +import { AnnouncementsSection } from "@/components/admin/judging/AnnouncementsSection"; +import { DangerZone } from "@/components/admin/judging/DangerZone"; +import { ScheduleTab as JudgingSchedule } from "@/components/admin/judging/schedule/ScheduleTab"; +import { ClaimsPanel as JudgingClaims } from "@/components/admin/judging/claims/ClaimsPanel"; +import { ResultsTab as JudgingResults } from "@/components/admin/judging/results/ResultsTab"; + +// One edition's judging console. The section lives in the URL hash so the +// checklist can link straight to the part that needs work and a reload keeps +// the organiser where they were. AdminLayout gates this page to staff. + +const subscribeHash = (onChange: () => void) => { + window.addEventListener("hashchange", onChange); + return () => window.removeEventListener("hashchange", onChange); +}; + +function useSection(): SectionId { + const hash = useSyncExternalStore( + subscribeHash, + () => window.location.hash.slice(1), + () => "", + ); + return SECTIONS.find((s) => s.id === hash)?.id ?? "checklist"; +} + +const STATE: Record = + { + draft: { label: "Draft", tone: "neutral" }, + open: { label: "Open", tone: "success" }, + closed: { label: "Closed", tone: "warning" }, + }; + +export default function AdminEditionJudgingPage() { + const params = useParams<{ hackathonId: string }>(); + const hackathonId = params?.hackathonId ?? ""; + const section = useSection(); + + const overview = trpc.judging.admin.overview.useQuery( + { hackathonId }, + { enabled: !!hackathonId, refetchInterval: OFFICER_POLL_MS }, + ); + + // Switching from far down a long section leaves the new one scrolled out of + // sight on a phone; bring its top back into view. + const contentRef = useRef(null); + const shown = useRef(section); + useEffect(() => { + if (shown.current === section) return; + shown.current = section; + const top = contentRef.current?.getBoundingClientRect().top ?? 0; + if (top < 0) contentRef.current?.scrollIntoView({ block: "start" }); + }, [section]); + + const data = overview.data; + + return ( +
+ + ← All editions + +
+ {data &&

{data.hackathon.name}

} +

+ Judging +

+ {data ? ( +

+ + Judging {STATE[data.config.state].label.toLowerCase()} + + + {plural( + data.counts.entries.devpost + data.counts.entries.portal, + "project", + )} + + {plural(data.counts.rooms.active, "room")} + {plural(data.counts.judgesSeated, "judge")} in rooms + {data.config.emergencyMode && ( + Emergency mode on + )} +

+ ) : null} +
+ + {overview.isLoading ? ( +

Loading the edition…

+ ) : overview.isError || !data ? ( +
+

+ {trpcErrorMessage(overview.error, "Could not load this edition.")} +

+
+ + + Pick another edition + +
+
+ ) : ( +
+ + +
+ {section === "checklist" && ( + + )} + {section === "projects" && ( + + )} + {section === "challenges" && ( + + )} + {section === "rooms" && ( + + )} + {section === "rubric" && } + {section === "launch" && ( + + )} + {section === "schedule" && ( + + )} + {section === "claims" && } + {section === "results" && ( + + )} + {section === "announcements" && ( + + )} + {section === "danger" && ( + + )} +
+
+ )} +
+ ); +} diff --git a/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/qr/page.tsx b/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/qr/page.tsx new file mode 100644 index 00000000..5c3ada1f --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/judging/[hackathonId]/qr/page.tsx @@ -0,0 +1,213 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { createPortal } from "react-dom"; +import { QRCodeSVG } from "qrcode.react"; +import { Printer } from "lucide-react"; +import type { RouterOutputs } from "@query/api"; +import { trpc } from "@/lib/trpc"; +import { trpcErrorMessage } from "@/lib/trpc-error"; +import { useIsClient } from "@/lib/use-is-client"; +import { + body, + btnPrimary, + btnSecondary, + meta, + object, + pageDek, +} from "@/components/portal/ui"; + +type Room = RouterOutputs["judging"]["admin"]["listRooms"]["rooms"][number]; + +// Every live room QR, ready to print and tape by each door. The portal's +// sidebar and header would print too, so the sheet that prints is a copy +// mounted straight under , and the print stylesheet hides everything +// else. Paper is white whatever the screen theme. +const PRINT_CSS = ` +@media print { + @page { margin: 16mm; } + body { background: #ffffff !important; } + body > :not(.judging-qr-print) { display: none !important; } + .judging-qr-print { display: block !important; color: #000000; } +} +`; + +export default function RoomQrSheetPage() { + const params = useParams<{ hackathonId: string }>(); + const hackathonId = params?.hackathonId ?? ""; + const isClient = useIsClient(); + + const overview = trpc.judging.admin.overview.useQuery( + { hackathonId }, + { enabled: !!hackathonId }, + ); + const rooms = trpc.judging.admin.listRooms.useQuery( + { hackathonId }, + { enabled: !!hackathonId }, + ); + + const active = (rooms.data?.rooms ?? []).filter((r) => !r.archivedAt); + const printable = active.filter((r) => r.link); + const missing = active.filter((r) => !r.link); + const edition = overview.data?.hackathon.name ?? ""; + + return ( +
+ + + ← Rooms + +
+
+

+ Room QR codes +

+

+ One room per page. Tape each sheet by its door; guest judges scan + it to join that room. +

+
+ +
+ + {rooms.isLoading ? ( +

Loading rooms…

+ ) : rooms.isError ? ( +
+

+ {trpcErrorMessage(rooms.error, "Could not load the rooms.")} +

+ +
+ ) : printable.length === 0 ? ( +

+ No room has a QR link yet. Generate links on{" "} + + Rooms + + , then come back. +

+ ) : ( + <> + {missing.length > 0 && ( +

+ Not on the sheet, no QR link yet:{" "} + {missing.map((r) => r.name).join(", ")}. +

+ )} +
    + {printable.map((room) => ( +
  • + +
  • + ))} +
+ + )} + + {isClient && + printable.length > 0 && + createPortal( +
+ {printable.map((room) => ( +
+ +
+ ))} +
, + document.body, + )} +
+ ); +} + +function Sheet({ + room, + edition, + size, + print, +}: { + room: Room; + edition: string; + size: number; + print?: boolean; +}) { + if (!room.link) return null; + const where = [room.buildingName, room.challengeLabel] + .filter(Boolean) + .join(" · "); + return ( +
+

+ {edition ? `${edition} judging` : "Judging"} +

+

+ {room.name} +

+ {where && ( +

+ {where} +

+ )} + {/* literal white: must match the QR bgColor below in both themes */} +
+ +
+

+ Judges: scan with your phone camera to join this room. +

+

+ {room.link.url} +

+
+ ); +} diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index 14e9d414..c025fada 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -1,420 +1,96 @@ "use client"; -import React, { useState, useEffect, useMemo, useRef } from "react"; import Link from "next/link"; -import { loginHref } from "@/lib/safe-callback"; -import { useSession } from "next-auth/react"; +import { ChevronRight } from "lucide-react"; import { trpc } from "@/lib/trpc"; -import { - READ_ONLY_TITLE, - canViewAdmin, - usePortalContext, - useReadOnly, -} from "@/lib/use-portal-context"; -import { useIsClient } from "@/lib/use-is-client"; -import { useRouter, useSearchParams } from "next/navigation"; -import { JudgingTools } from "@/components/admin/judging/JudgingTools"; -import { RoomAssignmentsView } from "@/components/admin/judging/RoomAssignmentsView"; -import { JudgeMatrixView } from "@/components/admin/judging/JudgeMatrixView"; -import { RankingsView } from "@/components/admin/judging/RankingsView"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; -import { JudgeLiveBoard } from "@/components/admin/hackathons/JudgeLiveBoard"; -import { judgingPrepIsCurrent } from "@/lib/judging-prep"; +import { hackathonStatus } from "@/lib/hackathon-status"; import { body, - btnPrimary, - btnSecondary, - kicker, + itemTitle, meta, pageDek, - status as statusClass, + sectionRule, + status, + textLink, } from "@/components/portal/ui"; -export default function AdminResultsPage() { - const { data: session, status } = useSession(); - const router = useRouter(); - const mounted = useIsClient(); - // ?hackathonId= preselects the event, so the hackathon dashboard's - // "Prepare judging" link lands on the right one. - const searchParams = useSearchParams(); - const [selectedHackathon, setSelectedHackathon] = useState( - searchParams.get("hackathonId"), - ); - const [selectedCategory, setSelectedCategory] = useState("ALL"); - const [selectedTrack, setSelectedTrack] = useState("ALL"); - const [viewMode, setViewMode] = useState<"results" | "rooms" | "judges">( - "results", - ); - - const { data: portalContext } = usePortalContext(); - const readOnly = useReadOnly(); - - // Get hackathons - const { data: hackathons } = trpc.hackathon.list.useQuery( - {}, - { - enabled: !!session && canViewAdmin(portalContext), - }, - ); - - // Get rankings for selected hackathon - const { data: panelConsole } = trpc.judge.panelConsole.useQuery( - { hackathonId: selectedHackathon as string }, - { enabled: !!selectedHackathon }, - ); - - const { data: rankings } = trpc.judge.getRankings.useQuery( - { hackathonId: selectedHackathon as string }, - { enabled: !!selectedHackathon }, - ); - - // Get judges - const { data: judges } = trpc.judge.list.useQuery(undefined, { - enabled: !!session && canViewAdmin(portalContext), - }); - - // Judging active status - const { data: judgingStatus, refetch: refetchJudgingStatus } = - trpc.judge.getJudgingStatus.useQuery( - { hackathonId: selectedHackathon as string }, - { enabled: !!selectedHackathon, refetchInterval: 10000 }, - ); - - const toggleJudging = trpc.judge.toggleJudging.useMutation({ - onSuccess: () => refetchJudgingStatus(), - }); - - // Preparing judging was two presses on a separate page, in an order nothing - // enforced: sync submissions, then build the queues. Doing them in the wrong - // order leaves late projects in nobody's queue. - const utils = trpc.useUtils(); - const setJudgingBackend = trpc.judge.setJudgingBackend.useMutation({ - onSuccess: () => utils.judge.panelConsole.invalidate(), - }); - const [prepState, setPrepState] = useState<{ - busy: boolean; - message: string | null; - error: string | null; - }>({ busy: false, message: null, error: null }); - const selectedHackathonRef = useRef(selectedHackathon); - const prepGen = useRef(0); - - const promoteSubmissions = trpc.judge.promoteSubmissions.useMutation(); - - const stillThisRun = (hackathonId: string, gen: number) => - judgingPrepIsCurrent( - hackathonId, - gen, - selectedHackathonRef.current, - prepGen.current, - ); - - const finishPrepare = async ( - hackathonId: string, - gen: number, - message: string, - ) => { - // The live board and table cards read what prepare just built; neither - // polls until judging is open. - await Promise.all([ - utils.judge.getRankings.invalidate({ hackathonId }), - utils.judge.liveProgress.invalidate(), - utils.judge.tableCards.invalidate(), - ]); - if (!stillThisRun(hackathonId, gen)) return; - await refetchJudgingStatus(); - if (!stillThisRun(hackathonId, gen)) return; - setPrepState({ busy: false, error: null, message }); - }; - - const prepareJudging = async () => { - const hackathonId = selectedHackathon; - if (!hackathonId) return; - const gen = ++prepGen.current; - setPrepState({ busy: true, message: null, error: null }); - try { - const promoted = await promoteSubmissions.mutateAsync({ - hackathonId, - }); - if (!stillThisRun(hackathonId, gen)) return; - // No queues to build: judges draw from the pool of judgeable projects as - // soon as judging is open, so syncing is the whole of preparing. - await finishPrepare( - hackathonId, - gen, - `Synced ${promoted.created} new submission(s) of ${promoted.total}. Judges draw tables from these as soon as judging is open. Print the table cards next.`, - ); - } catch (e) { - if (!stillThisRun(hackathonId, gen)) return; - setPrepState({ - busy: false, - message: null, - error: e instanceof Error ? e.message : "Could not prepare judging.", - }); - } - }; - - useEffect(() => { - if (status === "unauthenticated") { - router.push(loginHref()); - } - }, [status, router]); - - // Auto-select first hackathon - if (hackathons?.[0] && !selectedHackathon) { - setSelectedHackathon(hackathons[0].id); - } - - // A new selection drops the previous edition's prep result. - const [prepShownFor, setPrepShownFor] = useState(selectedHackathon); - if (prepShownFor !== selectedHackathon) { - setPrepShownFor(selectedHackathon); - setPrepState({ busy: false, message: null, error: null }); - } - - // Covers the auto-select above, which runs before any prep can start. - useEffect(() => { - selectedHackathonRef.current = selectedHackathon; - }, [selectedHackathon]); - - // The ref and generation move here, in the click, before B renders: a run - // still in flight for A fails stillThisRun from this moment, so its result - // cannot land on B's panel. Re-selecting the current edition is a no-op, so - // it cannot orphan that edition's own run with busy stuck on. - const selectHackathon = (id: string) => { - if (id === selectedHackathonRef.current) return; - selectedHackathonRef.current = id; - prepGen.current += 1; - setSelectedHackathon(id); - }; - - const categories = useMemo(() => { - if (!rankings?.rankings) return ["ALL"]; - const cats = new Set( - rankings.rankings - .map((r) => r.project.category) - .filter((c): c is string => !!c), - ); - return ["ALL", ...Array.from(cats)]; - }, [rankings]); - - const tracks = useMemo(() => { - if (!rankings?.rankings) return ["ALL"]; - const ts = new Set(); - rankings.rankings.forEach((r) => { - r.project.tracks?.forEach((t) => ts.add(t)); - r.project.challenges?.forEach((c) => ts.add(c.toUpperCase())); - if (r.project.isCreateX) ts.add("CREATE-X"); - }); - return ["ALL", ...Array.from(ts)]; - }, [rankings]); - - const processedRankings = useMemo(() => { - if (!rankings?.rankings) return []; - - let filtered = rankings.rankings; - - if (selectedCategory !== "ALL") { - filtered = filtered.filter( - (r) => r.project.category === selectedCategory, - ); - } - - if (selectedTrack !== "ALL") { - filtered = filtered.filter( - (r) => - r.project.tracks?.includes(selectedTrack) || - r.project.challenges?.some( - (c) => c.toUpperCase() === selectedTrack, - ) || - (selectedTrack === "CREATE-X" && r.project.isCreateX), - ); - } - - // Calculate display score based on track - return filtered - .map((r) => { - return { - ...r, - displayScore: r.weightedScore, - }; - }) - .sort((a, b) => b.displayScore - a.displayScore); - }, [rankings, selectedCategory, selectedTrack]); - - const selectedName = hackathons?.find((h) => h.id === selectedHackathon) - ?.name; - - if (!mounted) return ; +// Judging is run per edition. AdminLayout gates this page to staff. +export default function AdminJudgingPage() { + const { data: hackathons, isLoading, isError, refetch } = + trpc.hackathon.list.useQuery({ limit: 50 }); return ( - <> -
-
- {selectedName &&

{selectedName}

} -

- Judging results -

-

- {!selectedHackathon - ? "Pick a hackathon to see its results." - : "Open and close judging, watch the floor, and compare scores."} +

+
+

+ Judging +

+

+ Pick an edition to open its judging console: projects, challenges, + rooms, the schedule, launch and results. Each edition's judge + roster is on its hackathon page, under Judges. +

+
+ +
+ {isLoading ? ( +

Loading editions…

+ ) : isError ? ( +

+ Could not load the editions.{" "} +

- {selectedHackathon && panelConsole ? ( -
-

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

- {panelConsole.url ? ( - - Open the panel console - - ) : null} - -
- ) : null} -
- - {/* Judging Control Panel */} - {selectedHackathon && ( -
-
-
-

- {judgingStatus?.active ? "Judging is live" : "Judging is closed"} -

-

- {judgingStatus?.active - ? "Judges are scoring projects now." - : "Judges can't score until you start judging."} -

-
- - -
- {/* Starting is refused until queues exist; without this the - press looks like a no-op. Scoped to the event it was for. */} - {toggleJudging.error && - toggleJudging.variables?.hackathonId === selectedHackathon && ( -

- {toggleJudging.error.message} -

- )} - - {/* Sync then assign, in that order, from the screen that starts - judging — rather than two presses on a separate page. */} -
-
-
-

- Prepare judging -

-

- Syncs submissions into judging and gives each a table. Safe - to run again during judging: late submissions join the pool. -

-
- -
- - {prepState.message && ( -

{prepState.message}

- )} - {prepState.error && ( -

- {prepState.error} -

- )} -
-
- )} - - {selectedHackathon && ( -
- -
- )} - - {/* Judging Tools */} - - - {/* ===== ROOMS VIEW ===== */} - {viewMode === "rooms" && rankings && ( - - )} - - {/* ===== JUDGES VIEW ===== */} - {viewMode === "judges" && rankings && ( - - )} - - {viewMode === "results" && ( - + ) : !hackathons || hackathons.length === 0 ? ( +

+ No editions yet. Create one from{" "} + + Hackathons + {" "} + first. +

+ ) : ( +
    + {hackathons.map((h) => { + const conf = hackathonStatus(h.status); + return ( +
  • + +
    +

    {h.name}

    +

    + {conf.label} + {" · "} + {new Date(h.startDate).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +

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

{event.name}

-

Current phase: {message}

- {floor ? ( -
-

Floor

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

{zoneLabel}

: null} -

- {card.tableNumber} -

-

{card.name}

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

Rubric

- - - - -

Zone and floor

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

Prize

- - - - -

Judges

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

Withdraw a project

- - -
- ); -} - -const field: CSSProperties = { - display: "block", - width: "100%", - margin: "0.25rem 0 0.75rem", -}; - -const floor: CSSProperties = { - position: "relative", - height: 420, - margin: "1rem 0", - background: "#f5f5f4", - border: "1px solid #d6d3d1", -}; - -const chip: CSSProperties = { - position: "absolute", - width: 64, - height: 48, - background: "white", - border: "1px solid #1c1917", -}; diff --git a/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/page.tsx b/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/page.tsx deleted file mode 100644 index 92ca5b08..00000000 --- a/sites/mainweb/app/(portal)/admin/judging/panel/[hackathonId]/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Console } from "./console"; -import { loadEvent } from "@/lib/panel.server"; - -export default async function OrganizerPage({ - params, -}: { - params: Promise<{ hackathonId: string }>; -}) { - const { hackathonId } = await params; - const event = await loadEvent(hackathonId); - if (!event) - return ( -
This event is not available.
- ); - return ; -} diff --git a/sites/mainweb/app/(portal)/admin/setup/page.tsx b/sites/mainweb/app/(portal)/admin/setup/page.tsx index 4d25216f..a02804c6 100644 --- a/sites/mainweb/app/(portal)/admin/setup/page.tsx +++ b/sites/mainweb/app/(portal)/admin/setup/page.tsx @@ -2,7 +2,7 @@ import { redirect } from "next/navigation"; /** * Judging Setup used to create a second hackathon record. Editions already - * exist on the hackathon side of the portal; queue prep lives on /admin/judging. + * exist on the hackathon side of the portal; judging setup lives on /admin/judging. */ export default function AdminSetupRedirect() { redirect("/admin/judging"); diff --git a/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts b/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts deleted file mode 100644 index 982485c6..00000000 --- a/sites/mainweb/app/(portal)/api/panel/[...path]/route.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { panel } from "@query/api/panel"; - -/** - * The judging API, served by this app. Routes are written against `/`, so the - * prefix comes off before the request reaches them. Callers are identified by - * their portal session. - */ -const PREFIX = "/api/panel"; - -async function handler(req: Request) { - const url = new URL(req.url); - url.pathname = url.pathname.slice(PREFIX.length) || "/"; - // Not public: process metrics, and a readiness probe that queries Postgres. - // An uptime checker on /readyz would keep Neon awake around the clock. - if (url.pathname === "/metrics" || url.pathname === "/readyz") { - return new Response(null, { status: 404 }); - } - const body = - req.method === "GET" || req.method === "HEAD" - ? undefined - : await req.arrayBuffer(); - return panel().app.fetch( - new Request(url, { method: req.method, headers: req.headers, body }), - ); -} - -export { handler as GET, handler as POST }; diff --git a/sites/mainweb/app/(portal)/dashboard/page.tsx b/sites/mainweb/app/(portal)/dashboard/page.tsx index ca7cc34f..3f7e12bf 100644 --- a/sites/mainweb/app/(portal)/dashboard/page.tsx +++ b/sites/mainweb/app/(portal)/dashboard/page.tsx @@ -319,7 +319,7 @@ export default function Dashboard() { {/* Judge Portal — judges only */} {isJudge && ( - Score projects and manage your judging queue. + Your judging editions, rooms and the projects you score. )} diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/checkin/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/checkin/page.tsx new file mode 100644 index 00000000..6d498bae --- /dev/null +++ b/sites/mainweb/app/(portal)/hackathons/[id]/checkin/page.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { Suspense, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useSession } from "next-auth/react"; +import type { RouterOutputs } from "@query/api"; +import { trpc } from "@/lib/trpc"; +import { loginHref } from "@/lib/safe-callback"; +import { trpcErrorMessage } from "@/lib/trpc-error"; +import { decodeHackathonParam } from "@/lib/hackathon-slug"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { HackathonUnavailable } from "@/components/hackathon/HackathonUnavailable"; +import { CheckInCodeForm } from "@/components/hackathon/points/CheckInCodeForm"; +import { pointsLabel } from "@/components/hackathon/points/format"; +import { + btnPrimary, + meta, + page, + pageDek, + pageTitle, + sectionRule, + textLink, +} from "@/components/portal/ui"; + +/** + * Where an event's poster QR lands: /hackathons//checkin?code=XXXXXX. + * The code is submitted as soon as the hacker is signed in, so scanning the + * poster is the whole check-in. Signed-out visitors go to sign in and come + * back to this same URL, code included. + */ +export default function EventCodeCheckInPage() { + return ( + }> + + + ); +} + +function EventCodeCheckIn() { + const { status: authStatus } = useSession(); + const router = useRouter(); + const params = useParams(); + const searchParams = useSearchParams(); + + const rawId = params.id; + const hackathonParam = decodeHackathonParam( + Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""), + ); + const code = searchParams.get("code")?.trim() ?? ""; + + useEffect(() => { + if (authStatus === "unauthenticated") router.push(loginHref()); + }, [authStatus, router]); + + const hackathon = trpc.hackathon.getById.useQuery( + { id: hackathonParam }, + { enabled: authStatus === "authenticated" }, + ); + + // From the poster's code, or from one typed after it was refused. + const [checkedIn, setCheckedIn] = useState< + RouterOutputs["points"]["me"]["checkIn"] | null + >(null); + + const utils = trpc.useUtils(); + const checkIn = trpc.points.me.checkIn.useMutation({ + onSuccess: (result) => { + setCheckedIn(result); + void utils.points.me.invalidate(); + }, + }); + const { mutate: submitCode } = checkIn; + + // Once per visit. Strict Mode runs effects twice on mount, and a second + // submit would come back as "already checked in" over the real answer. + const submitted = useRef(false); + const hackathonId = hackathon.data?.id; + useEffect(() => { + if (!hackathonId || !code || submitted.current) return; + submitted.current = true; + submitCode({ hackathonId, code }); + }, [hackathonId, code, submitCode]); + + if (authStatus !== "authenticated") { + return ; + } + if (hackathon.isLoading) { + return ; + } + if (!hackathon.data) { + return ; + } + + const pointsHref = `/hackathons/${hackathon.data.id}?tab=POINTS`; + + // Opened without a code (or with the code stripped by a QR reader): ask for + // it rather than calling that an error. + if (!code && !checkedIn) { + return ( +
+

{hackathon.data.name}

+

Check in to an event

+

+ Type the code shown on the screen or poster at the event. +

+
+ +
+

+ + Your points + +

+
+ ); + } + + if (checkedIn) { + return ( +
+

{hackathon.data.name}

+

+ You're checked into {checkedIn.eventName} +

+

+ {checkedIn.pointsAwarded > 0 + ? `${pointsLabel(checkedIn.pointsAwarded)} added to your total.` + : "Your attendance is recorded. This event does not award points."} +

+ + See your points + +
+ ); + } + + if (checkIn.error) { + const refusal = checkIn.error.data?.code; + // Already in, or locked out for a minute: typing another code here does + // not help, so the form is only offered for the other refusals. + const offerForm = refusal !== "CONFLICT" && refusal !== "TOO_MANY_REQUESTS"; + return ( +
+

{hackathon.data.name}

+

+ {refusal === "CONFLICT" + ? "Already checked in" + : refusal === "TOO_MANY_REQUESTS" + ? "Too many tries" + : "Not checked in"} +

+

+ {trpcErrorMessage( + checkIn.error, + "The check-in did not go through. Type the code from the screen below.", + )} +

+ {offerForm && ( +
+ +
+ )} +

+ + Your points + +

+
+ ); + } + + return ; +} diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/claim/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/claim/page.tsx new file mode 100644 index 00000000..821ae7c9 --- /dev/null +++ b/sites/mainweb/app/(portal)/hackathons/[id]/claim/page.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import Link from "next/link"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { trpc } from "@/lib/trpc"; +import { trpcErrorMessage } from "@/lib/trpc-error"; +import { loginHref } from "@/lib/safe-callback"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { + body, + btnPrimary, + itemTitle, + kicker, + label, + meta, + object, + page, + pageDek, + pageTitle, + status, + textLink, +} from "@/components/portal/ui"; + +/** + * Where a claim email lands. Shows which project and which name on its team + * the link holds, so a forwarded link is caught before anyone confirms, then + * sends the hacker to the event's Judging tab. + */ +function ClaimContent() { + const { status: authStatus } = useSession(); + const router = useRouter(); + const params = useParams<{ id: string }>(); + const searchParams = useSearchParams(); + const utils = trpc.useUtils(); + + const hackathonId = params?.id ?? ""; + const token = searchParams.get("token")?.trim() ?? ""; + const judgingHref = `/hackathons/${hackathonId}?tab=JUDGING`; + const [error, setError] = useState(null); + + // The link usually arrives on a phone that is not signed in yet; come + // straight back here, token and all, afterwards. + useEffect(() => { + if (authStatus === "unauthenticated") router.push(loginHref()); + }, [authStatus, router]); + + const preview = trpc.judging.hacker.previewClaim.useQuery( + { hackathonId, token }, + { + enabled: authStatus === "authenticated" && !!token, + // Every refusal here is final until a person acts; retrying only delays it. + retry: false, + }, + ); + + const claim = trpc.judging.hacker.claimByToken.useMutation({ + onSuccess: async () => { + await utils.judging.hacker.myClaim.invalidate({ hackathonId }); + router.replace(judgingHref); + }, + onError: (e) => + setError(trpcErrorMessage(e, "The claim did not go through. Try again.")), + }); + + if (authStatus !== "authenticated" || (token && preview.isLoading)) { + return ; + } + + const back = ( + + ← Judging + + ); + + if (!token || preview.isError || !preview.data) { + const code = preview.error?.data?.code; + const title = !token + ? "This link is incomplete" + : code === "FORBIDDEN" + ? "Check in first" + : code === "BAD_REQUEST" + ? "This link no longer works" + : "This link did not open"; + const message = !token + ? "The claim code is missing from the address. Open the link from the email again, or ask an organiser at the help desk for a new one." + : trpcErrorMessage( + preview.error, + "Something went wrong opening this link. Try it again in a moment.", + ); + + return ( +
+ {back} +
+

{title}

+

{message}

+

+ Already claimed your project? It is on your{" "} + + Judging tab + + . +

+
+
+ ); + } + + const data = preview.data; + + return ( +
+ {back} +
+

{data.hackathonName}

+

Claim your place

+

+ This link holds the place listed as {data.place.name}. Once it is + yours, the project's rooms, times and feedback show on your + Judging tab. +

+ +
+

Project

+

{data.title}

+
    + {data.members.map((member) => { + const isPlace = member.id === data.place.memberId; + return ( +
  • + + {member.name} + + + {isPlace + ? "This link" + : member.claimed + ? "Claimed" + : "Not claimed"} + +
  • + ); + })} +
+
+ + {data.refusal ? ( +
+

+ {data.refusal} +

+ + Open the Judging tab + +
+ ) : ( +
+ {error && ( +

+ {error} +

+ )} + +

+ Not {data.place.name}? Leave this place for them and ask an + organiser at the help desk for your own link. +

+
+ )} +
+
+ ); +} + +export default function ClaimPage() { + return ( + }> + + + ); +} diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx deleted file mode 100644 index 1d220d80..00000000 --- a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx +++ /dev/null @@ -1,784 +0,0 @@ -"use client"; - -import React, { useEffect, useRef, useState } from "react"; -import { loginHref } from "@/lib/safe-callback"; -import { useSession } from "next-auth/react"; -import { useParams, useRouter } from "next/navigation"; -import Link from "next/link"; -import { trpc } from "@/lib/trpc"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; -import { QRScannerModal } from "@/components/portal/QRScannerModal"; -import { decodeHackathonParam } from "@/lib/hackathon-slug"; -import { AlertCircle } from "lucide-react"; -import { - body, - btnPrimary, - btnSecondary, - fieldLabel, - input, - itemTitle, - kicker, - meta, - page, - pageDek, - pageTitle, - textLink, -} from "@/components/portal/ui"; - -const BACK_LINK = - "inline-flex min-h-11 items-center text-[13px] text-[var(--text-subtle)] hover:text-[var(--text-primary)] transition-colors"; - -/** One full-page message (not found, not approved, not open): a headline, - * a sentence, and the actions under it. */ -function Notice({ - title, - children, - actions, -}: { - title: string; - children: React.ReactNode; - actions: React.ReactNode; -}) { - return ( -
- - ← Judging - -
-

{title}

-

{children}

-
- {actions} -
-
-
- ); -} - -type Project = { - id: string; - name: string; - description?: string | null; - tableNumber?: number | null; - githubUrl?: string | null; - demoUrl?: string | null; - videoUrl?: string | null; - tracks?: string[] | null; -}; - -const CRITERIA = [ - { key: "scoreCreativity", label: "Creativity" }, - { key: "scoreImpact", label: "Impact" }, - { key: "scoreScope", label: "Scope" }, - { key: "scoreClarity", label: "Clarity" }, - { key: "scoreSoundness", label: "Soundness" }, -] as const; - -type ScoreKey = (typeof CRITERIA)[number]["key"]; -type Scores = Record; - -const BLANK: Scores = { - scoreCreativity: 5, - scoreImpact: 5, - scoreScope: 5, - scoreClarity: 5, - scoreSoundness: 5, -}; - -// Mirrors packages/api/src/routers/judge/dispatch.ts: amber at the target, -// form locked at the hard cutoff. The server enforces the cutoff; this only -// shows it. -const TARGET_SECONDS = 180; -const HARD_LIMIT_SECONDS = 240; - -const TIME_UP_MESSAGE = - "Time ran out on that table before a score went in, so it goes back to be judged by someone else. Here is your next table."; - -/** - * A server timestamp on this phone's clock. The judging clock has to agree - * with the server's cutoff, and a phone set a minute off would otherwise - * show 3:00 when the server already counts 4:00. - */ -function toLocalClock( - serverTime: Date | string | null, - serverNow: Date | string, -): number | null { - if (!serverTime) return null; - const offset = Date.now() - new Date(serverNow).getTime(); - return new Date(serverTime).getTime() + offset; -} - -const mmss = (seconds: number) => - `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; - -export default function JudgeHackathonPage() { - const { data: session, status } = useSession(); - const router = useRouter(); - const params = useParams(); - - /** - * The [id] segment is a name on every sibling route, but the judging - * procedures take a uuid, so a by-name URL would fail input validation. - * Resolve it first when it is not already an id. - */ - const rawId = params.id; - const routeParam = decodeHackathonParam( - Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""), - ); - const isUuid = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - routeParam, - ); - const resolved = trpc.hackathon.getById.useQuery( - { id: routeParam }, - { enabled: !!session && !isUuid }, - ); - const hackathonId = isUuid ? routeParam : resolved.data?.id; - - const [current, setCurrent] = useState<{ - project: Project | null; - queueId: string | null; - } | null>(null); - const [scores, setScores] = useState(BLANK); - const [comment, setComment] = useState(""); - const [error, setError] = useState(""); - const [showScanner, setShowScanner] = useState(false); - /** When the judging clock started, on this device's clock: the server's - * arrival stamp shifted by the server/phone offset. Null until the judge - * taps or scans the table, and the scoring form stays shut until then. */ - const [clockStart, setClockStart] = useState(null); - const arrived = clockStart !== null; - const [tick, setTick] = useState(() => Date.now()); - useEffect(() => { - if (clockStart === null) return; - const id = setInterval(() => setTick(Date.now()), 1000); - return () => clearInterval(id); - }, [clockStart]); - const elapsed = - clockStart === null ? 0 : Math.max(0, Math.floor((tick - clockStart) / 1000)); - const timeUp = clockStart !== null && elapsed >= HARD_LIMIT_SECONDS; - const [done, setDone] = useState(false); - const [startedAt, setStartedAt] = useState(() => Date.now()); - - const judgeCheck = trpc.judge.isJudge.useQuery( - { hackathonId: hackathonId as string }, - { enabled: !!session && !!hackathonId }, - ); - - /** - * Fetched once, not polled. Every call re-claims a table, so a background - * refetch could hand the judge a different project mid-score; the mutations - * below already return the next one. - */ - /** - * Whether judging is open. - * - * getNextTable has no such guard, so without this the page hands a judge a - * table and a full scoring form while judgingActive is false — and then - * submitVote and completeAndNext both throw FORBIDDEN. The judge interviews - * the team, types their notes, presses submit and loses all of it. The flag - * defaults to false, so this is the state every event starts in. - */ - const judgingStatus = trpc.judge.getJudgingStatus.useQuery( - { hackathonId: hackathonId as string }, - { enabled: !!session && !!hackathonId }, - ); - - const judgingOpen = judgingStatus.data?.active === true; - - const nextTable = trpc.judge.getNextTable.useQuery( - { hackathonId: hackathonId as string }, - { - enabled: - !!session && - !!hackathonId && - judgeCheck.data?.isJudge === true && - judgingOpen, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }, - ); - - const progress = trpc.judge.getProgress.useQuery( - { hackathonId: hackathonId as string }, - { enabled: !!session && !!hackathonId && judgeCheck.data?.isJudge === true }, - ); - - // Seeds the first project only. Once a mutation has taken over, `current` or - // `done` is set and a late-arriving response must not replace what the judge - // is looking at. - if (nextTable.data && !current && !done) { - if (nextTable.data.done) { - setDone(true); - } else { - setCurrent({ - project: (nextTable.data.project as Project) ?? null, - queueId: nextTable.data.queueId ?? null, - }); - // A reload mid-table keeps the clock it already had. - setClockStart( - toLocalClock(nextTable.data.arrivedAt ?? null, nextTable.data.serverNow), - ); - } - } - - const advance = ( - project: Project | null, - queueId: string | null, - // Set when the table has already been started (the tap or scan); every - // other hand-over is a table the judge still has to walk to. - clock: number | null = null, - ) => { - setScores(BLANK); - setComment(""); - setError(""); - setStartedAt(Date.now()); - setClockStart(clock); - progress.refetch(); - if (!project) { - setDone(true); - setCurrent(null); - return; - } - setCurrent({ project, queueId }); - }; - - /** - * The slot on screen is gone: an organiser withdrew the project, which - * deletes its unscored slots. Every action on the card would fail the same - * way, so fetch the next table instead of leaving the judge on a dead card. - * Only these answers mean that; anything else (judging closed) keeps the - * card and the scores typed into it. - */ - const recoverOrReport = (e: { - message: string; - data?: { code?: string } | null; - }) => { - const slotGone = - e.data?.code === "NOT_FOUND" || - (e.data?.code === "FORBIDDEN" && - e.message.includes("not in your judging queue")); - if (!slotGone) { - setError(e.message); - return; - } - const goneQueueId = current?.queueId; - void nextTable.refetch().then(({ data, isError }) => { - // A failed refetch still resolves, carrying the cached answer — the - // withdrawn table itself. Moving to it would put the judge back on the - // dead card under a message saying they had a new one. - if (isError || !data || (!data.done && data.queueId === goneQueueId)) { - setError( - "That table was withdrawn by an organiser, and your next one could not be loaded. Check your connection and try again.", - ); - return; - } - advance( - data.done ? null : ((data.project as Project) ?? null), - data.queueId ?? null, - data.done ? null : toLocalClock(data.arrivedAt ?? null, data.serverNow), - ); - setError("That table was withdrawn by an organiser. Here is your next one."); - }); - }; - - // Neither mutation invalidates getNextTable: refetching it would claim a - // table a second time, and both already return the next project to show. - const complete = trpc.judge.completeAndNext.useMutation({ - onSuccess: (res) => { - if (res.done) advance(null, null); - else - advance( - (res.nextProject as Project) ?? null, - res.nextQueueId ?? null, - toLocalClock(res.nextArrivedAt ?? null, res.serverNow), - ); - if (res.timedOut) setError(TIME_UP_MESSAGE); - }, - onError: recoverOrReport, - }); - - /** - * Scanning the table card is what starts the clock. - * - * The judge is handed a table by the queue, walks to it, and scans — only - * then does scoring time begin, and only then does the form open. It also - * catches a judge standing at the wrong table before they score it. - */ - const startByQr = trpc.judge.startByQrCode.useMutation({ - onSuccess: (res) => { - setShowScanner(false); - setError(""); - advance( - (res.project as Project) ?? null, - res.queueId ?? null, - toLocalClock(res.arrivedAt, res.serverNow), - ); - }, - onError: (e) => { - setShowScanner(false); - setError(e.message); - }, - }); - - // Passing on a table returns it to the pool for another judge; this judge - // is not sent back to it. Also what "time's up" uses to move on. - const skip = trpc.judge.skipProject.useMutation({ - onSuccess: (res) => - advance( - res.done ? null : ((res.project as Project) ?? null), - res.queueId ?? null, - res.done ? null : toLocalClock(res.arrivedAt ?? null, res.serverNow), - ), - onError: recoverOrReport, - }); - - /** - * Tap-to-start. The NFC tag on a table holds this page's URL with - * ?table=, so tapping it opens the page and starts that table exactly - * as scanning the printed card would. Waits for judge access and an open - * round; the param is dropped once used so a reload cannot replay it - * (startByQrCode is idempotent anyway). - */ - const tappedTable = useRef(false); - useEffect(() => { - if (tappedTable.current) return; - if (!hackathonId || judgeCheck.data?.isJudge !== true || !judgingOpen) return; - const url = new URL(window.location.href); - const code = url.searchParams.get("table"); - if (!code) return; - tappedTable.current = true; - url.searchParams.delete("table"); - window.history.replaceState(null, "", url.pathname + url.search + url.hash); - startByQr.mutate({ qrCode: code.trim() }); - }, [hackathonId, judgeCheck.data?.isJudge, judgingOpen, startByQr]); - - useEffect(() => { - if (status === "unauthenticated") router.push(loginHref()); - }, [status, router]); - - if (status === "loading") { - return ; - } - if (!session) return null; - - // Without this the disabled judgeCheck below stays pending forever and the - // page spins on a hackathon that does not exist. - if (!hackathonId) { - if (resolved.isPending) { - return ; - } - return ( - - Back to judging - - } - > - {resolved.error?.message ?? "That hackathon does not exist."} - - ); - } - - if (judgeCheck.isPending) { - return ; - } - - if (judgeCheck.data && !judgeCheck.data.isJudge) { - return ( - - Back to judging - - } - > - An organiser has not approved your judge application for this - hackathon yet. You'll get an email when they do. - - ); - } - - if (judgingStatus.isPending) { - return ; - } - - // Said before a table is handed out, not after a score is lost. - if (!judgingOpen) { - return ( - - - - Back to judging - - - } - > - Your queue is waiting. An organiser opens scoring when the expo - begins; check again then. - - ); - } - - if (nextTable.isPending && !current) { - return ; - } - - // A failed lookup must not fall through to the "All Done" card below — a - // judge told they have finished walks away with their queue unscored. - if (nextTable.error && !current) { - return ( - nextTable.refetch()} - className={`min-h-11 ${btnPrimary}`} - > - Try again - - } - > - {nextTable.error.message} - - ); - } - - const total = scores.scoreCreativity + - scores.scoreImpact + - scores.scoreScope + - scores.scoreClarity + - scores.scoreSoundness; - - const project = current?.project; - const busy = - complete.isPending || skip.isPending; - - return ( -
-
-
- - ← Judging - - {progress.data && ( -

- {progress.data.completed} judged -

- )} -
- - {/* No progress bar: judges draw from a shared pool, so a share of - every eligible project says nothing about how a judge is doing. */} - - {error && ( -
-
- )} - - {done || !project ? ( - // An empty queue is not a finished one: "All done, 0 of 0" told a - // judge with nothing assigned that they had judged everything. Nor is - // an unknown one: "All done" waits until progress says there was work. -
-

- {progress.data?.total === 0 - ? "No tables yet" - : (progress.data?.total ?? 0) > 0 - ? "All done" - : "Your queue"} -

-

- {progress.data?.total === 0 - ? "There are no projects for you to judge yet. Tell an organiser." - : (progress.data?.total ?? 0) > 0 - ? "You have been to every project you can judge. Thank you." - : progress.isError - ? "Couldn't load your progress. Refresh the page." - : "Loading your queue…"} -

-
- ) : ( - <> -
- {project.tableNumber != null && ( -

- Table {project.tableNumber} -

- )} -

- {project.name} -

- {project.description && ( -

- {project.description} -

- )} -
- {[ - { href: project.githubUrl, label: "Repository" }, - { href: project.demoUrl, label: "Live demo" }, - { href: project.videoUrl, label: "Video" }, - ] - .filter((l) => !!l.href) - .map((l) => ( - - {l.label} - - ))} -
-
- - {/* The scoring form stays shut until the table card is scanned. - Opening it earlier is what let the clock run across the walk, - and let a judge score a table they never actually reached. */} - {!arrived ? ( -
-

Scan the team's code to begin

-

- Walk to table{" "} - - {project.tableNumber ?? "?"} - {" "} - and tap your phone on the NFC tag, or scan the card on their - desk. Your judging time starts then, not when this table was - assigned to you. -

-
- - -
-
- ) : ( -
- {/* The clock runs from the tap or scan. Amber at 3:00; at 4:00 - the form locks and the table goes back into the pool. */} -
= TARGET_SECONDS - ? "border-[var(--warning)]" - : "border-[var(--border-subtle)]" - }`} - > - = TARGET_SECONDS - ? "text-[var(--warning)]" - : "text-[var(--text-muted)]" - }`} - > - {timeUp - ? "Time's up" - : elapsed >= TARGET_SECONDS - ? `Wrap up: score locks at ${mmss(HARD_LIMIT_SECONDS)}` - : `Aim for ${mmss(TARGET_SECONDS)}`} - - = TARGET_SECONDS - ? "text-[var(--warning)]" - : "text-[var(--text-primary)]" - }`} - > - {mmss(Math.min(elapsed, HARD_LIMIT_SECONDS))} - -
- -
- {CRITERIA.map((c) => ( -
-
- - - {scores[c.key]} - /10 - -
- - setScores((prev) => ({ - ...prev, - [c.key]: Number(e.target.value), - })) - } - className="block h-11 w-full cursor-pointer accent-[var(--accent)]" - /> -
- ))} -
- -
- -