From d9fd083bcdba3671e7f5e63a8b47b82750876049 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Wed, 5 Aug 2026 23:25:06 -0400 Subject: [PATCH] adding --- README.md | 26 +- apphosting.yaml | 13 + package.json | 2 +- .../hackathon-admin-edge.test.ts | 159 +++- .../.internal-tests/hackathon-flow.test.ts | 11 +- .../src/.internal-tests/judge-edge.test.ts | 339 ++++++-- .../.internal-tests/participant-edge.test.ts | 2 - .../api/src/.internal-tests/routers.test.ts | 16 +- packages/api/src/middleware/db-errors.ts | 25 + packages/api/src/middleware/procedures.ts | 48 +- packages/api/src/middleware/security.ts | 24 +- packages/api/src/routers/admin.ts | 19 +- packages/api/src/routers/hackathon/admin.ts | 577 ++++++++++---- .../api/src/routers/hackathon/announce.ts | 201 +++++ packages/api/src/routers/hackathon/content.ts | 232 ++++-- packages/api/src/routers/hackathon/crud.ts | 71 +- packages/api/src/routers/hackathon/events.ts | 79 +- packages/api/src/routers/hackathon/index.ts | 2 + packages/api/src/routers/judge/admin.ts | 465 +++++------ packages/api/src/routers/judge/portal.ts | 78 +- packages/api/src/routers/judge/rankings.ts | 747 +++++++++++------- packages/api/src/routers/team.ts | 73 +- packages/api/src/services/portal-context.ts | 8 +- packages/api/src/trpc.ts | 73 +- packages/api/src/types/portal-context.ts | 14 + packages/auth/src/config.ts | 21 +- packages/auth/src/email.ts | 146 +++- packages/db/src/schemas/admins.ts | 8 +- packages/db/src/schemas/events.ts | 6 + packages/db/src/schemas/hackathons.ts | 12 + packages/db/src/schemas/judge.ts | 104 ++- packages/db/src/schemas/members.ts | 7 +- .../app/(portal)/admin/analytics/page.tsx | 5 +- .../admin/hackathons/[id]/attendees/page.tsx | 157 ---- .../(portal)/admin/hackathons/[id]/page.tsx | 19 +- .../app/(portal)/admin/projects/page.tsx | 159 ++++ .../mainweb/app/(portal)/admin/setup/page.tsx | 239 +++--- .../(portal)/hackathons/[id]/judge/page.tsx | 52 +- .../hackathons/[id]/participants/page.tsx | 349 -------- sites/mainweb/app/(portal)/judge/page.tsx | 36 - sites/mainweb/app/(portal)/login/page.tsx | 24 +- sites/mainweb/app/(portal)/submit/page.tsx | 115 +++ sites/mainweb/app/(portal)/verify/page.tsx | 12 +- .../admin/hackathons/AnnouncementsTab.tsx | 284 +++++++ .../admin/hackathons/AttendeesTab.tsx | 331 ++++++-- .../admin/hackathons/CreateHackathonForm.tsx | 88 ++- .../admin/hackathons/EditHackathonForm.tsx | 117 ++- .../components/admin/hackathons/EventsTab.tsx | 73 +- .../admin/hackathons/HackathonCard.tsx | 21 +- .../components/admin/hackathons/JudgesTab.tsx | 131 +++ .../admin/hackathons/ScannerTab.tsx | 102 ++- .../components/admin/setup/ImportDataStep.tsx | 254 ------ .../components/portal/PortalSidebar.tsx | 6 + sites/mainweb/lib/safe-callback.test.ts | 31 + sites/mainweb/lib/safe-callback.ts | 18 + turbo.json | 2 + 56 files changed, 4233 insertions(+), 2000 deletions(-) create mode 100644 packages/api/src/middleware/db-errors.ts create mode 100644 packages/api/src/routers/hackathon/announce.ts delete mode 100644 sites/mainweb/app/(portal)/admin/hackathons/[id]/attendees/page.tsx delete mode 100644 sites/mainweb/app/(portal)/hackathons/[id]/participants/page.tsx create mode 100644 sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx delete mode 100644 sites/mainweb/components/admin/setup/ImportDataStep.tsx create mode 100644 sites/mainweb/lib/safe-callback.test.ts create mode 100644 sites/mainweb/lib/safe-callback.ts diff --git a/README.md b/README.md index c3de9958..7fc619e6 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ in `drizzle.config.ts`. | `members.ts` | `user_profile`, `member`, `membership_history` | | `admins.ts` | `admin` | | `hackathons.ts` | `hackathon`, `hackathon_team`, `hackathon_participant`, `hackathon_project`, `hackathon_event`, `hackathon_event_attendee` | -| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue`, `hackathon_map` | +| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue` | | `initiatives.ts` | `project_leader`, `initiative`, `initiative_application` | | `events.ts` | `event`, `event_check_in` | | `stripe.ts` | `stripe_payment`, `user_account_link` | @@ -78,14 +78,24 @@ Two aspects share the database and touch nowhere: `member` is the one crossing case: a paid year still hangs off an edition, so membership resolves the current hackathon even though initiatives do not. -#### One-off step before the first push that carries this +#### One-off step — only for a database that already has the edition-scoped tables -`migrate:push` cannot work this one out on its own. `project_leader` moved from -`unique(user_id, hackathon_id)` to `unique(user_id)`, so anybody appointed in -more than one edition has more than one row; drizzle-kit fails building the new -index partway and leaves the schema half-applied. Run this against the target -database **once, before** the push. Every statement is guarded, so it is safe to -re-run. +**Check first:** + +```sql +SELECT to_regclass('public.project_leader'); +``` + +If that returns `NULL`, this database has never had the club tables. Skip +everything below — `migrate:push` simply creates them in the current shape, and +the statements here would error on tables that do not exist. + +If it returns a table name, `migrate:push` cannot work the change out on its +own. `project_leader` moved from `unique(user_id, hackathon_id)` to +`unique(user_id)`, so anybody appointed in more than one edition has more than +one row; drizzle-kit fails building the new index partway and leaves the schema +half-applied. Run this against that database **once, before** the push. Every +statement is guarded, so it is safe to re-run. ```sql BEGIN; diff --git a/apphosting.yaml b/apphosting.yaml index 1097a4b2..9d2fad38 100644 --- a/apphosting.yaml +++ b/apphosting.yaml @@ -51,3 +51,16 @@ env: value: datascience.gt@gmail.com - variable: CRON_SECRET secret: CRON_SECRET + # Flood-protection thresholds, sized per instance for a full venue. + # These are the ceiling for one signed-in person, not for the building — + # the limiter keys on user id when somebody is signed in. The short block + # duration bounds a false positive to a page refresh rather than locking + # an attendee out for five minutes in the middle of a workshop. + - variable: DDOS_BURST_THRESHOLD + value: "3000" + - variable: DDOS_MAX_REQUESTS_PER_MINUTE + value: "20000" + - variable: DDOS_SUSPICIOUS_THRESHOLD + value: "14000" + - variable: DDOS_BLOCK_DURATION_MS + value: "30000" diff --git a/package.json b/package.json index ec042a06..bdcf132d 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/db" + "test": "vitest run packages/api packages/db sites/mainweb/lib" }, "dependencies": { "next": "16.3.0", 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 07a2aacc..e0e7ca90 100644 --- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts +++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts @@ -56,7 +56,6 @@ vi.mock("@query/db", () => { hackathonProjects: table("hackathonProjects"), hackathonEvents: table("hackathonEvents"), hackathonEventAttendees: table("hackathonEventAttendees"), - hackathonMaps: table("hackathonMaps"), members: table("members"), events: table("events"), eventCheckIns: table("eventCheckIns"), @@ -162,7 +161,6 @@ vi.mock("@query/db", () => { participantId: "participant_id", checkedInAt: "checked_in_at", }, - hackathonMaps: { _t: "hackathonMaps", id: "id", hackathonId: "hackathon_id" }, members: { _t: "members", id: "id", @@ -290,6 +288,77 @@ describe("Hackathon admin management edge cases", () => { return appRouter.createCaller(createMockCtx(ADMIN_USER)); }; + // ===================================================================== + describe("Volunteer scan tier", () => { + const volunteerCaller = (rows: Record = {}) => + adminCaller(rows, "volunteer"); + + /** + * The whole point of the tier. A volunteer holds an admins row, so without + * an explicit role check they would pass every isAdmin gate in the API — + * including the one that deletes the hackathon and cascades every + * participant, team and vote with it. + */ + it("refuses a volunteer every full-staff action", async () => { + const caller = volunteerCaller({ + hackathons: { id: HACK_A, name: "Hacklytics 2027" }, + }); + + await expect( + caller.hackathon.adminGetAttendees({ hackathonId: HACK_A }), + ).rejects.toThrow(/Admin access required/); + + await expect( + caller.hackathon.exportAttendees({ hackathonId: HACK_A }), + ).rejects.toThrow(/Admin access required/); + + await expect( + caller.hackathon.delete({ + hackathonId: HACK_A, + confirmName: "Hacklytics 2027", + }), + ).rejects.toThrow(/Admin access required/); + + await expect( + caller.hackathon.batchUpdateParticipantStatus({ + hackathonId: HACK_A, + participantIds: [PART_A1], + status: "approved", + }), + ).rejects.toThrow(/Admin access required/); + }); + + it("lets a volunteer work a check-in desk", async () => { + const caller = volunteerCaller({ + hackathonEvents: { id: EVENT_A, hackathonId: HACK_A }, + }); + mockFindMany.mockReturnValue([]); + + await expect( + caller.hackathon.getEventAttendees({ + hackathonId: HACK_A, + eventId: EVENT_A, + }), + ).resolves.toMatchObject({ matching: 0 }); + }); + + // Full staff must keep the scan access they already had — the tier is + // additive at the desk, not a replacement for it. + it("still lets full staff scan", async () => { + const caller = adminCaller({ + hackathonEvents: { id: EVENT_A, hackathonId: HACK_A }, + }); + mockFindMany.mockReturnValue([]); + + await expect( + caller.hackathon.getEventAttendees({ + hackathonId: HACK_A, + eventId: EVENT_A, + }), + ).resolves.toBeDefined(); + }); + }); + const liveHackathon = (overrides: Record = {}) => ({ id: HACK_A, name: "Hacklytics 2027", @@ -386,10 +455,10 @@ describe("Hackathon admin management edge cases", () => { const mailed = mockSendAcceptanceEmail.mock.calls.map((c) => c[0].email); expect(mailed).toEqual(["ada@example.com"]); // The B participant's row is never updated, so it must not be counted. - expect(res.count).toBe(1); + expect(res.approved).toBe(1); }); - // BUG: `count` is `participantIds.length`, not the number of rows the + // BUG: `approved` is `participantIds.length`, not the number of rows the // scoped UPDATE actually touched. it("reports how many participants were really approved, not how many ids were pasted", async () => { const caller = adminCaller({ hackathons: { name: "Hacklytics 2027" } }); @@ -404,7 +473,29 @@ describe("Hackathon admin management edge cases", () => { participantIds: [PART_A1, PART_A2, PART_B1], }); - expect(res.count).toBe(2); + expect(res.approved).toBe(2); + }); + + // A send that the provider rejected must not be reported as delivered: + // "sent to 500" when 0 arrived gives the organiser no reason to look again. + it("counts emails that actually left, separately from approvals", async () => { + const caller = adminCaller({ hackathons: { name: "Hacklytics 2027" } }); + mockFindMany.mockReturnValue([ + { id: PART_A1, hackathonId: HACK_A, user: { email: "ada@example.com" } }, + { id: PART_A2, hackathonId: HACK_A, user: { email: "alan@example.com" } }, + ]); + mockSendAcceptanceEmail.mockRejectedValueOnce( + new Error("450 mailbox unavailable"), + ); + + const res = await caller.hackathon.sendMassAcceptanceEmails({ + hackathonId: HACK_A, + participantIds: [PART_A1, PART_A2], + }); + + expect(res.approved).toBe(2); + expect(res.emailed).toBe(1); + expect(res.failedEmails).toEqual(["ada@example.com"]); }); }); @@ -544,6 +635,42 @@ describe("Hackathon admin management edge cases", () => { ).resolves.toBeDefined(); }); + /** + * `undefined` means leave alone, `null` means clear. Without the + * distinction a track list that was once set could never be emptied — the + * edit form would send `[]`, zod would drop it, and the stale value would + * keep routing judges at projects nobody entered for it. + */ + it("clears a field sent as null and leaves omitted ones alone", async () => { + const caller = adminCaller({ hackathons: liveHackathon() }); + mockUpdate.mockReturnValue([{ id: HACK_A }]); + + await caller.hackathon.update({ + id: HACK_A, + tracks: null, + rules: null, + }); + + const written = mockUpdate.mock.calls.at(-1)?.[2]?.[0]; + expect(written).toMatchObject({ tracks: null, rules: null }); + // theme was never sent, so it must not appear in the UPDATE at all. + expect(written).not.toHaveProperty("theme"); + }); + + it("stores the tracks it was given", async () => { + const caller = adminCaller({ hackathons: liveHackathon() }); + mockUpdate.mockReturnValue([{ id: HACK_A }]); + + await caller.hackathon.update({ + id: HACK_A, + tracks: ["AI", "Healthcare"], + }); + + expect(mockUpdate.mock.calls.at(-1)?.[2]?.[0]).toMatchObject({ + tracks: ["AI", "Healthcare"], + }); + }); + // Every child table cascades off this row, so reporting success for an id // that matched nothing hides a delete that never happened. it("refuses to delete a hackathon id that does not exist", async () => { @@ -553,9 +680,29 @@ describe("Hackathon admin management edge cases", () => { mockDelete.mockReturnValue([]); await expect( - caller.hackathon.delete({ hackathonId: HACK_B }), + caller.hackathon.delete({ + hackathonId: HACK_B, + confirmName: "Hacklytics 2027", + }), ).rejects.toThrow(/not found/i); }); + + // Eleven tables cascade off this row. A click-through confirm is one stray + // Enter key; the name has to be typed and has to match. + it("refuses to delete when the typed name does not match", async () => { + const caller = adminCaller({ + hackathons: { id: HACK_A, name: "Hacklytics 2027" }, + }); + + await expect( + caller.hackathon.delete({ + hackathonId: HACK_A, + confirmName: "hacklytics 2026", + }), + ).rejects.toThrow(/exact name/i); + + expect(mockDelete).not.toHaveBeenCalled(); + }); }); // ===================================================================== diff --git a/packages/api/src/.internal-tests/hackathon-flow.test.ts b/packages/api/src/.internal-tests/hackathon-flow.test.ts index 03c653d1..9467217c 100644 --- a/packages/api/src/.internal-tests/hackathon-flow.test.ts +++ b/packages/api/src/.internal-tests/hackathon-flow.test.ts @@ -30,7 +30,6 @@ vi.mock("@query/db", () => { hackathonProjects: table("hackathonProjects"), hackathonEvents: table("hackathonEvents"), hackathonEventAttendees: table("hackathonEventAttendees"), - hackathonMaps: table("hackathonMaps"), members: table("members"), events: table("events"), eventCheckIns: table("eventCheckIns"), @@ -118,7 +117,6 @@ vi.mock("@query/db", () => { eventId: "event_id", participantId: "participant_id", }, - hackathonMaps: { id: "id", hackathonId: "hackathon_id" }, members: { id: "id", userId: "user_id", hackathonId: "hackathon_id" }, membershipHistory: { id: "id", memberId: "member_id" }, events: { @@ -441,8 +439,11 @@ describe("Hackathon end-to-end flow", () => { ).rejects.toThrow(/Event not found/); }); - it("requires admin rights to scan a pass", async () => { - mockFindFirst.mockImplementation(() => undefined); // not an admin + // Scanning is the one action volunteers may take, so it is gated on + // holding any active admins row rather than on being full staff. An + // ordinary participant still has none and is still refused. + it("requires event staff to scan a pass", async () => { + mockFindFirst.mockImplementation(() => undefined); // no admins row at all const caller = appRouter.createCaller(createMockCtx("random_user")); await expect( @@ -451,7 +452,7 @@ describe("Hackathon end-to-end flow", () => { eventId: EVENT_A, participantId: PARTICIPANT, }), - ).rejects.toThrow(/Admin access required/); + ).rejects.toThrow(/Event staff access required/); }); }); diff --git a/packages/api/src/.internal-tests/judge-edge.test.ts b/packages/api/src/.internal-tests/judge-edge.test.ts index 88e4079d..8ebacbc6 100644 --- a/packages/api/src/.internal-tests/judge-edge.test.ts +++ b/packages/api/src/.internal-tests/judge-edge.test.ts @@ -45,6 +45,7 @@ vi.mock("@query/db", () => { "orderBy", "limit", "offset", + "for", ]) { chain[m] = (...a: any[]) => { trace.push([m, a]); @@ -67,7 +68,6 @@ vi.mock("@query/db", () => { hackathonProjects: table("hackathonProjects"), hackathonEvents: table("hackathonEvents"), hackathonEventAttendees: table("hackathonEventAttendees"), - hackathonMaps: table("hackathonMaps"), members: table("members"), events: table("events"), eventCheckIns: table("eventCheckIns"), @@ -76,6 +76,7 @@ vi.mock("@query/db", () => { judgingProjects: table("judgingProjects"), judgeVotes: table("judgeVotes"), judgeQueue: table("judgeQueue"), + hackathonResults: table("hackathonResults"), stripePayments: table("stripePayments"), userAccountLinks: table("userAccountLinks"), auditLogs: table("auditLogs"), @@ -133,13 +134,17 @@ vi.mock("@query/db", () => { registrationStatus: "registration_status", }, hackathonTeams: { id: "id", hackathonId: "hackathon_id", name: "name" }, - hackathonProjects: { id: "id", hackathonId: "hackathon_id" }, + hackathonProjects: { + id: "id", + hackathonId: "hackathon_id", + status: "status", + submittedAt: "submitted_at", + }, hackathonEvents: { id: "id", hackathonId: "hackathon_id", name: "name" }, hackathonEventAttendees: { eventId: "event_id", participantId: "participant_id", }, - hackathonMaps: { id: "id", hackathonId: "hackathon_id", order: "order" }, members: { id: "id", userId: "user_id", hackathonId: "hackathon_id" }, membershipHistory: { id: "id", memberId: "member_id" }, events: { @@ -168,6 +173,7 @@ vi.mock("@query/db", () => { judgingProjects: { id: "id", hackathonId: "hackathon_id", + sourceProjectId: "source_project_id", tableNumber: "table_number", tracks: "tracks", challenges: "challenges", @@ -180,6 +186,14 @@ vi.mock("@query/db", () => { score: "score", durationSeconds: "duration_seconds", }, + hackathonResults: { + id: "id", + hackathonId: "hackathon_id", + projectId: "project_id", + track: "track", + placement: "placement", + publishedAt: "published_at", + }, judgeQueue: { id: "id", judgeId: "judge_id", @@ -511,10 +525,21 @@ describe("Judge edge cases", () => { // ===================================================================== describe("5. forceSkipOvertime reassignment", () => { + /** + * Candidate selection now runs two set-based queries rather than two per + * candidate: who already holds this project, and each judge's uncompleted + * count. The mocks mirror that shape — feeding the old per-candidate + * counts here would make these tests pass without exercising the sort. + */ const wireForceSkip = (opts: { myAssignment?: Record; others: Record[]; - remaining: number[]; + /** judgeIds already holding the skipped project */ + holders?: string[]; + /** judgeId -> uncompleted queue length */ + remaining?: Record; + /** the judge's own next uncompleted slot, if any */ + next?: Record; }) => { const nextQueue = seq([ { id: QUEUE_A, hackathonId: HACK_A }, // isJudge middleware lookup @@ -525,7 +550,8 @@ describe("Judge edge cases", () => { projectId: PROJECT_A, project: { id: PROJECT_A, tracks: [] }, }, - // one "already queued?" lookup per candidate — all undefined + // the "what do I do next" lookup at the end + opts.next, ]); mockFindFirst.mockImplementation((table: string) => { if (table === "judges") return JUDGE_ROW; @@ -540,9 +566,15 @@ describe("Judge edge cases", () => { mockFindMany.mockImplementation((table: string) => table === "judgeAssignments" ? opts.others : [], ); - for (const n of opts.remaining) { - mockSelect.mockReturnValueOnce([{ count: n }]); - } + mockSelect.mockReturnValueOnce( + (opts.holders ?? []).map((judgeId) => ({ judgeId })), + ); + mockSelect.mockReturnValueOnce( + Object.entries(opts.remaining ?? {}).map(([judgeId, remaining]) => ({ + judgeId, + remaining, + })), + ); }; // BUG: portal.ts:428-486 draws candidates from every judgeAssignments row @@ -565,7 +597,7 @@ describe("Judge edge cases", () => { judge: { id: "active_judge", isActive: true }, }, ], - remaining: [0, 4], + remaining: { inactive_judge: 0, active_judge: 4 }, }); await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A }); @@ -574,6 +606,61 @@ describe("Judge edge cases", () => { expect(reassigned?.judgeId).toBe("active_judge"); }); + // A judge already holding this project must not be handed it twice — they + // would see the same table appear again later in their own queue. + it("never hands the project to a judge who already has it", async () => { + wireForceSkip({ + others: [ + { + judgeId: "has_it", + track: null, + judge: { id: "has_it", isActive: true }, + }, + { + judgeId: "free_judge", + track: null, + judge: { id: "free_judge", isActive: true }, + }, + ], + holders: [JUDGE_ID, "has_it"], + remaining: { has_it: 0, free_judge: 9 }, + }); + + await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A }); + + const reassigned = insertedRows().find( + (r: any) => r.projectId === PROJECT_A, + ); + expect(reassigned?.judgeId).toBe("free_judge"); + }); + + // Between two eligible judges the lighter queue wins, so the reassigned + // project is actually reached before judging closes. + it("prefers the judge with the fewest projects left", async () => { + wireForceSkip({ + others: [ + { + judgeId: "busy", + track: null, + judge: { id: "busy", isActive: true }, + }, + { + judgeId: "light", + track: null, + judge: { id: "light", isActive: true }, + }, + ], + remaining: { busy: 11, light: 2 }, + }); + + await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A }); + + const reassigned = insertedRows().find( + (r: any) => r.projectId === PROJECT_A, + ); + expect(reassigned?.judgeId).toBe("light"); + }); + // BUG: portal.ts:422-424 loads myAssignment with no hackathonId filter and // then uses myAssignment.hackathonId (not queueItem.hackathonId) for the // reassignment row, orphaning it in the wrong hackathon. @@ -588,7 +675,7 @@ describe("Judge edge cases", () => { judge: { id: "active_judge", isActive: true }, }, ], - remaining: [1], + remaining: { active_judge: 1 }, }); await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A }); @@ -597,11 +684,40 @@ describe("Judge edge cases", () => { expect(reassigned?.hackathonId).toBe(HACK_A); }); + // Both siblings (completeAndNext, skipProject) stamp startedAt on the slot + // they hand over. Without it here the next table stays unclaimed and the + // following judge to ask for work is sent to the table this judge just + // walked up to. + it("claims the table it hands the judge next", async () => { + wireForceSkip({ + others: [], + next: { + id: "queue_next", + judgeId: JUDGE_ID, + hackathonId: HACK_A, + projectId: "project_next", + project: { id: "project_next", tracks: [] }, + }, + }); + + const res = await judgeCaller().judge.forceSkipOvertime({ + queueId: QUEUE_A, + }); + + expect(res.queueId).toBe("queue_next"); + const claimed = mockUpdate.mock.calls.some( + (call: any) => + call[2]?.[0]?.startedAt instanceof Date && + !("isCompleted" in (call[2]?.[0] ?? {})), + ); + expect(claimed).toBe(true); + }); + // BUG: with no judgeAssignments row the whole reassignment block is // skipped (portal.ts:426) yet the response still looks like a success, so // the project is dropped with nobody left to judge it. it("reports that nothing was reassigned when the judge has no assignment row", async () => { - wireForceSkip({ myAssignment: undefined, others: [], remaining: [] }); + wireForceSkip({ myAssignment: undefined, others: [] }); const res = await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A, @@ -742,9 +858,16 @@ describe("Judge edge cases", () => { // ===================================================================== describe("7. initializeQueue track filtering", () => { - const wireInit = (track: string, projects: Record[]) => { + const wireInit = ( + track: string, + projects: Record[], + judgeHackathonId: string = HACK_A, + ) => { mockFindFirst.mockImplementation((table: string) => { if (table === "admins") return ADMIN_ROW; + // The judge's own edition. initializeQueue reads this to refuse + // building a queue nobody could ever open. + if (table === "judges") return { hackathonId: judgeHackathonId }; if (table === "judgeAssignments") return { judgeId: JUDGE_ID, hackathonId: HACK_A, track }; return undefined; @@ -799,6 +922,26 @@ describe("Judge edge cases", () => { expect(res.projectCount).toBe(1); }); + + /** + * A judges row belongs to one hackathon and isJudge authorizes against it, + * so a queue built across editions can never be opened — the projects in + * it are simply never scored, with nothing anywhere reporting a problem. + * assignToHackathon already refuses this; this path did not. + */ + it("refuses to build a queue for a judge from another hackathon", async () => { + wireInit("Sports", pool, HACK_B); + + await expect( + adminCaller().judge.initializeQueue({ + judgeId: JUDGE_ID, + hackathonId: HACK_A, + shuffle: false, + }), + ).rejects.toThrow(/different hackathon/i); + + expect(mockDelete).not.toHaveBeenCalled(); + }); }); // ===================================================================== @@ -858,58 +1001,96 @@ describe("Judge edge cases", () => { }); // ===================================================================== - describe("9. Bulk import", () => { - const wireExistingJudge = () => { - mockFindFirst.mockImplementation((table: string) => { - if (table === "admins") return ADMIN_ROW; - if (table === "users") return { id: "u1", email: "ada@example.com" }; - if (table === "judges") return { id: JUDGE_ID, userId: "u1" }; - if (table === "judgeAssignments") - return { judgeId: JUDGE_ID, hackathonId: HACK_A }; - return undefined; - }); - }; + describe("9. Promoting submissions into judging", () => { + const asAdmin = () => + mockFindFirst.mockImplementation((table: string) => + table === "admins" ? ADMIN_ROW : undefined, + ); - const importOne = () => - adminCaller().judge.bulkImportJudges({ + 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, - judges: [{ name: "Ada", email: "ada@example.com" }], }); - it("writes nothing when the judge, user and assignment already exist", async () => { - wireExistingJudge(); + 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, + }); - await importOne(); + expect(res).toMatchObject({ created: 1, alreadyPresent: 1, total: 2 }); - expect(mockInsert).not.toHaveBeenCalled(); + 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); }); - // BUG: admin.ts:309 increments results.created for every row that did not - // throw, including rows where nothing was created, so the admin is told - // judges were imported when none were. - it("counts only judges that were actually created", async () => { - wireExistingJudge(); + // 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 }]); - const res = await importOne(); + await adminCaller().judge.promoteSubmissions({ hackathonId: HACK_A }); - expect(res.created).toBe(0); + const rows = mockInsert.mock.calls[0]?.[2]?.[0]; + expect(rows[0].teamMembers).toBe("Ada, Grace"); }); - // BUG: admin.ts:366-369 calls .values(rows) unconditionally; an empty CSV - // produces .values([]) which Drizzle rejects, turning a plausible admin - // action into a 500. - it("returns a zero-row result for an empty project import", async () => { - mockFindFirst.mockImplementation((table: string) => - table === "admins" ? ADMIN_ROW : undefined, + // Queues are built from a snapshot of the project list. A project promoted + // afterwards is in nobody's queue and would never be judged, silently. + it("warns when queues already exist and new projects were added", async () => { + asAdmin(); + mockFindMany.mockImplementation((table: string) => + table === "hackathonProjects" ? [submission("s1")] : [], ); + mockSelect.mockResolvedValue([{ count: 12 }]); - const res = await adminCaller().judge.bulkImportProjects({ + const res = await adminCaller().judge.promoteSubmissions({ hackathonId: HACK_A, - projects: [], }); - expect(res.created).toBe(0); - expect(mockInsert).not.toHaveBeenCalled(); + expect(res.queuesNeedRebuild).toBe(true); }); }); @@ -1155,4 +1336,66 @@ describe("Judge edge cases", () => { expect(claimWrite).toBeDefined(); }); }); -}); + + // ===================================================================== + describe("12. Freezing results", () => { + const wireResults = (opts: { + judgingActive?: boolean; + published?: 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; + 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(); + }); + + it("computes once judging has closed", async () => { + wireResults({ judgingActive: false }); + + const res = await adminCaller().judge.computeResults({ + hackathonId: HACK_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); + }); + + 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); + }); + }); +}); \ No newline at end of file diff --git a/packages/api/src/.internal-tests/participant-edge.test.ts b/packages/api/src/.internal-tests/participant-edge.test.ts index d71f3384..9fe5f807 100644 --- a/packages/api/src/.internal-tests/participant-edge.test.ts +++ b/packages/api/src/.internal-tests/participant-edge.test.ts @@ -48,7 +48,6 @@ vi.mock("@query/db", async () => { hackathonProjects: table("hackathonProjects"), hackathonEvents: table("hackathonEvents"), hackathonEventAttendees: table("hackathonEventAttendees"), - hackathonMaps: table("hackathonMaps"), members: table("members"), membershipHistory: table("membershipHistory"), events: table("events"), @@ -159,7 +158,6 @@ vi.mock("@query/db", async () => { eventId: "event_id", participantId: "participant_id", }, - hackathonMaps: { id: "id", hackathonId: "hackathon_id" }, members: { id: "id", userId: "user_id", diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts index 802c8384..2b0b1082 100644 --- a/packages/api/src/.internal-tests/routers.test.ts +++ b/packages/api/src/.internal-tests/routers.test.ts @@ -106,10 +106,6 @@ vi.mock("@query/db", () => { findFirst: (...args: any[]) => mockFindFirst("judgeQueue", ...args), findMany: (...args: any[]) => mockFindMany("judgeQueue", ...args), }, - hackathonMaps: { - findFirst: (...args: any[]) => mockFindFirst("hackathonMaps", ...args), - findMany: (...args: any[]) => mockFindMany("hackathonMaps", ...args), - }, stripePayments: { findFirst: (...args: any[]) => mockFindFirst("stripePayments", ...args), @@ -271,10 +267,6 @@ vi.mock("@query/db", () => { hackathonId: "hackathon_id", isCompleted: "is_completed", }, - hackathonMaps: { - id: "id", - hackathonId: "hackathon_id", - }, stripePayments: { id: "id", customerEmail: "customer_email", @@ -1091,11 +1083,17 @@ describe("Router Integration and Access Control Verification Suite", () => { if (table === "admins") { return { id: "admin_1", userId: "admin_user_id", role: "admin", isActive: true }; } + if (table === "hackathons") { + return { id: hackathonId, name: "Test Hackathon" }; + } return null; }); const caller = appRouter.createCaller(ctx); - const res = await caller.hackathon.delete({ hackathonId }); + const res = await caller.hackathon.delete({ + hackathonId, + confirmName: "Test Hackathon", + }); expect(res.success).toBe(true); expect(mockDelete).toHaveBeenCalled(); }); diff --git a/packages/api/src/middleware/db-errors.ts b/packages/api/src/middleware/db-errors.ts new file mode 100644 index 00000000..966cfb19 --- /dev/null +++ b/packages/api/src/middleware/db-errors.ts @@ -0,0 +1,25 @@ +/** + * Postgres unique_violation. Drizzle wraps every driver error in a + * DrizzleQueryError, which carries no `code` — the pg error holding the + * SQLSTATE sits on `.cause` — so the chain has to be walked. Checking only the + * top-level object silently never matches in production, however well it works + * against a mock that throws a bare `{ code: "23505" }`. + */ +const hasSqlState = (error: unknown, code: string) => { + for (let cursor = error, depth = 0; cursor && depth < 5; depth++) { + if (typeof cursor !== "object") break; + if ((cursor as { code?: string }).code === code) return true; + cursor = (cursor as { cause?: unknown }).cause; + } + return false; +}; + +export const isUniqueViolation = (error: unknown) => hasSqlState(error, "23505"); + +/** + * Postgres foreign_key_violation. Raised when an ON DELETE RESTRICT reference + * still points at the row being deleted — which is exactly what protects paid + * club memberships from a hackathon delete. + */ +export const isForeignKeyViolation = (error: unknown) => + hasSqlState(error, "23503"); diff --git a/packages/api/src/middleware/procedures.ts b/packages/api/src/middleware/procedures.ts index 7ba1282c..3ec73fec 100644 --- a/packages/api/src/middleware/procedures.ts +++ b/packages/api/src/middleware/procedures.ts @@ -10,6 +10,7 @@ import { import { eq, and } from "drizzle-orm"; import { CacheKeys } from "./cache"; import { resolveHackathonId } from "../services/portal-context"; +import { isStaffRole } from "../types/portal-context"; import type { Context } from "../context"; /** @@ -32,23 +33,27 @@ export const callerIsAdmin = async (ctx: Context) => { where: and(eq(admins.userId, ctx.userId), eq(admins.isActive, true)), }); - ctx.cache.set(cacheKey, !!admin, 60); + const isStaff = !!admin && admin.role !== "volunteer"; - return !!admin; + ctx.cache.set(cacheKey, isStaff, 60); + + return isStaff; }; + /** - * Middleware that verifies the current user is an active admin. - * Result is cached for 60s per user to avoid a DB round-trip on every request. + * Loads the caller's active admin row, cached 60s per user. + * + * Shared by isScanner and isAdmin so a check-in station and a staff action + * cost the same single lookup. */ -export const isAdmin = protectedProcedure.use(async ({ ctx, next }) => { +const loadAdminRow = async (ctx: Context) => { const cacheKey = `${CacheKeys.admin(ctx.userId as string)}:role`; let admin = ctx.cache.get(cacheKey); if (!admin) { admin = (await (ctx.db as NonNullable).query.admins.findFirst({ - // try catch for ctx.db where: and( eq(admins.userId, ctx.userId as string), eq(admins.isActive, true), @@ -58,7 +63,38 @@ export const isAdmin = protectedProcedure.use(async ({ ctx, next }) => { if (admin) ctx.cache.set(cacheKey, admin, 60); } + return admin; +}; + +/** + * Anyone staffing the event, volunteers included. + * + * Scoped to badge scanning and its undo. A 2000-person event runs several + * check-in stations, and the people on them should not need the role that can + * delete the hackathon and cascade every participant, team and vote with it. + */ +export const isScanner = protectedProcedure.use(async ({ ctx, next }) => { + const admin = await loadAdminRow(ctx); + if (!admin) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Event staff access required", + }); + } + + return next({ ctx: { ...ctx, admin } }); +}); + +/** + * Full staff. Volunteers are deliberately rejected here — they hold an admins + * row, so without the role check they would pass every admin gate in the API. + * Result is cached for 60s per user to avoid a DB round-trip on every request. + */ +export const isAdmin = protectedProcedure.use(async ({ ctx, next }) => { + const admin = await loadAdminRow(ctx); + + if (!admin || !isStaffRole(admin.role)) { throw new TRPCError({ code: "FORBIDDEN", message: "Admin access required", diff --git a/packages/api/src/middleware/security.ts b/packages/api/src/middleware/security.ts index 0fb6a0e6..fd443f43 100644 --- a/packages/api/src/middleware/security.ts +++ b/packages/api/src/middleware/security.ts @@ -523,14 +523,21 @@ export function getRecentSecurityEvents(minutes: number = 60): SecurityEvent[] { return securityLog.filter((e) => e.timestamp > cutoff); } -export function ddosProtection(clientIp: string): { +/** + * Coarse per-caller flood protection. + * + * `key` is an identity when we have one and an address only when we do not — + * callers must prefix it (`user:` / `ip:`) so the two namespaces can never + * collide. Keying on the address alone puts an entire venue behind one NAT into + * a single bucket, which is exactly the crowd this is supposed to serve. + */ +export function ddosProtection(key: string): { allowed: boolean; retryAfter?: number; } { const now = Date.now(); - // Get or create IP record - let record = ipTrackingStore.get(clientIp); + let record = ipTrackingStore.get(key); if (!record) { record = { requests: 0, @@ -539,15 +546,14 @@ export function ddosProtection(clientIp: string): { isBlocked: false, blockedUntil: 0, }; - ipTrackingStore.set(clientIp, record); + ipTrackingStore.set(key, record); } - // Check if IP is blocked if (record.isBlocked && now < record.blockedUntil) { logSecurityEvent({ type: "rate_limit", - identifier: clientIp, - details: `Blocked IP attempted access`, + identifier: key, + details: `Blocked caller attempted access`, }); return { allowed: false, @@ -576,7 +582,7 @@ export function ddosProtection(clientIp: string): { logSecurityEvent({ type: "rate_limit", - identifier: clientIp, + identifier: key, details: `Burst attack detected: ${record.requests} requests in ${elapsed}ms`, }); @@ -594,7 +600,7 @@ export function ddosProtection(clientIp: string): { logSecurityEvent({ type: "rate_limit", - identifier: clientIp, + identifier: key, details: `Sustained attack: ${record.requests} requests/minute`, }); diff --git a/packages/api/src/routers/admin.ts b/packages/api/src/routers/admin.ts index 33a3a5cb..12d1f0e2 100644 --- a/packages/api/src/routers/admin.ts +++ b/packages/api/src/routers/admin.ts @@ -56,6 +56,19 @@ export const adminRouter = createTRPCRouter({ }), analyticsOverview: isAdmin.query(async ({ ctx }) => { + // The analytics page polls this every 5s and leaves it open all weekend. + // Five uncached aggregates per poll per open dashboard is a standing load + // for numbers nobody watches change second by second; a 15s entry means at + // most one round of aggregates per 15s no matter how many tabs are up. + const cacheKey = "admin:analytics-overview"; + const cached = ctx.cache.get<{ + totalParticipants: number; + totalEvents: number; + totalHackathons: number; + checkinsToday: number; + }>(cacheKey); + if (cached !== null) return cached; + const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0); @@ -87,13 +100,17 @@ export const adminRouter = createTRPCRouter({ .where(gte(eventCheckIns.checkedInAt, startOfToday)), ]); - return { + 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), }; + + ctx.cache.set(cacheKey, result, 15); + + return result; }), list: isAdmin.query(async ({ ctx }) => { diff --git a/packages/api/src/routers/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts index b5b917f0..32feaf73 100644 --- a/packages/api/src/routers/hackathon/admin.ts +++ b/packages/api/src/routers/hackathon/admin.ts @@ -1,12 +1,14 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { createTRPCRouter } from "../../trpc"; -import { isAdmin } from "../../middleware/procedures"; +import { isAdmin, isScanner } from "../../middleware/procedures"; +import { isUniqueViolation } from "../../middleware/db-errors"; import { hackathons, hackathonParticipants, hackathonEvents, hackathonEventAttendees, + users, } from "@query/db"; import { eq, and, inArray, sql } from "drizzle-orm"; import type { DrizzleDB } from "@query/db"; @@ -41,19 +43,78 @@ const syncCurrentParticipants = (db: DrizzleDB, hackathonId: string) => }); /** - * Postgres unique_violation. Drizzle wraps every driver error in a - * DrizzleQueryError, which carries no `code` — the pg error holding the - * SQLSTATE sits on `.cause` — so the chain has to be walked. Checking only the - * top-level object silently never matches in production, however well it works - * against a mock that throws a bare `{ code: "23505" }`. + * Evicts exactly the keys a participant status change moves. + * + * The old `deletePattern("hackathon*")` matched both the `hackathon:` and + * `hackathons:` namespaces, so a single badge scan wiped every attendee's + * cached registrations and the events list the whole venue reads. At 2000 + * people that turns a once-per-TTL query into a per-request one, during the + * hour the schedule page is busiest. + * + * Each affected user's own registration list has to go too, or an acceptance + * lands in somebody's inbox while their dashboard still says pending. + */ +const evictParticipantCaches = ( + cache: { delete: (key: string) => boolean }, + hackathonId: string, + userIds: string[], +) => { + cache.delete(`hackathon:${hackathonId}:participants`); + cache.delete(`hackathon:${hackathonId}:analytics`); + for (const userId of new Set(userIds)) { + cache.delete(`hackathon:registrations:${userId}`); + } +}; + +const PARTICIPANT_STATUSES = z.enum([ + "pending", + "approved", + "rejected", + "waitlisted", + "checked_in", +]); + +/** + * The WHERE shared by the paged roster and the CSV export, so the file an + * organiser downloads always matches the list they were looking at. + * + * Search covers the same fields the old client-side filter did. ILIKE rather + * than lower(...) LIKE because it reads as what it is; neither uses an index + * at this row count, and 2000 rows is well inside what a scan handles. */ -const isUniqueViolation = (error: unknown) => { - for (let cursor = error, depth = 0; cursor && depth < 5; depth++) { - if (typeof cursor !== "object") break; - if ((cursor as { code?: string }).code === "23505") return true; - cursor = (cursor as { cause?: unknown }).cause; +const buildAttendeeWhere = (input: { + hackathonId: string; + search?: string; + status?: z.infer; +}) => { + const clauses = [eq(hackathonParticipants.hackathonId, input.hackathonId)]; + + if (input.status) { + clauses.push(eq(hackathonParticipants.registrationStatus, input.status)); + } + + const term = input.search?.trim(); + if (term) { + // Escaped so a literal % or _ in somebody's name searches for that + // character instead of turning into a wildcard. + const pattern = `%${term.replace(/[\\%_]/g, (c) => `\\${c}`)}%`; + clauses.push( + sql`( + ${hackathonParticipants.firstName} ilike ${pattern} + or ${hackathonParticipants.lastName} ilike ${pattern} + or ${hackathonParticipants.school} ilike ${pattern} + or ${hackathonParticipants.major} ilike ${pattern} + or ${hackathonParticipants.whyAttend} ilike ${pattern} + or exists ( + select 1 from ${users} + where ${users.id} = ${hackathonParticipants.userId} + and (${users.name} ilike ${pattern} or ${users.email} ilike ${pattern}) + ) + )`, + ); } - return false; + + return and(...clauses); }; export const hackathonAdminRouter = createTRPCRouter({ @@ -61,33 +122,76 @@ export const hackathonAdminRouter = createTRPCRouter({ .input( z.object({ hackathonId: z.string().uuid("Invalid hackathon ID"), + limit: z.number().int().min(1).max(200).default(50), + offset: z.number().int().min(0).default(0), + search: z.string().trim().max(200).optional(), + status: PARTICIPANT_STATUSES.optional(), }), ) .query(async ({ ctx, input }) => { - const attendees = await ( - ctx.db as DrizzleDB - ).query.hackathonParticipants.findMany({ - where: eq(hackathonParticipants.hackathonId, input.hackathonId), - with: { - user: { - columns: { - id: true, - name: true, - email: true, - image: true, - }, - }, - team: { - columns: { - id: true, - name: true, + const db = ctx.db as DrizzleDB; + + // Filtering happens in the database, not in the browser. The old version + // shipped every participant row — 35 columns including resumes, phone + // numbers and 2000-character essays — so the client could filter an array + // it had already downloaded. At 2000 attendees that is megabytes of PII + // per keystroke-triggered refetch. + const where = buildAttendeeWhere(input); + + const [rows, [totals]] = await Promise.all([ + db.query.hackathonParticipants.findMany({ + where, + with: { + user: { + columns: { id: true, name: true, email: true, image: true }, }, + team: { columns: { id: true, name: true } }, }, + orderBy: (participants, { desc }) => [desc(participants.registeredAt)], + limit: input.limit, + offset: input.offset, + }), + db + .select({ count: sql`count(*)::int` }) + .from(hackathonParticipants) + .where(where), + ]); + + return { + attendees: rows, + // How many match the current filter, so the pager knows where it ends. + // Deliberately not the unfiltered total: those are different numbers + // and conflating them makes the last page unreachable. + matching: totals?.count ?? 0, + limit: input.limit, + offset: input.offset, + }; + }), + + /** + * The whole filtered roster, for CSV export. + * + * Its own endpoint rather than a flag on adminGetAttendees so the one call + * that hands over every attendee's PII is explicit at the call site and can + * be audited or restricted on its own later. + */ + exportAttendees: isAdmin + .input( + z.object({ + hackathonId: z.string().uuid("Invalid hackathon ID"), + search: z.string().trim().max(200).optional(), + status: PARTICIPANT_STATUSES.optional(), + }), + ) + .query(async ({ ctx, input }) => { + return await (ctx.db as DrizzleDB).query.hackathonParticipants.findMany({ + where: buildAttendeeWhere(input), + with: { + user: { columns: { id: true, name: true, email: true } }, + team: { columns: { id: true, name: true } }, }, orderBy: (participants, { desc }) => [desc(participants.registeredAt)], }); - - return attendees; }), @@ -136,7 +240,7 @@ export const hackathonAdminRouter = createTRPCRouter({ await syncCurrentParticipants(ctx.db as DrizzleDB, input.hackathonId); - ctx.cache.deletePattern("hackathon*"); + evictParticipantCaches(ctx.cache, input.hackathonId, [participant.userId]); return { success: true }; }), @@ -146,7 +250,11 @@ export const hackathonAdminRouter = createTRPCRouter({ .input( z.object({ hackathonId: z.string().uuid("Invalid hackathon ID"), - participantIds: z.array(z.string().uuid()).min(1), + // Each id is one SMTP round trip. 500 is roughly what fits inside a + // Cloud Run request, and it matches the daily ceiling of the consumer + // Gmail account this currently sends through — the UI chunks a larger + // selection rather than handing the request a batch it cannot finish. + participantIds: z.array(z.string().uuid()).min(1).max(500), }), ) .mutation(async ({ ctx, input }) => { @@ -180,49 +288,86 @@ export const hackathonAdminRouter = createTRPCRouter({ }) ).filter((participant) => participant.hackathonId === hackathonId); - await db.transaction(async (tx) => { - for (const participant of participants) { - await tx - .update(hackathonParticipants) - .set({ registrationStatus: "approved", updatedAt: new Date() }) - .where( - and( - eq(hackathonParticipants.id, participant.id), - eq(hackathonParticipants.hackathonId, hackathonId), - ), - ); - } - }); + if (participants.length === 0) { + return { + success: true, + approved: 0, + emailed: 0, + failedEmails: [] as string[], + skipped: participantIds.length, + message: `None of the ${participantIds.length} id(s) are registered for this hackathon.`, + }; + } + + // One statement rather than one per recipient: this runs against the + // full accepted list, and a 500-round-trip transaction holds a pool + // connection for its whole duration. + await db + .update(hackathonParticipants) + .set({ registrationStatus: "approved", updatedAt: new Date() }) + .where( + and( + inArray( + hackathonParticipants.id, + participants.map((participant) => participant.id), + ), + eq(hackathonParticipants.hackathonId, hackathonId), + ), + ); // Approving a rejected or waitlisted applicant hands a seat back out. await syncCurrentParticipants(db, hackathonId); + const { sendAcceptanceEmail } = await import("@query/auth/email"); + + let emailed = 0; + const failedEmails: string[] = []; + for (const participant of participants) { - if (participant.user?.email) { - try { - const { sendAcceptanceEmail } = await import("@query/auth/email"); - await sendAcceptanceEmail({ - email: participant.user.email, - hackathonName: hackathon.name, - host: process.env.NEXTAUTH_URL || "https://datasciencegt.org" - }); - // Deliberate server-side operational logging: acceptance emails are - // sent in a loop and individual failures are swallowed below, so - // these lines are the only record of what actually went out. - // eslint-disable-next-line no-console - console.log(`[Email Service] Sent acceptance email to ${participant.user.email} for hackathon ${hackathon.name}.`); - } catch (error) { - // eslint-disable-next-line no-console - console.error(`[Email Service] Failed to send acceptance email to ${participant.user.email}:`, error); - } + if (!participant.user?.email) continue; + try { + await sendAcceptanceEmail({ + email: participant.user.email, + hackathonName: hackathon.name, + host: process.env.NEXTAUTH_URL || "https://datasciencegt.org" + }); + // Stamped one row at a time, immediately after the send. A batch of + // hundreds can die partway through — Cloud Run kills the request at + // 300s — and this marker is what keeps a retry from mailing everyone + // who already heard from us a second time. + await db + .update(hackathonParticipants) + .set({ acceptanceEmailSentAt: new Date() }) + .where(eq(hackathonParticipants.id, participant.id)); + emailed++; + } catch (error) { + failedEmails.push(participant.user.email); + // Deliberate server-side operational logging: this is the only record + // of which address the provider rejected. + // eslint-disable-next-line no-console + console.error(`[Email Service] Failed to send acceptance email to ${participant.user.email}:`, error); } } - ctx.cache.deletePattern("hackathon*"); + evictParticipantCaches( + ctx.cache, + hackathonId, + participants.map((participant) => participant.userId), + ); const skipped = participantIds.length - participants.length; - return { success: true, count: participants.length, skipped, message: `Successfully approved and sent acceptance emails to ${participants.length} participants.${skipped > 0 ? ` ${skipped} id(s) are not registered for this hackathon and were skipped.` : ""}` }; + // Approved and emailed are reported separately because they genuinely + // differ: the provider throttles, addresses bounce, and an organiser told + // "sent to 500" when 80 were delivered has no reason to look again. + return { + success: true, + approved: participants.length, + emailed, + failedEmails, + skipped, + message: `Approved ${participants.length} participant(s); ${emailed} acceptance email(s) sent.${failedEmails.length > 0 ? ` ${failedEmails.length} could not be delivered.` : ""}${skipped > 0 ? ` ${skipped} id(s) are not registered for this hackathon and were skipped.` : ""}`, + }; }), @@ -230,7 +375,10 @@ export const hackathonAdminRouter = createTRPCRouter({ .input( z.object({ hackathonId: z.string().uuid("Invalid hackathon ID"), - participantIds: z.array(z.string().uuid()).min(1).max(500), + // Sized for one organiser selecting every applicant at a 2000-person + // event. The bound stays — an unbounded array is a memory ceiling, not + // a feature — but 500 silently rejected the whole selection. + participantIds: z.array(z.string().uuid()).min(1).max(2500), status: z.enum([ "pending", "approved", @@ -243,102 +391,241 @@ export const hackathonAdminRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const { hackathonId, participantIds, status } = input; - // Each UPDATE is scoped by (id, hackathonId), so an id pasted from - // another hackathon matches nothing. The caller is told how many rows - // really changed rather than how many ids were submitted. - const updated = await (ctx.db as DrizzleDB).transaction(async (tx) => { - let changed = 0; - for (const participantId of participantIds) { - const rows = await tx - .update(hackathonParticipants) - .set({ - registrationStatus: status, - updatedAt: new Date(), - // coalesce so a batch that re-checks in someone who already - // arrived keeps their original arrival time. `at time zone 'utc'` - // because the column is timestamp-without-tz and drizzle reads it - // back as UTC — a bare now() would be cast through the session - // TimeZone and disagree with the `new Date()` that - // updateParticipantStatus writes for the very same event. - ...(status === "checked_in" - ? { - checkedInAt: sql`coalesce(${hackathonParticipants.checkedInAt}, now() at time zone 'utc')`, - } - : {}), - }) - .where( - and( - eq(hackathonParticipants.id, participantId), - eq(hackathonParticipants.hackathonId, hackathonId), - ), - ) - .returning({ id: hackathonParticipants.id }); - changed += rows.length; - } - return changed; - }); + // One statement, not one per id: 2000 sequential round trips would hold a + // pool connection open for the whole batch. Scoping by (id, hackathonId) + // is preserved exactly by the AND, so an id pasted from another hackathon + // still matches nothing, and the caller is told how many rows really + // changed rather than how many ids were submitted. + const rows = await (ctx.db as DrizzleDB) + .update(hackathonParticipants) + .set({ + registrationStatus: status, + updatedAt: new Date(), + // coalesce so a batch that re-checks in someone who already arrived + // keeps their original arrival time. `at time zone 'utc'` because the + // column is timestamp-without-tz and drizzle reads it back as UTC — a + // bare now() would be cast through the session TimeZone and disagree + // with the `new Date()` that updateParticipantStatus writes for the + // very same event. + ...(status === "checked_in" + ? { + checkedInAt: sql`coalesce(${hackathonParticipants.checkedInAt}, now() at time zone 'utc')`, + } + : {}), + }) + .where( + and( + inArray(hackathonParticipants.id, participantIds), + eq(hackathonParticipants.hackathonId, hackathonId), + ), + ) + .returning({ + id: hackathonParticipants.id, + userId: hackathonParticipants.userId, + }); await syncCurrentParticipants(ctx.db as DrizzleDB, hackathonId); - ctx.cache.deletePattern("hackathon*"); + evictParticipantCaches( + ctx.cache, + hackathonId, + rows.map((row) => row.userId), + ); - return { success: true, updated }; + return { success: true, updated: rows.length }; }), analytics: isAdmin .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") })) .query(async ({ ctx, input }) => { - const participants = await ( - ctx.db as DrizzleDB - ).query.hackathonParticipants.findMany({ - where: eq(hackathonParticipants.hackathonId, input.hackathonId), - }); - - const stats = { - totalRegistrations: participants.length, - statusBreakdown: { - approved: 0, - pending: 0, - rejected: 0, - waitlisted: 0, - checked_in: 0, - }, - shirtSizes: {} as Record, - dietaryRestrictions: {} as Record, + const db = ctx.db as DrizzleDB; + const scope = eq(hackathonParticipants.hackathonId, input.hackathonId); + + // Counted by the database. This used to load every participant row — + // all 35 columns, including the essays — to produce a handful of + // integers, and it backs both the stat tiles and the analytics page. + const [byStatus, bySize, byDiet] = await Promise.all([ + db + .select({ + status: hackathonParticipants.registrationStatus, + count: sql`count(*)::int`, + }) + .from(hackathonParticipants) + .where(scope) + .groupBy(hackathonParticipants.registrationStatus), + db + .select({ + size: hackathonParticipants.shirtSize, + count: sql`count(*)::int`, + }) + .from(hackathonParticipants) + .where(scope) + .groupBy(hackathonParticipants.shirtSize), + // unnest so each restriction in the array counts once, rather than + // pulling every array back to be flattened in JS. + db + .select({ + restriction: sql`btrim(restriction)`.as("restriction"), + count: sql`count(*)::int`, + }) + .from(hackathonParticipants) + .innerJoin( + sql`unnest(${hackathonParticipants.dietaryRestrictions}) as restriction`, + sql`true`, + ) + .where(scope) + .groupBy(sql`btrim(restriction)`), + ]); + + const statusBreakdown = { + approved: 0, + pending: 0, + rejected: 0, + waitlisted: 0, + checked_in: 0, }; - participants.forEach((p) => { - // Status breakdown - if (p.registrationStatus in stats.statusBreakdown) { - stats.statusBreakdown[ - p.registrationStatus as keyof typeof stats.statusBreakdown - ]++; + let totalRegistrations = 0; + for (const row of byStatus) { + totalRegistrations += row.count; + if (row.status && row.status in statusBreakdown) { + statusBreakdown[row.status as keyof typeof statusBreakdown] = + row.count; } + } - // Shirt sizes - if (p.shirtSize) { - stats.shirtSizes[p.shirtSize] = - (stats.shirtSizes[p.shirtSize] || 0) + 1; - } + const shirtSizes: Record = {}; + for (const row of bySize) { + if (row.size) shirtSizes[row.size] = row.count; + } - // Dietary restrictions - if (p.dietaryRestrictions && p.dietaryRestrictions.length > 0) { - p.dietaryRestrictions.forEach((restriction) => { - const normalized = restriction.trim(); - if (normalized) { - stats.dietaryRestrictions[normalized] = - (stats.dietaryRestrictions[normalized] || 0) + 1; - } - }); - } + const dietaryRestrictions: Record = {}; + for (const row of byDiet) { + if (row.restriction) dietaryRestrictions[row.restriction] = row.count; + } + + return { + totalRegistrations, + statusBreakdown, + shirtSizes, + dietaryRestrictions, + }; + }), + + + /** + * Who scanned into one event. + * + * The scanner writes these rows and, until now, nothing ever read or removed + * them — so a station left pointed at the wrong event produced dozens of + * check-ins an organiser could see the count of but not the contents. + */ + getEventAttendees: isScanner + .input( + z.object({ + hackathonId: z.string().uuid("Invalid hackathon ID"), + eventId: z.string().uuid("Invalid event ID"), + limit: z.number().int().min(1).max(200).default(50), + offset: z.number().int().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + // Scoped through the event's own hackathonId rather than trusting the + // pair in the input, so an eventId from another edition returns nothing + // instead of that edition's roster. + const event = await db.query.hackathonEvents.findFirst({ + where: and( + eq(hackathonEvents.id, input.eventId), + eq(hackathonEvents.hackathonId, input.hackathonId), + ), + columns: { id: true }, }); - return stats; + if (!event) { + throw new TRPCError({ code: "NOT_FOUND", message: "Event not found." }); + } + + const [rows, [totals]] = await Promise.all([ + db.query.hackathonEventAttendees.findMany({ + where: eq(hackathonEventAttendees.eventId, input.eventId), + with: { + participant: { + columns: { id: true, firstName: true, lastName: true }, + with: { user: { columns: { name: true, email: true } } }, + }, + }, + orderBy: (attendees, { desc }) => [desc(attendees.checkedInAt)], + limit: input.limit, + offset: input.offset, + }), + db + .select({ count: sql`count(*)::int` }) + .from(hackathonEventAttendees) + .where(eq(hackathonEventAttendees.eventId, input.eventId)), + ]); + + return { attendees: rows, matching: totals?.count ?? 0 }; }), + /** + * Undoes one scan. + * + * The scan path is deliberately hard to fool — a duplicate is a CONFLICT and + * an ended event is a FORBIDDEN — but none of that helps when the mistake is + * the event itself. Somebody has to be able to take a row back out. + */ + removeEventAttendance: 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 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." }); + } + + // RETURNING rather than a preceding existence check: it names the row + // this statement removed, so a scan already undone by another organiser + // reads as "nothing to undo" instead of a second success. + const deleted = await db + .delete(hackathonEventAttendees) + .where( + and( + eq(hackathonEventAttendees.eventId, input.eventId), + eq(hackathonEventAttendees.participantId, input.participantId), + ), + ) + .returning({ id: hackathonEventAttendees.id }); + + if (deleted.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That participant is not checked into this event.", + }); + } + + ctx.cache.delete(`hackathon:${input.hackathonId}:events`); + + return { success: true }; + }), - scanParticipantPass: isAdmin + scanParticipantPass: isScanner .input( z.object({ hackathonId: z.string().uuid("Invalid hackathon ID"), @@ -440,8 +727,10 @@ export const hackathonAdminRouter = createTRPCRouter({ throw error; } - // Invalidate hackathon caches after attendance scan - ctx.cache.deletePattern("hackathon*"); + // A scan changes one event's attendee count and nothing else. This runs + // at every door station all weekend, so it must not touch the roster or + // any attendee's cached registrations. + ctx.cache.delete(`hackathon:${input.hackathonId}:events`); return { success: true, diff --git a/packages/api/src/routers/hackathon/announce.ts b/packages/api/src/routers/hackathon/announce.ts new file mode 100644 index 00000000..ec9813a0 --- /dev/null +++ b/packages/api/src/routers/hackathon/announce.ts @@ -0,0 +1,201 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { and, eq, inArray, isNotNull } from "drizzle-orm"; +import { + hackathonInterest, + hackathonParticipants, + hackathons, + users, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { createTRPCRouter } from "../../trpc"; +import { isAdmin } from "../../middleware/procedures"; + +/** + * Mass announcements: "registration is open", "the schedule is live", + * "results are up". + * + * Kept separate from sendMassAcceptanceEmails because the two differ in the + * thing that matters — an acceptance also changes a participant's status and + * must be exactly once, while an announcement writes nothing and is safe to + * repeat. Sharing one procedure would have meant one set of guarantees serving + * two jobs badly. + */ + +/** Recipients per request. See MASS_EMAIL_BATCH on the client: each one is an + * SMTP round trip, and a request carrying more does not finish inside Cloud + * Run's timeout. */ +const MAX_RECIPIENTS_PER_CALL = 500; + +const AUDIENCES = [ + "interested", + "registered", + "approved", + "checked_in", +] as const; + +type Audience = (typeof AUDIENCES)[number]; + +/** + * Everyone in the chosen audience, as `{ userId, email }`. + * + * Email is read from the users table rather than stored alongside the interest + * or participant row, so a person who changes their address gets the mail at + * the address they actually use. + */ +const resolveAudience = async ( + db: DrizzleDB, + hackathonId: string, + audience: Audience, +) => { + if (audience === "interested") { + const rows = await db + .select({ userId: hackathonInterest.userId, email: users.email }) + .from(hackathonInterest) + .innerJoin(users, eq(users.id, hackathonInterest.userId)) + .where( + and( + eq(hackathonInterest.hackathonId, hackathonId), + isNotNull(users.email), + ), + ); + return rows; + } + + // "registered" is everyone holding a seat, whatever stage they are at. + // Rejected and waitlisted applicants are deliberately excluded from all + // three: nothing here is the right channel for telling somebody they are + // out, and a "see you this weekend" to a rejected applicant is worse than + // no email at all. + const statuses = + audience === "registered" + ? (["pending", "approved", "checked_in"] as const) + : ([audience] as const); + + return await db + .select({ userId: hackathonParticipants.userId, email: users.email }) + .from(hackathonParticipants) + .innerJoin(users, eq(users.id, hackathonParticipants.userId)) + .where( + and( + eq(hackathonParticipants.hackathonId, hackathonId), + inArray(hackathonParticipants.registrationStatus, [...statuses]), + isNotNull(users.email), + ), + ); +}; + +export const hackathonAnnounceRouter = createTRPCRouter({ + /** How many people each audience would reach, so the compose screen can say + * so before anything is sent. */ + audienceCounts: isAdmin + .input(z.object({ hackathonId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const entries = await Promise.all( + AUDIENCES.map(async (audience) => { + const rows = await resolveAudience(db, input.hackathonId, audience); + return [audience, rows.length] as const; + }), + ); + + return Object.fromEntries(entries) as Record; + }), + + sendAnnouncement: isAdmin + .input( + z.object({ + hackathonId: z.string().uuid(), + audience: z.enum(AUDIENCES), + subject: z.string().trim().min(1).max(200), + heading: z.string().trim().min(1).max(200), + body: z.string().trim().min(1).max(5000), + ctaLabel: z.string().trim().max(60).optional(), + ctaUrl: z.string().url().max(500).optional(), + /** Skip this many recipients. The client walks the audience in batches + * and reports progress; the server stays one bounded unit of work. */ + offset: z.number().int().min(0).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, name: true }, + }); + + if (!hackathon) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hackathon not found", + }); + } + + // A CTA label without a target renders a dead button, and a target + // without a label renders nothing at all — neither is what the organiser + // meant, and both are only visible once it is in someone's inbox. + if (!!input.ctaLabel !== !!input.ctaUrl) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "A button needs both a label and a link, or neither.", + }); + } + + const all = await resolveAudience(db, input.hackathonId, input.audience); + + // Deduplicated: somebody on the interest list who later registered would + // otherwise be counted, and mailed, twice. + const seen = new Set(); + const recipients = all.filter((row) => { + if (!row.email || seen.has(row.email)) return false; + seen.add(row.email); + return true; + }); + + const batch = recipients.slice( + input.offset, + input.offset + MAX_RECIPIENTS_PER_CALL, + ); + + const { sendAnnouncementEmail } = await import("@query/auth/email"); + + let sent = 0; + const failed: string[] = []; + + for (const recipient of batch) { + if (!recipient.email) continue; + try { + await sendAnnouncementEmail({ + email: recipient.email, + subject: input.subject, + heading: input.heading, + body: input.body, + ctaLabel: input.ctaLabel, + ctaUrl: input.ctaUrl, + }); + sent++; + } catch (error) { + failed.push(recipient.email); + // Deliberate server-side operational logging: this is the only + // record of which address the provider rejected. + // eslint-disable-next-line no-console + console.error( + `[Email Service] Announcement failed for ${recipient.email}:`, + error, + ); + } + } + + const nextOffset = input.offset + batch.length; + + return { + sent, + failed, + totalRecipients: recipients.length, + nextOffset, + done: nextOffset >= recipients.length, + }; + }), +}); diff --git a/packages/api/src/routers/hackathon/content.ts b/packages/api/src/routers/hackathon/content.ts index 833b358d..3fddb9d6 100644 --- a/packages/api/src/routers/hackathon/content.ts +++ b/packages/api/src/routers/hackathon/content.ts @@ -1,12 +1,13 @@ import { z } from "zod"; +import { TRPCError } from "@trpc/server"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { hackathonParticipants, hackathonProjects, - hackathonTeams, + hackathonResults, } from "@query/db"; -import { eq, and, inArray } from "drizzle-orm"; -import { callerIsAdmin } from "../../middleware/procedures"; +import { eq, and, inArray, isNotNull } from "drizzle-orm"; +import { callerIsAdmin, isAdmin } from "../../middleware/procedures"; import type { DrizzleDB } from "@query/db"; // Same visibility rule as getPublicProjects: a project only becomes public once @@ -15,36 +16,149 @@ const PUBLIC_PROJECT_STATUSES: (typeof hackathonProjects.$inferSelect)["status"] ["submitted", "judging", "winner"]; export const hackathonContentRouter = createTRPCRouter({ - getTeams: publicProcedure + /** + * Fixes a submitted project on a team's behalf. + * + * team.submitProject refuses every edit once the submission window closes, + * and withdrawProject tells participants to "ask an organiser" about a + * project already in judging — which, until this existed, was advice nobody + * could act on. A dead demo link found during judging had no remedy. + * + * Deliberately narrow: the links and the copy, not the tracks. Tracks decide + * which judges a project reaches, and changing that mid-judging would + * silently rewrite who was supposed to have scored it. + */ + adminUpdateProject: isAdmin + .input( + z.object({ + projectId: z.string().uuid(), + name: z.string().min(1).max(255).optional(), + description: z.string().min(1).max(5000).optional(), + githubUrl: z.string().url().max(500).nullable().optional(), + demoUrl: z.string().url().max(500).nullable().optional(), + videoUrl: z.string().url().max(500).nullable().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { projectId, ...updateData } = input; + const db = ctx.db as DrizzleDB; + + const existing = await db.query.hackathonProjects.findFirst({ + where: eq(hackathonProjects.id, projectId), + columns: { id: true, hackathonId: true }, + }); + + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + const [updated] = await db + .update(hackathonProjects) + .set({ ...updateData, updatedAt: new Date() }) + .where(eq(hackathonProjects.id, projectId)) + .returning(); + + ctx.cache.delete(`hackathon:${existing.hackathonId}:projects`); + ctx.cache.deletePattern( + `hackathon:${existing.hackathonId}:public-projects*`, + ); + + return updated; + }), + + /** + * Pulls a submission out of the event. + * + * The participant-facing path refuses this once judging holds the project; + * an organiser has to be able to do it anyway — a plagiarised or + * rule-breaking entry is exactly the case that arises after judging starts. + */ + adminWithdrawProject: isAdmin + .input( + z.object({ + projectId: z.string().uuid(), + /** Withdraw even though judges have already scored it. Their votes + * stay on the record; the project simply stops being eligible. */ + force: z.boolean().default(false), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const existing = await db.query.hackathonProjects.findFirst({ + where: eq(hackathonProjects.id, input.projectId), + columns: { id: true, hackathonId: true, status: true }, + }); + + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + if (existing.status === "judging" && !input.force) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Judges are already scoring this project. Withdrawing it removes it from the results — confirm to continue.", + }); + } + + await db + .update(hackathonProjects) + .set({ status: "draft", submittedAt: null, updatedAt: new Date() }) + .where(eq(hackathonProjects.id, input.projectId)); + + ctx.cache.delete(`hackathon:${existing.hackathonId}:projects`); + ctx.cache.deletePattern( + `hackathon:${existing.hackathonId}:public-projects*`, + ); + + return { success: true }; + }), + + /** + * The published placings, for everyone. + * + * Reads only rows with publishedAt set, so a computed-but-unreviewed draft + * is invisible until an organiser releases it. Unpublishing takes it back + * down — the announcement is reversible rather than a one-way door. + */ + getResults: publicProcedure .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") })) .query(async ({ ctx, input }) => { - const teams = await (ctx.db as DrizzleDB).query.hackathonTeams.findMany({ - where: eq(hackathonTeams.hackathonId, input.hackathonId), - with: { - captain: { - columns: { id: true, name: true, image: true }, - }, - participants: { - // Team rosters are public, so they carry neither the decision made - // on each application — registrationStatus names everyone who was - // rejected or waitlisted — nor a participant id, which is the - // entire content of that participant's event pass QR. - columns: { - userId: true, + const cacheKey = `hackathon:${input.hackathonId}:results`; + + const fetchResults = () => + (ctx.db as DrizzleDB).query.hackathonResults.findMany({ + where: and( + eq(hackathonResults.hackathonId, input.hackathonId), + isNotNull(hackathonResults.publishedAt), + ), + with: { + project: { + columns: { id: true, name: true, teamMembers: true }, }, - with: { - user: { - columns: { id: true, name: true, image: true }, - }, + sourceProject: { + columns: { id: true, name: true, githubUrl: true, demoUrl: true }, + with: { team: { columns: { id: true, name: true } } }, }, }, - }, - orderBy: (hackathonTeams, { desc }) => [desc(hackathonTeams.createdAt)], - }); + orderBy: (results, { asc }) => [asc(results.placement)], + }); - return teams; - }), + const cached = + ctx.cache.get>>(cacheKey); + if (cached !== null) return cached; + const results = await fetchResults(); + ctx.cache.set(cacheKey, results, 60); + return results; + }), projects: publicProcedure .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") })) @@ -124,30 +238,56 @@ export const hackathonContentRouter = createTRPCRouter({ }), + /** + * The public project gallery. + * + * Anonymous, and read by most of the venue at once when demos open — so it + * is both bounded and cached. Uncached and unbounded it was a full table + * read with a team join per request, at the busiest moment of the event. + */ getPublicProjects: publicProcedure - .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") })) + .input( + z.object({ + hackathonId: z.string().uuid("Invalid hackathon ID"), + limit: z.number().int().min(1).max(200).default(100), + offset: z.number().int().min(0).default(0), + }), + ) .query(async ({ ctx, input }) => { - const projects = await ( - ctx.db as DrizzleDB - ).query.hackathonProjects.findMany({ - where: and( - eq(hackathonProjects.hackathonId, input.hackathonId), - // We only show projects that are submitted, judging, or winner. Drafts stay hidden. - inArray(hackathonProjects.status, ["submitted", "judging", "winner"]), - ), - // Same rule as `projects` above: submittedById is the participant id - // behind that person's event pass QR, and this endpoint is anonymous. - columns: { submittedById: false }, - with: { - team: { - columns: { - id: true, - name: true, + const cacheKey = `hackathon:${input.hackathonId}:public-projects:${input.limit}:${input.offset}`; + + const fetchPage = () => + (ctx.db as DrizzleDB).query.hackathonProjects.findMany({ + where: and( + eq(hackathonProjects.hackathonId, input.hackathonId), + // We only show projects that are submitted, judging, or winner. Drafts stay hidden. + inArray(hackathonProjects.status, [ + ...PUBLIC_PROJECT_STATUSES, + ]), + ), + // Same rule as `projects` above: submittedById is the participant id + // behind that person's event pass QR, and this endpoint is anonymous. + columns: { submittedById: false }, + with: { + team: { + columns: { + id: true, + name: true, + }, }, }, - }, - orderBy: (projects, { desc }) => [desc(projects.submittedAt)], - }); + orderBy: (projects, { desc }) => [desc(projects.submittedAt)], + limit: input.limit, + offset: input.offset, + }); + + const cached = ctx.cache.get>>( + cacheKey, + ); + if (cached !== null) return cached; + + const projects = await fetchPage(); + ctx.cache.set(cacheKey, projects, 60); return projects; }), }); diff --git a/packages/api/src/routers/hackathon/crud.ts b/packages/api/src/routers/hackathon/crud.ts index 432ae25b..85ec5365 100644 --- a/packages/api/src/routers/hackathon/crud.ts +++ b/packages/api/src/routers/hackathon/crud.ts @@ -4,6 +4,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; import { hackathons } from "@query/db"; import { eq, and, gte, notInArray } from "drizzle-orm"; import { callerIsAdmin, isAdmin } from "../../middleware/procedures"; +import { isForeignKeyViolation } from "../../middleware/db-errors"; import { CacheKeys, VOLATILE_TTL } from "../../middleware/cache"; import type { DrizzleDB } from "@query/db"; @@ -269,6 +270,10 @@ export const hackathonCrudRouter = createTRPCRouter({ "cancelled", ]) .optional(), + // These five are nullable as well as optional, and the distinction is + // load-bearing: `undefined` means "leave unchanged", `null` means + // "clear it". Optional alone gave the edit form no way to empty a + // field it had already filled — sending `[]` reads as unchanged. prizes: z .array( z.object({ @@ -278,12 +283,15 @@ export const hackathonCrudRouter = createTRPCRouter({ }), ) .max(20) + .nullable() .optional(), - rules: z.string().max(10000).optional(), + rules: z.string().max(10000).nullable().optional(), theme: z.string().max(200).optional(), - tracks: z.array(z.string().max(100)).max(50).optional(), - challenges: z.array(z.string().max(100)).max(50).optional(), - websiteUrl: z.string().url().max(500).optional(), + tracks: z.array(z.string().max(100)).max(50).nullable().optional(), + challenges: z.array(z.string().max(100)).max(50).nullable().optional(), + // No empty-string escape hatch: "" would be stored and render as a + // link to nowhere. Clearing the field sends null. + websiteUrl: z.string().url().max(500).nullable().optional(), isPublic: z.boolean().optional(), }), ) @@ -348,19 +356,62 @@ export const hackathonCrudRouter = createTRPCRouter({ delete: isAdmin - .input(z.object({ hackathonId: z.string().uuid() })) + .input( + z.object({ + hackathonId: z.string().uuid(), + // The hackathon's own name, typed by the caller. Eleven tables cascade + // off this row — every participant, team, project and judge vote for + // the event. A browser confirm() is one misplaced click; this is not. + confirmName: z.string().min(1), + }), + ) .mutation(async ({ ctx, input }) => { - const { hackathonId } = input; + const { hackathonId, confirmName } = input; + + const existing = await (ctx.db as DrizzleDB).query.hackathons.findFirst({ + where: eq(hackathons.id, hackathonId), + columns: { id: true, name: true }, + }); + + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hackathon not found", + }); + } + + if (confirmName.trim() !== existing.name.trim()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Type the hackathon's exact name to confirm. Expected "${existing.name}".`, + }); + } // Every child table cascades off this row, so reporting success for an id // that matched nothing hides a delete that never happened. RETURNING names // the rows the statement itself removed, which a separate existence check // cannot: that only describes the row as it was before the DELETE, and a // concurrent delete landing in between would still be called a success. - const deleted = await (ctx.db as DrizzleDB) - .delete(hackathons) - .where(eq(hackathons.id, hackathonId)) - .returning({ id: hackathons.id }); + let deleted; + try { + deleted = await (ctx.db as DrizzleDB) + .delete(hackathons) + .where(eq(hackathons.id, hackathonId)) + .returning({ id: hackathons.id }); + } catch (error) { + // member.hackathon_id is ON DELETE RESTRICT, so this fires when paid + // club memberships still hang off the edition. That is the guard + // working, not a bug — those rows are the only record of who paid and + // nothing re-creates them. + if (isForeignKeyViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: + "This hackathon still has club memberships attached. Those are paid records and cannot be cascaded away — move or remove them deliberately first.", + }); + } + throw error; + } if (deleted?.length === 0) { throw new TRPCError({ diff --git a/packages/api/src/routers/hackathon/events.ts b/packages/api/src/routers/hackathon/events.ts index 814af0c5..50674002 100644 --- a/packages/api/src/routers/hackathon/events.ts +++ b/packages/api/src/routers/hackathon/events.ts @@ -4,8 +4,9 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; import { hackathons, hackathonEvents, + hackathonEventAttendees, } from "@query/db"; -import { eq } from "drizzle-orm"; +import { eq, inArray, sql } from "drizzle-orm"; import { isAdmin } from "../../middleware/procedures"; import type { DrizzleDB } from "@query/db"; @@ -59,7 +60,7 @@ export const hackathonEventsRouter = createTRPCRouter({ }) .returning(); - ctx.cache.deletePattern("hackathon*"); + ctx.cache.delete(`hackathon:${input.hackathonId}:events`); return newEvent; }), @@ -113,7 +114,9 @@ export const hackathonEventsRouter = createTRPCRouter({ .where(eq(hackathonEvents.id, eventId)) .returning(); - ctx.cache.deletePattern("hackathon*"); + // The schedule for this edition, and nothing else. The old blanket + // pattern also matched every attendee's cached registrations. + ctx.cache.delete(`hackathon:${existing.hackathonId}:events`); return updatedEvent; }), @@ -123,12 +126,15 @@ export const hackathonEventsRouter = createTRPCRouter({ .input( z.object({ eventId: z.string().uuid("Invalid event ID"), + /** Delete even though people have already scanned in. Their check-in + * rows go with it — there is no undo and no export first. */ + force: z.boolean().default(false), }), ) .mutation(async ({ ctx, input }) => { - const existing = await ( - ctx.db as DrizzleDB - ).query.hackathonEvents.findFirst({ + const db = ctx.db as DrizzleDB; + + const existing = await db.query.hackathonEvents.findFirst({ where: eq(hackathonEvents.id, input.eventId), }); @@ -136,13 +142,30 @@ export const hackathonEventsRouter = createTRPCRouter({ throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" }); } - await (ctx.db as DrizzleDB) + // hackathon_event_attendee cascades off this row. At a keynote that is + // every badge scanned at the door — thousands of rows, gone on one + // click, with nothing that can rebuild them. + const [scans] = await db + .select({ count: sql`count(*)::int` }) + .from(hackathonEventAttendees) + .where(eq(hackathonEventAttendees.eventId, input.eventId)); + + const checkIns = scans?.count ?? 0; + + 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.`, + }); + } + + await db .delete(hackathonEvents) .where(eq(hackathonEvents.id, input.eventId)); - ctx.cache.deletePattern("hackathon*"); + ctx.cache.delete(`hackathon:${existing.hackathonId}:events`); - return { success: true }; + return { success: true, deletedCheckIns: checkIns }; }), @@ -152,21 +175,41 @@ export const hackathonEventsRouter = createTRPCRouter({ const cacheKey = `hackathon:${input.hackathonId}:events`; const fetchEvents = async () => { - const eventsData = await ( - ctx.db as DrizzleDB - ).query.hackathonEvents.findMany({ + const db = ctx.db as DrizzleDB; + + const eventsData = await db.query.hackathonEvents.findMany({ where: eq(hackathonEvents.hackathonId, input.hackathonId), orderBy: (events, { asc }) => [asc(events.startTime)], - with: { - attendees: { - columns: { id: true }, - }, - }, }); + if (eventsData.length === 0) return []; + + // Counted in the database rather than by loading the rows. This is the + // schedule every attendee's phone polls: eagerly joining attendees to + // produce a handful of integers meant ~15 events x 2000 people, and it + // shipped the whole array over the wire on the way back. + const counts = await db + .select({ + eventId: hackathonEventAttendees.eventId, + count: sql`count(*)::int`, + }) + .from(hackathonEventAttendees) + .where( + inArray( + hackathonEventAttendees.eventId, + eventsData.map((event) => event.id), + ), + ) + .groupBy(hackathonEventAttendees.eventId); + + const countByEvent = new Map( + counts.map((row) => [row.eventId, row.count]), + ); + return eventsData.map((e) => ({ ...e, - attendeeCount: e.attendees.length, + // An event nobody has scanned into produces no group, not a zero row. + attendeeCount: countByEvent.get(e.id) ?? 0, })); }; diff --git a/packages/api/src/routers/hackathon/index.ts b/packages/api/src/routers/hackathon/index.ts index e25f6af8..ebc1aec2 100644 --- a/packages/api/src/routers/hackathon/index.ts +++ b/packages/api/src/routers/hackathon/index.ts @@ -5,6 +5,7 @@ import { hackathonAdminRouter } from "./admin"; import { hackathonEventsRouter } from "./events"; import { hackathonContentRouter } from "./content"; import { hackathonInterestRouter } from "./interest"; +import { hackathonAnnounceRouter } from "./announce"; export const hackathonRouter = mergeRouters( hackathonCrudRouter, @@ -13,4 +14,5 @@ export const hackathonRouter = mergeRouters( hackathonEventsRouter, hackathonContentRouter, hackathonInterestRouter, + hackathonAnnounceRouter, ); diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index 314f2ae3..1bb3279a 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -7,17 +7,21 @@ import { judgeVotes, judgingProjects, judgeQueue, - hackathonMaps, hackathons, + hackathonProjects, users, hackathonParticipants, } from "@query/db"; -import { eq, and, asc, sql } from "drizzle-orm"; +import { eq, and, asc, sql, inArray } from "drizzle-orm"; import { isAdmin } from "../../middleware/procedures"; import { CacheKeys } from "../../middleware/cache"; import type { DrizzleDB } from "@query/db"; import { shuffleArray, buildCoverageQueues } from "./helpers"; +/** Rows per queue INSERT. Well under the ~16k that Postgres's 65535-parameter + * ceiling allows at 4 bound parameters per row. */ +const QUEUE_INSERT_CHUNK = 5000; + export const judgeAdminRouter = createTRPCRouter({ list: isAdmin.query(async ({ ctx }) => { const allJudges = await (ctx.db as DrizzleDB).query.judges.findMany({ @@ -193,238 +197,117 @@ export const judgeAdminRouter = createTRPCRouter({ return result[0]; }), - createProject: isAdmin - .input( - z.object({ - hackathonId: z.string().uuid(), - name: z.string().min(1).max(255), - description: z.string().max(1000).optional(), - tableNumber: z.number().min(1), - zone: z.string().optional(), - teamMembers: z.string().max(500).optional(), - projectUrl: z.string().url().optional(), - repoUrl: z.string().url().optional(), - tracks: z.array(z.string()).optional(), - challenges: z.array(z.string()).optional(), - isCreateX: z.boolean().default(false), - }), - ) + /** + * Turns submitted projects into judgeable ones. + * + * This is the only way a judging entry comes into existence. Teams submit + * through the portal, an organiser presses one button, and every submission + * gets a table number. Idempotent by design — run it again as late + * submissions land and only the new ones are added, because + * judging_project_source_unique pins one judgeable row per submission. + */ + promoteSubmissions: isAdmin + .input(z.object({ hackathonId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { - const result = await (ctx.db as DrizzleDB) - .insert(judgingProjects) - .values(input) - .returning(); + return await (ctx.db as DrizzleDB).transaction(async (tx) => { + // Serializes concurrent promotions for this event, so two organisers + // pressing the button together cannot both read the same max table + // number and hand out duplicates. + await tx + .select({ id: hackathons.id }) + .from(hackathons) + .where(eq(hackathons.id, input.hackathonId)) + .for("update"); - return result[0]; - }), + 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)], + }); - bulkCreateProjects: isAdmin - .input( - z.object({ - hackathonId: z.string().uuid(), - projects: z.array( - z.object({ - name: z.string().min(1).max(255), - description: z.string().max(1000).optional(), - tableNumber: z.number().min(1), - zone: z.string().optional(), - category: z.string().max(100).optional(), - teamMembers: z.string().max(500).optional(), - tracks: z.array(z.string()).optional(), - challenges: z.array(z.string()).optional(), - isCreateX: z.boolean().default(false), - }), - ), - }), - ) - .mutation(async ({ ctx, input }) => { - const result = await (ctx.db as DrizzleDB) - .insert(judgingProjects) - .values( - input.projects.map((p) => ({ - ...p, - hackathonId: input.hackathonId, - })), - ) - .returning(); + if (submissions.length === 0) { + return { + created: 0, + alreadyPresent: 0, + total: 0, + queuesNeedRebuild: false, + }; + } - return result; - }), + const existing = await tx.query.judgingProjects.findMany({ + where: eq(judgingProjects.hackathonId, input.hackathonId), + columns: { id: true, sourceProjectId: true, tableNumber: true }, + }); - /** Bulk import judges from a parsed CSV. - * Creates user stubs for emails not yet in the system, - * creates judge records, and assigns to the hackathon. */ - bulkImportJudges: isAdmin - .input( - z.object({ - hackathonId: z.string().uuid(), - judges: z.array( - z.object({ - name: z.string().min(1).max(255), - email: z.string().email(), - track: z.string().optional(), - }), - ), - }), - ) - .mutation(async ({ ctx, input }) => { - return await (ctx.db as DrizzleDB).transaction(async (tx) => { - const results = { created: 0, skipped: 0, errors: [] as string[] }; + const promoted = new Set( + existing + .map((row) => row.sourceProjectId) + .filter((id): id is string => !!id), + ); - for (const j of input.judges) { - try { - // Only rows that actually gained a judge record or a hackathon - // assignment count as imported. - let imported = false; + const fresh = submissions.filter((s) => !promoted.has(s.id)); - // 1. Find or create user by email - let user = await tx.query.users.findFirst({ - where: eq(users.email, j.email), - }); + let nextTable = existing.reduce( + (max, row) => Math.max(max, row.tableNumber), + 0, + ); - if (!user) { - const id = crypto.randomUUID(); - const [newUser] = await tx - .insert(users) - .values({ id, name: j.name, email: j.email }) - .returning(); - user = newUser as NonNullable; - } - - // 2. Find or create judge record for this hackathon - let judge = await tx.query.judges.findFirst({ - where: and( - eq(judges.userId, user.id), - eq(judges.hackathonId, input.hackathonId), - ), - }); + 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 — handing an array + // straight over 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, + })), + ); - if (!judge) { - const [newJudge] = await tx - .insert(judges) - .values({ - userId: user.id, - hackathonId: input.hackathonId, - name: j.name, - isActive: true, - }) - .returning(); - judge = newJudge as NonNullable; - imported = true; - } - - // 3. Assign to hackathon (skip if already assigned) - const existingAssignment = - await tx.query.judgeAssignments.findFirst({ - where: and( - eq(judgeAssignments.judgeId, judge.id), - eq(judgeAssignments.hackathonId, input.hackathonId), - ), - }); - - if (!existingAssignment) { - await tx.insert(judgeAssignments).values({ - judgeId: judge.id, - hackathonId: input.hackathonId, - track: j.track || null, - }); - imported = true; - } - - if (imported) results.created++; - else results.skipped++; - } catch (e) { - results.skipped++; - results.errors.push( - `${j.email}: ${e instanceof Error ? e.message : "Unknown error"}`, + await tx + .update(hackathonProjects) + .set({ status: "judging", updatedAt: new Date() }) + .where( + inArray( + hackathonProjects.id, + fresh.map((submission) => submission.id), + ), ); - } } - return results; - }); - }), - - /** Bulk import projects from a parsed CSV. - * Auto-assigns incrementing table numbers starting from 1. */ - bulkImportProjects: isAdmin - .input( - z.object({ - hackathonId: z.string().uuid(), - projects: z.array( - z.object({ - name: z.string().min(1).max(255), - teamMembers: z.string().max(500).optional(), - mainTrack: z.string().optional(), - extraTracks: z.array(z.string()).optional(), - isCreateX: z.boolean().default(false), - }), - ), - }), - ) - .mutation(async ({ ctx, input }) => { - // An empty CSV would reach .values([]), which Drizzle rejects. - // The table bounds stay numeric so this branch keeps the same response - // shape as a real import — widening them to `undefined` breaks the - // setup wizard's prop type and takes the whole site build down with it. - if (input.projects.length === 0) { - return { created: 0, startTable: 0, endTable: 0 }; - } - - // Get the current max table number for this hackathon - const maxResult = await (ctx.db as DrizzleDB) - .select({ - max: sql`COALESCE(MAX(${judgingProjects.tableNumber}), 0)`, - }) - .from(judgingProjects) - .where(eq(judgingProjects.hackathonId, input.hackathonId)); - - let nextTable = (maxResult[0]?.max ?? 0) + 1; - - const rows = input.projects.map((p) => { - const tracks = [ - ...(p.mainTrack ? [p.mainTrack] : []), - ...(p.extraTracks || []), - ].filter(Boolean); + // Queues are built from a snapshot of the project list. Anything + // promoted after assignment sits in nobody's queue and would simply + // never be judged, with nothing on screen to say so. + const [queued] = await tx + .select({ count: sql`count(*)::int` }) + .from(judgeQueue) + .where(eq(judgeQueue.hackathonId, input.hackathonId)); return { - hackathonId: input.hackathonId, - name: p.name, - teamMembers: p.teamMembers, - tableNumber: nextTable++, - tracks: tracks.length > 0 ? tracks : undefined, - isCreateX: p.isCreateX, + created: fresh.length, + alreadyPresent: submissions.length - fresh.length, + total: submissions.length, + queuesNeedRebuild: fresh.length > 0 && (queued?.count ?? 0) > 0, }; }); - - const result = await (ctx.db as DrizzleDB) - .insert(judgingProjects) - .values(rows) - .returning(); - - return { - created: result.length, - startTable: rows[0]?.tableNumber, - endTable: rows[rows.length - 1]?.tableNumber, - }; - }), - - addMap: isAdmin - .input( - z.object({ - hackathonId: z.string().uuid(), - imageUrl: z.string().url(), - name: z.string().max(100).optional(), - order: z.number().min(0).default(0), - }), - ) - .mutation(async ({ ctx, input }) => { - const result = await (ctx.db as DrizzleDB) - .insert(hackathonMaps) - .values(input) - .returning(); - - return result[0]; }), initializeQueue: isAdmin @@ -436,6 +319,26 @@ export const judgeAdminRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { + // A judges row belongs to one hackathon and isJudge authorizes against + // that, so a queue built for a judge from another edition can never be + // opened — the projects sit in it and are silently never scored. + // assignToHackathon makes exactly this check; this path did not. + 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", + }); + } + await (ctx.db as DrizzleDB) .delete(judgeQueue) .where( @@ -564,6 +467,10 @@ export const judgeAdminRouter = createTRPCRouter({ * When true, they stay grouped in table order. */ groupSpecial: z.boolean().default(false), autoCalculate: z.boolean().default(true), + /** Rebuild even though judging is live or work has been completed. + * Completed slots are still carried over; this only waives the + * refusal, so the admin has to have seen the count first. */ + force: z.boolean().default(false), }), ) .mutation(async ({ ctx, input }) => { @@ -577,6 +484,37 @@ export const judgeAdminRouter = createTRPCRouter({ message: "Hackathon not found", }); + // This procedure deletes and rebuilds every queue in the hackathon. Run + // a second time by accident — and the wizard drops you straight onto + // its button after a project import — it would restart judging for + // everyone at once, mid-event. + if (hackathon.judgingActive && !input.force) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Judging is live. Re-running assignment rebuilds every judge's queue. Stop judging first, or confirm to rebuild anyway.", + }); + } + + // Completed slots are not reconstructible from votes: skipProject marks + // a slot complete without writing one, so a wipe sends judges back to + // tables they already dealt with. judgingActive defaults false and + // organisers switch it off when judging closes, so the flag above + // cannot be the only guard. + const completed = await tx.query.judgeQueue.findMany({ + where: and( + eq(judgeQueue.hackathonId, input.hackathonId), + eq(judgeQueue.isCompleted, true), + ), + }); + + if (completed.length > 0 && !input.force) { + throw new TRPCError({ + code: "CONFLICT", + message: `${completed.length} judging slot(s) are already complete. Rebuilding preserves them but reorders everything else — confirm to continue.`, + }); + } + const allAssignments = await tx.query.judgeAssignments.findMany({ where: eq(judgeAssignments.hackathonId, input.hackathonId), with: { judge: true }, @@ -702,29 +640,73 @@ export const judgeAdminRouter = createTRPCRouter({ }, ); - // Build all insert rows in one pass + // Build all insert rows in one pass, skipping pairs a judge has already + // finished. judge_queue has no unique on (judgeId, projectId), so + // without this filter the rebuild happily re-issues a completed pair as + // a fresh uncompleted row and getNextTable sends the judge back. + const completedKeys = new Set( + completed.map((row) => `${row.judgeId}:${row.projectId}`), + ); + const insertRows: { judgeId: string; hackathonId: string; projectId: string; order: number; + isCompleted?: boolean; + startedAt?: Date | null; + completedAt?: Date | null; }[] = []; for (const [judgeId, projectIds] of queues.entries()) { - projectIds.forEach((projectId, idx) => { + let order = 0; + for (const projectId of projectIds) { + if (completedKeys.has(`${judgeId}:${projectId}`)) continue; insertRows.push({ judgeId, hackathonId: input.hackathonId, projectId, - order: idx + 1, + order: ++order, }); + } + } + + // Re-append the finished work past the tail of each judge's new queue, + // so their history survives and nothing re-serves it. + const tailByJudge = new Map(); + for (const row of insertRows) { + tailByJudge.set( + row.judgeId, + Math.max(tailByJudge.get(row.judgeId) ?? 0, row.order), + ); + } + for (const row of completed) { + const next = (tailByJudge.get(row.judgeId) ?? 0) + 1; + tailByJudge.set(row.judgeId, next); + insertRows.push({ + judgeId: row.judgeId, + hackathonId: input.hackathonId, + projectId: row.projectId, + order: next, + isCompleted: true, + startedAt: row.startedAt, + completedAt: row.completedAt, }); } - if (insertRows.length > 0) { - await tx.insert(judgeQueue).values(insertRows); + // Chunked because a single INSERT carries 4 bound parameters per row + // against Postgres's 65535 limit — about 16k rows. A sponsor-track + // judge's pool is uncapped, so a few of them over a large project list + // crosses it and aborts the whole assignment with an opaque driver + // error at the worst possible moment. + for (let i = 0; i < insertRows.length; i += QUEUE_INSERT_CHUNK) { + await tx + .insert(judgeQueue) + .values(insertRows.slice(i, i + QUEUE_INSERT_CHUNK)); } - // Compute coverage stats for admin feedback + // Compute coverage stats for admin feedback. Counted over the merged + // set — over the generated rows alone, a fully-judged project reads as + // uncovered and the admin re-runs assignment chasing it. const projectCoverage = new Map(); for (const row of insertRows) { projectCoverage.set( @@ -747,11 +729,18 @@ export const judgeAdminRouter = createTRPCRouter({ const maxCoverage = coverageValues.length > 0 ? Math.max(...coverageValues) : 0; + // Counted from the rows actually written, not from `queues` — those + // still hold the completed pairs that were filtered out above. + const countByJudge = new Map(); + for (const row of insertRows) { + countByJudge.set(row.judgeId, (countByJudge.get(row.judgeId) ?? 0) + 1); + } + const results = allAssignments.map((a) => ({ judgeId: a.judgeId, judgeName: a.judge.name, track: a.track ?? null, - assignedCount: queues.get(a.judgeId)?.length ?? 0, + assignedCount: countByJudge.get(a.judgeId) ?? 0, })); return { @@ -893,32 +882,6 @@ export const judgeAdminRouter = createTRPCRouter({ return result; }), - getAllVotes: isAdmin - .input(z.object({ hackathonId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - const projects = await ( - ctx.db as DrizzleDB - ).query.judgingProjects.findMany({ - where: eq(judgingProjects.hackathonId, input.hackathonId), - with: { - votes: { - with: { - judge: { - with: { - user: { - columns: { name: true }, - }, - }, - }, - }, - }, - }, - orderBy: [asc(judgingProjects.tableNumber)], - }); - - return projects; - }), - register: protectedProcedure .input( z.object({ diff --git a/packages/api/src/routers/judge/portal.ts b/packages/api/src/routers/judge/portal.ts index a3d90dfc..3efa32d4 100644 --- a/packages/api/src/routers/judge/portal.ts +++ b/packages/api/src/routers/judge/portal.ts @@ -7,7 +7,6 @@ import { judgeVotes, judgingProjects, judgeQueue, - hackathonMaps, hackathons, } from "@query/db"; import { eq, ne, gt, and, asc, inArray, sql } from "drizzle-orm"; @@ -234,17 +233,6 @@ export const judgePortalRouter = createTRPCRouter({ })); }), - getMaps: isJudge - .input(z.object({ hackathonId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - const maps = await (ctx.db as DrizzleDB).query.hackathonMaps.findMany({ - where: eq(hackathonMaps.hackathonId, input.hackathonId), - orderBy: [asc(hackathonMaps.order)], - }); - - return maps; - }), - getJudgingStatus: protectedProcedure .input(z.object({ hackathonId: z.string().uuid() })) .query(async ({ ctx, input }) => { @@ -636,7 +624,35 @@ export const judgePortalRouter = createTRPCRouter({ // Get the project's tracks for matching const projectTracks = queueItem.project?.tracks || []; - // Build candidate list with workload info + // Two queries for the whole candidate set, not two per candidate. + // This runs inside an open transaction during judging: at 40 judges + // the per-candidate version was ~80 sequential round trips, holding + // a pool connection the entire time. + const [holders, workloads] = await Promise.all([ + tx + .select({ judgeId: judgeQueue.judgeId }) + .from(judgeQueue) + .where(eq(judgeQueue.projectId, queueItem.projectId)), + tx + .select({ + judgeId: judgeQueue.judgeId, + remaining: sql`count(*)::int`, + }) + .from(judgeQueue) + .where( + and( + eq(judgeQueue.hackathonId, queueItem.hackathonId), + eq(judgeQueue.isCompleted, false), + ), + ) + .groupBy(judgeQueue.judgeId), + ]); + + const alreadyHolding = new Set(holders.map((row) => row.judgeId)); + const remainingByJudge = new Map( + workloads.map((row) => [row.judgeId, row.remaining]), + ); + const candidates: { judgeId: string; trackMatch: boolean; @@ -650,26 +666,7 @@ export const judgePortalRouter = createTRPCRouter({ // them the project strands it with nobody able to score it. if (!other.judge?.isActive) continue; - // Check if already has this project - const alreadyQueued = await tx.query.judgeQueue.findFirst({ - where: and( - eq(judgeQueue.judgeId, other.judgeId), - eq(judgeQueue.projectId, queueItem.projectId), - ), - }); - if (alreadyQueued) continue; - - // Count remaining (uncompleted) projects for workload balancing - const remainingCount = await tx - .select({ count: sql`COUNT(*)` }) - .from(judgeQueue) - .where( - and( - eq(judgeQueue.judgeId, other.judgeId), - eq(judgeQueue.hackathonId, queueItem.hackathonId), - eq(judgeQueue.isCompleted, false), - ), - ); + if (alreadyHolding.has(other.judgeId)) continue; // Check track match: judge's assigned track overlaps with project's tracks const trackMatch = other.track @@ -679,7 +676,9 @@ export const judgePortalRouter = createTRPCRouter({ candidates.push({ judgeId: other.judgeId, trackMatch, - remaining: remainingCount[0]?.count ?? 0, + // A judge with nothing left has no group row at all, which is the + // lightest possible load rather than a missing one. + remaining: remainingByJudge.get(other.judgeId) ?? 0, }); } @@ -713,6 +712,17 @@ export const judgePortalRouter = createTRPCRouter({ orderBy: [asc(judgeQueue.order)], }); + // Claim the table being handed over, exactly as completeAndNext and + // skipProject do. Without this the slot stays unclaimed and the next + // judge to ask for work is sent to the table this judge just walked up + // to — two judges, one team, at the same moment. + if (nextInQueue) { + await tx + .update(judgeQueue) + .set({ startedAt: new Date() }) + .where(eq(judgeQueue.id, nextInQueue.id)); + } + return { done: !nextInQueue, project: nextInQueue?.project ?? null, diff --git a/packages/api/src/routers/judge/rankings.ts b/packages/api/src/routers/judge/rankings.ts index 784c27d7..fcc1a27a 100644 --- a/packages/api/src/routers/judge/rankings.ts +++ b/packages/api/src/routers/judge/rankings.ts @@ -1,328 +1,493 @@ import { z } from "zod"; +import { TRPCError } from "@trpc/server"; import { createTRPCRouter } from "../../trpc"; -import { - judgingProjects, -} from "@query/db"; -import { eq } from "drizzle-orm"; +import { hackathonResults, hackathons, judgingProjects } from "@query/db"; +import { and, eq, isNotNull, sql } from "drizzle-orm"; import { isAdmin } from "../../middleware/procedures"; import type { DrizzleDB } from "@query/db"; import { zNormalize } from "./helpers"; +/** + * The whole ranking pipeline, in one place. + * + * Extracted so the live view and the frozen snapshot cannot drift: two + * implementations of a scoring formula are two different answers to "who + * won", and only one of them gets announced. + */ +async function computeRanking(db: DrizzleDB, hackathonId: string) { + const projects = await db.query.judgingProjects.findMany({ + where: eq(judgingProjects.hackathonId, hackathonId), + with: { + votes: { + with: { + judge: { + with: { + user: { + columns: { name: true, email: true }, + }, + }, + }, + }, + }, + }, + }); + + const round2 = (n: number) => Math.round(n * 100) / 100; + + // ─── Step 1: Collect all raw scores grouped by judge ────────────────── + // We need per-judge score distributions to perform Z-score normalization, + // which eliminates the "harsh judge / lenient judge" bias problem. + 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: Build per-judge normalized score lookup ────────────────── + // For each judge, map their raw score index to a Z-normalized score. + const normalizedScoreLookup = new Map>(); + for (const [judgeId, rawScores] of scoresByJudge.entries()) { + const normalized = zNormalize(rawScores, globalMean, globalStd); + // Map raw score value -> normalized value (index-based, preserves order) + const lookup = new Map(); + rawScores.forEach((raw, i) => { + // If same raw score appears multiple times, average the normalized values + const existing = lookup.get(raw); + lookup.set( + raw, + existing !== undefined + ? (existing + normalized[i]!) / 2 + : normalized[i]!, + ); + }); + normalizedScoreLookup.set(judgeId, lookup); + } + + const getNormalized = (judgeId: string, rawScore: number): number => { + const lookup = normalizedScoreLookup.get(judgeId); + return lookup?.get(rawScore) ?? rawScore; + }; + + // ─── Step 4: Build raw + normalized stats per project ───────────────── + const C = 2; // Bayesian confidence weight + + const rawRankings = projects.map((project) => { + const voteCount = project.votes.length; + + // Raw scores (unadjusted) + const totalScore = project.votes.reduce((sum, v) => sum + v.score, 0); + const avgScore = voteCount > 0 ? totalScore / voteCount : 0; + + // Z-score normalized scores (bias-corrected) + const normalizedScores = project.votes.map((v) => + getNormalized(v.judgeId, v.score), + ); + const normalizedAvg = + voteCount > 0 + ? round2(normalizedScores.reduce((a, b) => a + b, 0) / voteCount) + : 0; + + // Per-category averages (raw) + const sumCat = { + creativity: 0, + impact: 0, + scope: 0, + clarity: 0, + soundness: 0, + }; + project.votes.forEach((v) => { + sumCat.creativity += v.scoreCreativity ?? 0; + sumCat.impact += v.scoreImpact ?? 0; + sumCat.scope += v.scoreScope ?? 0; + sumCat.clarity += v.scoreClarity ?? 0; + sumCat.soundness += v.scoreSoundness ?? 0; + }); + + const categoryAvg = + voteCount > 0 + ? { + creativity: round2(sumCat.creativity / voteCount), + impact: round2(sumCat.impact / voteCount), + scope: round2(sumCat.scope / voteCount), + clarity: round2(sumCat.clarity / voteCount), + soundness: round2(sumCat.soundness / voteCount), + } + : { creativity: 0, impact: 0, scope: 0, clarity: 0, soundness: 0 }; + + return { + project: { + id: project.id, + // Carried through so a frozen placing can name the team that built it. + // Without it a winner is a judging row and nothing more. + sourceProjectId: project.sourceProjectId, + name: project.name, + tableNumber: project.tableNumber, + zone: project.zone, + category: project.category, + teamMembers: project.teamMembers, + tracks: project.tracks, + challenges: project.challenges, + isCreateX: project.isCreateX, + }, + totalScore, + voteCount, + avgScore: round2(avgScore), + normalizedAvg, + categoryAvg, + votes: project.votes.map((v, i) => ({ + score: v.score, + normalizedScore: round2(normalizedScores[i] ?? v.score), + scoreCreativity: v.scoreCreativity, + scoreImpact: v.scoreImpact, + scoreScope: v.scoreScope, + scoreClarity: v.scoreClarity, + scoreSoundness: v.scoreSoundness, + comment: v.comment, + durationSeconds: v.durationSeconds, + judgeName: + ( + v as VoteWithJudge & { + judge: { + user?: { name?: string | null }; + name?: string | null; + }; + } + ).judge.user?.name || + ( + v as VoteWithJudge & { + judge: { + user?: { name?: string | null }; + name?: string | null; + }; + } + ).judge.name || + "Unknown", + })), + }; + }); + + // ─── Step 5: Compute global normalized average for Bayesian prior ────── + const votedProjects = rawRankings.filter((r) => r.voteCount > 0); + const globalAvg = + votedProjects.length > 0 + ? round2( + votedProjects.reduce((sum, r) => sum + r.normalizedAvg, 0) / + votedProjects.length, + ) + : 0; + + // ─── Step 6: Bayesian + Z-score combined final score ────────────────── + // weightedScore blends normalized avg toward the global mean when few judges voted. + const rankings = rawRankings.map((r) => { + const n = r.voteCount; + const weightedScore = + n > 0 + ? round2( + (n / (n + C)) * r.normalizedAvg + (C / (n + C)) * globalAvg, + ) + : 0; + const confidenceLevel: "NONE" | "LOW" | "MEDIUM" | "HIGH" = + n === 0 ? "NONE" : n === 1 ? "LOW" : n === 2 ? "MEDIUM" : "HIGH"; + const scoreShift = round2(r.normalizedAvg - r.avgScore); // how much bias-correction shifted this project + + return { ...r, weightedScore, confidenceLevel, scoreShift }; + }); + + // Sort by weighted score desc + rankings.sort((a, b) => b.weightedScore - a.weightedScore); + + // Weighted-score ties + const ties: { + score: number; + projects: { + id: string; + name: string; + tableNumber: number; + zone: string | null; + }[]; + }[] = []; + const scoreGroups = new Map(); + + rankings.forEach((r) => { + const existing = scoreGroups.get(r.weightedScore); + if (existing) { + existing.push(r); + } else { + scoreGroups.set(r.weightedScore, [r]); + } + }); + + scoreGroups.forEach((group, score) => { + if (group.length > 1) { + ties.push({ + score, + projects: group.map((g) => ({ + id: g.project.id, + name: g.project.name, + tableNumber: g.project.tableNumber, + zone: g.project.zone ?? null, + })), + }); + } + }); + + // Per-category ties (only among projects with votes) + const categoryNames = [ + "creativity", + "impact", + "scope", + "clarity", + "soundness", + ] as const; + const categoryLabels: Record<(typeof categoryNames)[number], string> = { + creativity: "Creativity", + impact: "Impact", + scope: "Scope", + clarity: "Clarity", + soundness: "Soundness", + }; + + const categoryTies: { + category: string; + avgScore: number; + projects: { + id: string; + name: string; + tableNumber: number; + zone: string | null; + }[]; + }[] = []; + + for (const cat of categoryNames) { + const catGroups = new Map< + number, + { + id: string; + name: string; + tableNumber: number; + zone: string | null; + }[] + >(); + rankings.forEach((r) => { + if (r.voteCount === 0) return; + const avg = r.categoryAvg[cat]; + const existing = catGroups.get(avg); + const projectInfo = { + id: r.project.id, + name: r.project.name, + tableNumber: r.project.tableNumber, + zone: r.project.zone ?? null, + }; + if (existing) { + existing.push(projectInfo); + } else { + catGroups.set(avg, [projectInfo]); + } + }); + catGroups.forEach((group, avg) => { + if (group.length > 1) { + categoryTies.push({ + category: categoryLabels[cat], + avgScore: avg, + projects: group, + }); + } + }); + } + + const result = { + rankings, + globalAvg, + ties, + hasTies: ties.length > 0, + categoryTies, + hasCategoryTies: categoryTies.length > 0, + }; + + return result; +} + export const judgeRankingsRouter = createTRPCRouter({ getRankings: isAdmin .input(z.object({ hackathonId: z.string().uuid() })) .query(async ({ ctx, input }) => { const cacheKey = `hackathon:${input.hackathonId}:rankings`; - const cached = ctx.cache.get(cacheKey); + const cached = + ctx.cache.get>>(cacheKey); if (cached) return cached; - const projects = await ( - ctx.db as DrizzleDB - ).query.judgingProjects.findMany({ - where: eq(judgingProjects.hackathonId, input.hackathonId), - with: { - votes: { - with: { - judge: { - with: { - user: { - columns: { name: true, email: true }, - }, - }, - }, - }, - }, - }, + 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 a single vote arriving after this would have shifted + * every score. Computing while judging is live produces a snapshot that is + * already stale. + * + * Idempotent — recomputing upserts onto result_unique_placing rather than + * appending a second, contradictory ordering. Published placings are left + * alone; unpublish first if you mean to change what people have seen. + */ + 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 }, }); - const round2 = (n: number) => Math.round(n * 100) / 100; - - // ─── Step 1: Collect all raw scores grouped by judge ────────────────── - // We need per-judge score distributions to perform Z-score normalization, - // which eliminates the "harsh judge / lenient judge" bias problem. - 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); - } + if (!hackathon) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hackathon not found", + }); } - // ─── 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: Build per-judge normalized score lookup ────────────────── - // For each 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]!, - ); + 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.", }); - normalizedScoreLookup.set(judgeId, lookup); } - const getNormalized = (judgeId: string, rawScore: number): number => { - const lookup = normalizedScoreLookup.get(judgeId); - return lookup?.get(rawScore) ?? rawScore; - }; + const published = await db.query.hackathonResults.findFirst({ + where: and( + eq(hackathonResults.hackathonId, input.hackathonId), + isNotNull(hackathonResults.publishedAt), + ), + columns: { id: true }, + }); - // ─── Step 4: Build raw + normalized stats per project ───────────────── - const C = 2; // Bayesian confidence weight - - const rawRankings = projects.map((project) => { - const voteCount = project.votes.length; - - // Raw scores (unadjusted) - const totalScore = project.votes.reduce((sum, v) => sum + v.score, 0); - const avgScore = voteCount > 0 ? totalScore / voteCount : 0; - - // Z-score normalized scores (bias-corrected) - const normalizedScores = project.votes.map((v) => - getNormalized(v.judgeId, v.score), - ); - const normalizedAvg = - voteCount > 0 - ? round2(normalizedScores.reduce((a, b) => a + b, 0) / voteCount) - : 0; - - // Per-category averages (raw) - const sumCat = { - creativity: 0, - impact: 0, - scope: 0, - clarity: 0, - soundness: 0, - }; - project.votes.forEach((v) => { - sumCat.creativity += v.scoreCreativity ?? 0; - sumCat.impact += v.scoreImpact ?? 0; - sumCat.scope += v.scoreScope ?? 0; - sumCat.clarity += v.scoreClarity ?? 0; - sumCat.soundness += v.scoreSoundness ?? 0; + if (published) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Results are already published. Unpublish them before recomputing.", }); + } - 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, - 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", + // Reuses the live ranking pipeline rather than duplicating the maths — + // two implementations of a scoring formula is two answers to "who won". + const { rankings } = await computeRanking(db, input.hackathonId); + + if (rankings.length === 0) { + return { computed: 0 }; + } + + await db + .insert(hackathonResults) + .values( + rankings.map((row, index) => ({ + hackathonId: input.hackathonId, + projectId: row.project.id, + sourceProjectId: row.project.sourceProjectId ?? null, + track: null, + 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()`, + }, + }); - // ─── 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 combined final score ────────────────── - // weightedScore blends normalized avg toward the global mean when few judges voted. - const rankings = rawRankings.map((r) => { - const n = r.voteCount; - const weightedScore = - n > 0 - ? round2( - (n / (n + C)) * r.normalizedAvg + (C / (n + C)) * globalAvg, - ) - : 0; - const confidenceLevel: "NONE" | "LOW" | "MEDIUM" | "HIGH" = - n === 0 ? "NONE" : n === 1 ? "LOW" : n === 2 ? "MEDIUM" : "HIGH"; - const scoreShift = round2(r.normalizedAvg - r.avgScore); // how much bias-correction shifted this project - - return { ...r, weightedScore, confidenceLevel, scoreShift }; - }); + ctx.cache.delete(`hackathon:${input.hackathonId}:results`); - // 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]); - } - }); + return { computed: rankings.length }; + }), - 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, - })), - }); - } + /** 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)], }); + }), - // 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", - }; + publishResults: isAdmin + .input(z.object({ hackathonId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const rows = await (ctx.db as DrizzleDB) + .update(hackathonResults) + .set({ publishedAt: new Date() }) + .where(eq(hackathonResults.hackathonId, input.hackathonId)) + .returning({ id: hackathonResults.id }); - 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, - }); - } + if (rows.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Nothing to publish — compute the results first.", }); } - const result = { - rankings, - globalAvg, - ties, - hasTies: ties.length > 0, - categoryTies, - hasCategoryTies: categoryTies.length > 0, - }; + ctx.cache.delete(`hackathon:${input.hackathonId}:results`); - ctx.cache.set(cacheKey, result, 30); // 30 second cache for live rankings + return { published: rows.length }; + }), - return result; + /** 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 }); + + ctx.cache.delete(`hackathon:${input.hackathonId}:results`); + + return { unpublished: rows.length }; }), }); diff --git a/packages/api/src/routers/team.ts b/packages/api/src/routers/team.ts index bef558f1..3c85ff4b 100644 --- a/packages/api/src/routers/team.ts +++ b/packages/api/src/routers/team.ts @@ -8,6 +8,7 @@ import { hackathons, } from "@query/db"; import { eq, and, or, isNull, inArray, lt, sql } from "drizzle-orm"; +import { VOLATILE_TTL } from "../middleware/cache"; import type { DrizzleDB } from "@query/db"; const HOUR = 60 * 60 * 1000; @@ -560,6 +561,9 @@ export const teamRouter = createTRPCRouter({ technologies: z.array(z.string()).optional(), tracks: z.array(z.string()).optional(), challenges: z.array(z.string()).optional(), + // Judge routing filters on exactly this, and nothing else in the + // product ever set it — every CreateX judge got an empty pool. + isCreateX: z.boolean().optional(), githubUrl: z .string() .url("Must be a valid URL") @@ -718,6 +722,7 @@ export const teamRouter = createTRPCRouter({ technologies: input.technologies || [], tracks: input.tracks || [], challenges: input.challenges || [], + isCreateX: input.isCreateX ?? false, githubUrl, demoUrl, videoUrl, @@ -740,6 +745,7 @@ export const teamRouter = createTRPCRouter({ technologies: input.technologies || [], tracks: input.tracks || [], challenges: input.challenges || [], + isCreateX: input.isCreateX ?? false, githubUrl, demoUrl, videoUrl, @@ -863,37 +869,58 @@ export const teamRouter = createTRPCRouter({ return await loadTeamWindow(ctx.db as DrizzleDB, input.hackathonId); }), + /** + * Every team in a hackathon. + * + * Deliberately NOT paginated. The Teams tab finds the caller's own team by + * searching this list, so with a page size any member of an early-created + * team would fall off page one and lose their entire "Your Team" panel, + * including Leave Team, with nothing on screen explaining why. + * + * Bounded by caching instead. The TTL is deliberately short: the tab + * refetches immediately after every join, leave and disband, and a long TTL + * served from another instance would show a roster the user just changed. + */ list: protectedProcedure .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") })) .query(async ({ ctx, input }) => { - const teams = await ( - ctx.db as NonNullable - ).query.hackathonTeams.findMany({ - where: eq(hackathonTeams.hackathonId, input.hackathonId), - with: { - captain: { - columns: { id: true, name: true, image: true }, - }, - participants: { - // Same rule as the public hackathon.getTeams roster: any signed-in - // caller can read every team here, so it carries neither the - // decision made on each application — registrationStatus names - // everyone rejected or waitlisted — nor the participant id, which - // is the entire content of that participant's event pass QR. - // userId identifies the captain and keys the list. - columns: { - userId: true, + const cacheKey = `hackathon:${input.hackathonId}:teams`; + + const fetchTeams = () => + (ctx.db as NonNullable).query.hackathonTeams.findMany({ + where: eq(hackathonTeams.hackathonId, input.hackathonId), + with: { + captain: { + columns: { id: true, name: true, image: true }, }, - with: { - user: { - columns: { id: true, name: true, image: true }, + participants: { + // Any signed-in caller can read every team here, so it carries + // neither the decision made on each application — + // registrationStatus names everyone rejected or waitlisted — nor + // the participant id, which is the entire content of that + // participant's event pass QR. userId identifies the captain and + // keys the list. + columns: { + userId: true, + }, + with: { + user: { + columns: { id: true, name: true, image: true }, + }, }, }, }, - }, - orderBy: (hackathonTeams, { desc }) => [desc(hackathonTeams.createdAt)], - }); + orderBy: (hackathonTeams, { desc }) => [ + desc(hackathonTeams.createdAt), + ], + }); + + const cached = + ctx.cache.get>>(cacheKey); + if (cached !== null) return cached; + const teams = await fetchTeams(); + ctx.cache.set(cacheKey, teams, VOLATILE_TTL); return teams; }), diff --git a/packages/api/src/services/portal-context.ts b/packages/api/src/services/portal-context.ts index 138ff3d6..6e4f28a6 100644 --- a/packages/api/src/services/portal-context.ts +++ b/packages/api/src/services/portal-context.ts @@ -8,7 +8,7 @@ import { import { eq, and } from "drizzle-orm"; import type { DrizzleDB } from "@query/db"; import { cache, clearMembershipCaches } from "../middleware/cache"; -import { EMPTY_MEMBER_CONTEXT } from "../types/portal-context"; +import { EMPTY_MEMBER_CONTEXT, isStaffRole } from "../types/portal-context"; import type { MemberContext, PortalContext } from "../types/portal-context"; const CURRENT_HACKATHON_KEY = "hackathon:current-id"; @@ -140,7 +140,11 @@ export async function fetchPortalContext( const isProjectLeader = !!leaderRecord; return { - isAdmin: !!admin, + // A volunteer holds an admins row but is not staff. Reporting them as + // admin here would render the whole admin nav for someone every one of + // those pages rejects. + isAdmin: isStaffRole(admin?.role), + isScanner: !!admin, role: admin?.role ?? null, permissions: admin?.permissions ?? [], isJudge: !!judgeRecord, diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 9c21bc64..c62d0eae 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -250,7 +250,11 @@ const CACHE_INVALIDATION_MAP: Record = { "hackathon:*:participants", "hackathon:*:analytics", ], - "hackathon.scanParticipantPass": ["hackathon:*:participants"], + // A badge scan changes one event's attendee count, not the roster. The + // resolver evicts that single key by id; an empty list here keeps the + // namespace fallback below from wiping every attendee's cached registrations + // on every scan, all weekend, at every door. + "hackathon.scanParticipantPass": [], "hackathon.create": ["hackathons:list"], "hackathon.update": ["hackathons:list", "hackathon:*"], "hackathon.delete": ["hackathons:list", "hackathon:*"], @@ -272,17 +276,63 @@ const CACHE_INVALIDATION_MAP: Record = { "hackathon:*:rankings", "hackathon:*:judge-analytics", ], + // Promotion creates judgeable projects and flips submissions to "judging", + // so both the public project list and the rankings view move. + "judge.promoteSubmissions": [ + "hackathon:*:projects", + "hackathon:*:public-projects*", + "hackathon:*:rankings", + ], + // Announcements read the audience live and write nothing cacheable. + "hackathon.sendAnnouncement": [], "judge.assignToHackathon": ["judge:*"], // Member mutations "member.update": ["member:*", "user:*:profile"], // A renewal changes the membership the portal reads, so its context must go too // Team mutations — team membership is embedded in both the public roster and // each participant's own registration list - "team.createTeam": ["hackathon:*:participants", "hackathon:registrations:*"], - "team.joinTeam": ["hackathon:*:participants", "hackathon:registrations:*"], - "team.leaveTeam": ["hackathon:*:participants", "hackathon:registrations:*"], - "team.disbandTeam": ["hackathon:*:participants", "hackathon:registrations:*"], - "team.submitProject": ["hackathon:*:projects", "hackathon:registrations:*"], + // team.list is cached now, and the tab refetches straight after each of + // these — so the roster key has to go with them or the user sees the state + // they just changed back again. + "team.createTeam": [ + "hackathon:*:participants", + "hackathon:*:teams", + "hackathon:registrations:*", + ], + "team.joinTeam": [ + "hackathon:*:participants", + "hackathon:*:teams", + "hackathon:registrations:*", + ], + "team.leaveTeam": [ + "hackathon:*:participants", + "hackathon:*:teams", + "hackathon:registrations:*", + ], + "team.disbandTeam": [ + "hackathon:*:participants", + "hackathon:*:teams", + "hackathon:registrations:*", + ], + // The public gallery is cached per page, so its keys carry a limit/offset + // suffix that a bare `:projects` pattern would not match. + "team.submitProject": [ + "hackathon:*:projects", + "hackathon:*:public-projects*", + "hackathon:registrations:*", + ], + "team.withdrawProject": [ + "hackathon:*:projects", + "hackathon:*:public-projects*", + ], + // Both evict precisely by id in the resolver; empty keeps the namespace + // fallback from sweeping every attendee's cached registrations. + "hackathon.adminUpdateProject": [], + "hackathon.adminWithdrawProject": [], + // Publishing and unpublishing change what the public getResults returns. + "judge.computeResults": ["hackathon:*:results"], + "judge.publishResults": ["hackathon:*:results"], + "judge.unpublishResults": ["hackathon:*:results"], // Stripe — invalidate member status after linking "stripe.attemptAutoLink": ["member:*"], "stripe.linkAccount": ["member:*"], @@ -331,12 +381,17 @@ export const publicProcedure = t.procedure .use(sanitizeInputs) .use(enforceContentType) .use(async ({ ctx, next, type }) => { - // DDoS Protection - check IP-based limits first - const ddosCheck = ddosProtection(ctx.clientIp); + // Flood protection. Key on the signed-in user when there is one: at a + // 2000-person venue every attendee shares one NAT address, so an + // address-keyed bucket blocks the whole building the moment the schedule + // page gets popular. Prefixes keep the two namespaces from colliding. + const ddosCheck = ddosProtection( + ctx.userId ? `user:${ctx.userId}` : `ip:${ctx.clientIp}`, + ); if (!ddosCheck.allowed) { throw new TRPCError({ code: "TOO_MANY_REQUESTS", - message: `Too many requests from your IP. Please try again in ${ddosCheck.retryAfter} seconds.`, + message: `Too many requests. Please try again in ${ddosCheck.retryAfter} seconds.`, }); } diff --git a/packages/api/src/types/portal-context.ts b/packages/api/src/types/portal-context.ts index e40d5541..b545efce 100644 --- a/packages/api/src/types/portal-context.ts +++ b/packages/api/src/types/portal-context.ts @@ -16,8 +16,22 @@ export type MemberContext = { renewalCount: number; }; +/** + * Full staff, as opposed to a volunteer. + * + * Lives here rather than beside the middleware because both the middleware and + * the portal context need it, and procedures.ts already imports from the + * portal-context service — putting it there would close an import cycle. + */ +export const isStaffRole = (role: string | null | undefined) => + !!role && role !== "volunteer"; + export type PortalContext = { + /** Full staff. False for volunteers, who hold an admins row but are limited + * to badge scanning. */ isAdmin: boolean; + /** Any active admins row, volunteers included — may staff a check-in desk. */ + isScanner: boolean; role: string | null; permissions: string[]; isJudge: boolean; diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 449f220e..e91ff584 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -180,16 +180,21 @@ export const authConfig: NextAuthConfig = { error: "/auth/error", }, callbacks: { + /** + * Deliberately does no database work beyond what the adapter already did. + * + * With the database session strategy this callback runs on every single + * request, so anything queried here is queried once per request per user. + * A judge lookup used to live here to set `session.user.isJudge` — with + * 2000 attendees, none of whom are judges, that was a second connection + * checkout per request across the whole fleet. + * + * Judge status is read from `user.getPortalContext` (cached) and + * `judge.isJudge` instead, which is where every consumer already gets it. + */ async session({ session, user }) { - if (user && session.user && db) { + if (user && session.user) { session.user.id = user.id; - - // Add judge status to session for easier client-side checks - const judge = await db.query.judges.findFirst({ - where: (j, { eq }) => eq(j.userId, user.id), - }); - // @ts-expect-error - custom property - session.user.isJudge = !!judge; } return session; }, diff --git a/packages/auth/src/email.ts b/packages/auth/src/email.ts index 5a359a93..f41f0967 100644 --- a/packages/auth/src/email.ts +++ b/packages/auth/src/email.ts @@ -1,4 +1,138 @@ import nodemailer from "nodemailer"; +import type { Transporter } from "nodemailer"; + +/** + * One pooled transporter for the process, built on first use. + * + * `pool: true` only does anything if the transporter outlives the message. + * Built per call it was worse than useless: every recipient paid a fresh + * TCP + TLS + AUTH handshake and left a pool behind to be garbage collected. + * A mass acceptance send is thousands of messages, so that is the difference + * between a batch that finishes and one that times out. + * + * Lazily created so importing this module never requires SMTP config — + * the send path is the only thing that needs it. + */ +let transporter: Transporter | null = null; + +const getTransporter = () => { + if (!transporter) { + transporter = nodemailer.createTransport({ + host: process.env.EMAIL_SERVER_HOST, + port: Number(process.env.EMAIL_SERVER_PORT || "587"), + auth: { + user: process.env.EMAIL_SERVER_USER, + pass: process.env.EMAIL_SERVER_PASSWORD, + }, + pool: true, + // Deliberately env-tunable. A consumer Gmail account tolerates far less + // than a bulk provider, and the same code has to serve both: point + // EMAIL_SERVER_* at Mailgun/SendGrid/SES and raise these, no redeploy of + // anything but config. + maxConnections: Number(process.env.EMAIL_MAX_CONNECTIONS || "5"), + maxMessages: Number(process.env.EMAIL_MAX_MESSAGES || "100"), + }); + } + return transporter; +}; + +const escapeHtml = (value: string) => + value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +/** + * The shared shell every transactional message uses, so an announcement looks + * like it came from the same organisation as the acceptance. + */ +const renderShell = ({ + heading, + bodyHtml, + ctaLabel, + ctaUrl, +}: { + heading: string; + bodyHtml: string; + ctaLabel?: string; + ctaUrl?: string; +}) => { + const mainColor = "#10b981"; + const backgroundColor = "#0f172a"; + const textColor = "#f8fafc"; + + const cta = + ctaLabel && ctaUrl + ? `${escapeHtml(ctaLabel)}` + : ""; + + return ` + + + + + + + + + + + +
+
+

DataScienceGT

+
+

${escapeHtml(heading)}

+
${bodyHtml}
+ ${cta} +
+ © ${new Date().getFullYear()} Data Science at Georgia Tech +
+ + `; +}; + +/** + * One announcement to one recipient — "registration is open", "schedule is + * live", "results are up". + * + * `body` is plain text written by an organiser in the admin panel. It is + * escaped and then newline-split into paragraphs: treating it as HTML would + * make the compose box an injection point into thousands of inboxes. + */ +export async function sendAnnouncementEmail({ + email, + subject, + heading, + body, + ctaLabel, + ctaUrl, +}: { + email: string; + subject: string; + heading: string; + body: string; + ctaLabel?: string; + ctaUrl?: string; +}) { + const bodyHtml = body + .split(/\n{2,}/) + .map( + (paragraph) => + `

${escapeHtml(paragraph).replace(/\n/g, "
")}

`, + ) + .join(""); + + await getTransporter().sendMail({ + from: process.env.EMAIL_FROM || "noreply@datasciencegt.org", + to: email, + subject, + text: body, + html: renderShell({ heading, bodyHtml, ctaLabel, ctaUrl }), + }); +} export async function sendAcceptanceEmail({ email, @@ -9,16 +143,6 @@ export async function sendAcceptanceEmail({ hackathonName: string; host?: string; }) { - const transporter = nodemailer.createTransport({ - host: process.env.EMAIL_SERVER_HOST, - port: Number(process.env.EMAIL_SERVER_PORT || "587"), - auth: { - user: process.env.EMAIL_SERVER_USER, - pass: process.env.EMAIL_SERVER_PASSWORD, - }, - pool: true, - }); - const mainColor = "#10b981"; const backgroundColor = "#0f172a"; const textColor = "#f8fafc"; @@ -69,7 +193,7 @@ export async function sendAcceptanceEmail({ `; - await transporter.sendMail({ + await getTransporter().sendMail({ from: process.env.EMAIL_FROM || "noreply@datasciencegt.org", to: email, subject: `You're accepted to ${hackathonName}!`, diff --git a/packages/db/src/schemas/admins.ts b/packages/db/src/schemas/admins.ts index 2e1b3176..41e49eab 100644 --- a/packages/db/src/schemas/admins.ts +++ b/packages/db/src/schemas/admins.ts @@ -8,7 +8,13 @@ export const admins = pgTable("admin", { .notNull() .unique() .references(() => users.id, { onDelete: "cascade" }), - role: text("role", { enum: ["super_admin", "admin", "moderator"] }) + // "volunteer" is deliberately the weakest tier and is NOT full staff: it + // exists so the six-to-ten people running check-in desks can scan badges + // without holding the role that can delete the hackathon. isAdmin rejects + // it; only the scanner procedures accept it. + role: text("role", { + enum: ["super_admin", "admin", "moderator", "volunteer"], + }) .notNull() .default("admin"), permissions: text("permissions").array(), diff --git a/packages/db/src/schemas/events.ts b/packages/db/src/schemas/events.ts index 9dedcf04..188b1e54 100644 --- a/packages/db/src/schemas/events.ts +++ b/packages/db/src/schemas/events.ts @@ -5,6 +5,7 @@ import { uuid, boolean, integer, + index, unique, } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; @@ -54,6 +55,11 @@ export const eventCheckIns = pgTable( // but that only covers the one path that takes the lock — the constraint is // what holds for any future manual or imported check-in as well. unique("unique_event_check_in").on(table.eventId, table.userId), + // The unique above leads with eventId, so a lookup by user alone cannot use + // it. events.myEvents and myStats filter on exactly userId and run on every + // portal dashboard load — without this they sequentially scan the whole + // check-in table. + index("event_check_in_user_id_idx").on(table.userId), ], ); diff --git a/packages/db/src/schemas/hackathons.ts b/packages/db/src/schemas/hackathons.ts index 4ac85564..f60a2b5d 100644 --- a/packages/db/src/schemas/hackathons.ts +++ b/packages/db/src/schemas/hackathons.ts @@ -170,6 +170,11 @@ export const hackathonParticipants = pgTable( hasSubmittedProject: boolean("has_submitted_project") .notNull() .default(false), + // Stamped per participant as their acceptance mail leaves. A mass send is + // thousands of SMTP round trips and can die halfway through; without a + // per-row marker the only safe retry is none, and the unsafe one mails + // everybody twice. + acceptanceEmailSentAt: timestamp("acceptance_email_sent_at"), registeredAt: timestamp("registered_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), @@ -178,6 +183,13 @@ export const hackathonParticipants = pgTable( index("participant_hackathon_id_idx").on(table.hackathonId), index("participant_user_id_idx").on(table.userId), index("participant_team_id_idx").on(table.teamId), + // syncCurrentParticipants filters on exactly this pair and runs after every + // approve and every check-in. Without it each call is a full scan of the + // participant table. + index("participant_hackathon_status_idx").on( + table.hackathonId, + table.registrationStatus, + ), // Enforce one registration per user per hackathon at the DB level. // This prevents duplicates even under concurrent requests that race // past the application-level findFirst check inside the transaction. diff --git a/packages/db/src/schemas/judge.ts b/packages/db/src/schemas/judge.ts index f6e1ca44..0cb2cc10 100644 --- a/packages/db/src/schemas/judge.ts +++ b/packages/db/src/schemas/judge.ts @@ -8,10 +8,11 @@ import { index, uniqueIndex, unique, + numeric, } from "drizzle-orm/pg-core"; -import { relations } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { users } from "./auth"; -import { hackathons } from "./hackathons"; +import { hackathons, hackathonProjects } from "./hackathons"; export const judges = pgTable( "judge", @@ -67,8 +68,8 @@ export const judgeAssignments = pgTable( (table) => [ index("assignment_judge_id_idx").on(table.judgeId), index("assignment_hackathon_id_idx").on(table.hackathonId), - // assignToHackathon, judge.register and bulkImportJudges all enforce one - // assignment per judge per hackathon with a read before the insert. + // assignToHackathon and judge.register both enforce one assignment per + // judge per hackathon with a read before the insert. unique("unique_assignment_per_hackathon").on( table.judgeId, table.hackathonId, @@ -84,6 +85,14 @@ export const judgingProjects = pgTable( hackathonId: uuid("hackathon_id") .notNull() .references(() => hackathons.id, { onDelete: "cascade" }), + // The submission this judgeable entry was promoted from. Judging runs on + // this table while participants submit into hackathon_project, and without + // this column the two halves share no key at all — a winner could not be + // mapped back to the team that built it. + sourceProjectId: uuid("source_project_id").references( + () => hackathonProjects.id, + { onDelete: "cascade" }, + ), name: text("name").notNull(), description: text("description"), tableNumber: integer("table_number").notNull(), @@ -100,6 +109,19 @@ export const judgingProjects = pgTable( (table) => [ index("judging_project_hackathon_id_idx").on(table.hackathonId), index("judging_project_table_idx").on(table.tableNumber), + // A table number identifies one physical table at one event. Without this, + // a retried CSV import appends the entire project list a second time with + // fresh numbers, and judges get routed to tables that do not exist. + uniqueIndex("judging_project_table_unique").on( + table.hackathonId, + table.tableNumber, + ), + // Partial: one judgeable entry per submission, while still allowing any + // number of rows that came from nowhere. This is what makes promoting + // submissions safe to re-run as teams keep submitting. + uniqueIndex("judging_project_source_unique") + .on(table.sourceProjectId) + .where(sql`${table.sourceProjectId} is not null`), ], ); @@ -134,20 +156,57 @@ export const judgeVotes = pgTable( ], ); -// Map images for hackathon venues -export const hackathonMaps = pgTable( - "hackathon_map", +/** + * A frozen placing, computed once when judging closes. + * + * getRankings recomputes the whole ordering on every call, and its z-score + * normalisation runs over the entire vote set — so one late vote silently + * changes every project's score, including ones already announced. The + * ordering existed only inside an HTTP response; nothing in the product could + * say who won yesterday. + * + * A snapshot instead: computed deliberately, reviewable while unpublished, and + * unchanged by anything that happens to the votes afterwards. + */ +export const hackathonResults = pgTable( + "hackathon_result", { id: uuid("id").defaultRandom().primaryKey(), hackathonId: uuid("hackathon_id") .notNull() .references(() => hackathons.id, { onDelete: "cascade" }), - imageUrl: text("image_url").notNull(), - name: text("name"), - order: integer("order").notNull().default(0), - createdAt: timestamp("created_at").defaultNow().notNull(), + projectId: uuid("project_id") + .notNull() + .references(() => judgingProjects.id, { onDelete: "cascade" }), + /** Carried across at compute time so results survive the judging tables + * and can name the team that actually built the thing. */ + sourceProjectId: uuid("source_project_id").references( + () => hackathonProjects.id, + { onDelete: "set null" }, + ), + /** Which prize this placing is for. Null is the overall ranking. */ + track: text("track"), + placement: integer("placement").notNull(), + /** The blended score at the moment of computation. `numeric` because the + * pipeline produces a float — hackathon_project.score is an integer and + * could never have held this value. */ + weightedScore: numeric("weighted_score", { precision: 6, scale: 2 }), + voteCount: integer("vote_count").notNull().default(0), + /** Null while the snapshot is a draft. Set on publish; cleared on + * unpublish, which is what makes publishing reversible. */ + publishedAt: timestamp("published_at"), + computedAt: timestamp("computed_at").defaultNow().notNull(), }, - (table) => [index("map_hackathon_id_idx").on(table.hackathonId)], + (table) => [ + index("result_hackathon_idx").on(table.hackathonId), + // One placing per project per prize. Recomputing upserts onto this rather + // than appending a second, contradictory ordering. + uniqueIndex("result_unique_placing").on( + table.hackathonId, + table.projectId, + table.track, + ), + ], ); // Track which tables a judge still needs to visit @@ -244,12 +303,23 @@ export const judgeVotesRelations = relations(judgeVotes, ({ one }) => ({ }), })); -export const hackathonMapsRelations = relations(hackathonMaps, ({ one }) => ({ - hackathon: one(hackathons, { - fields: [hackathonMaps.hackathonId], - references: [hackathons.id], +export const hackathonResultsRelations = relations( + hackathonResults, + ({ one }) => ({ + hackathon: one(hackathons, { + fields: [hackathonResults.hackathonId], + references: [hackathons.id], + }), + project: one(judgingProjects, { + fields: [hackathonResults.projectId], + references: [judgingProjects.id], + }), + sourceProject: one(hackathonProjects, { + fields: [hackathonResults.sourceProjectId], + references: [hackathonProjects.id], + }), }), -})); +); export const judgeQueueRelations = relations(judgeQueue, ({ one }) => ({ judge: one(judges, { diff --git a/packages/db/src/schemas/members.ts b/packages/db/src/schemas/members.ts index 6abdc14d..03c810ec 100644 --- a/packages/db/src/schemas/members.ts +++ b/packages/db/src/schemas/members.ts @@ -36,9 +36,14 @@ export const members = pgTable( userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), + // restrict, not cascade. This row is the paid club membership behind a + // stripe_payment, and it is not reconstructible: linkPaidPaymentByVerifiedEmail + // short-circuits on already-linked and every re-grant path filters on an + // unlinked payment, so no amount of signing back in restores it. Deleting a + // hackathon must fail loudly rather than quietly destroy paid records. hackathonId: uuid("hackathon_id") .notNull() - .references(() => hackathons.id, { onDelete: "cascade" }), + .references(() => hackathons.id, { onDelete: "restrict" }), memberType: text("member_type", { enum: ["new", "continuous"] }) .notNull() .default("new"), diff --git a/sites/mainweb/app/(portal)/admin/analytics/page.tsx b/sites/mainweb/app/(portal)/admin/analytics/page.tsx index d7bf26f2..af0d7e8e 100644 --- a/sites/mainweb/app/(portal)/admin/analytics/page.tsx +++ b/sites/mainweb/app/(portal)/admin/analytics/page.tsx @@ -76,7 +76,10 @@ export default function AnalyticsPage() { const { data: stats, isLoading } = trpc.admin.analyticsOverview.useQuery( undefined, - { enabled: !!session, refetchInterval: 5000 }, + // Matched to the server's cache entry. Polling faster only produced + // repeated cache hits and a request per tab per 5s for numbers that move + // on a much slower clock. + { enabled: !!session, refetchInterval: 15000 }, ); if (status === "unauthenticated") { diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/attendees/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/attendees/page.tsx deleted file mode 100644 index afc9dd28..00000000 --- a/sites/mainweb/app/(portal)/admin/hackathons/[id]/attendees/page.tsx +++ /dev/null @@ -1,157 +0,0 @@ -"use client"; - -import React, { useState } from "react"; -import { useSession } from "next-auth/react"; -import { trpc } from "@/lib/trpc"; -import { usePortalContext } from "@/lib/use-portal-context"; -import { useParams, useRouter } from "next/navigation"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; -import { LiquidGlass } from "@/components/portal/LiquidGlass"; - -export default function AdminAttendeeViewer() { - const { data: session, status: authStatus } = useSession(); - const router = useRouter(); - const params = useParams(); - const hackathonId = params?.id as string; - - const [selectedIds, setSelectedIds] = useState>(new Set()); - - const { data: portalContext, isLoading: portalLoading } = usePortalContext(); - const { data: hackathon, isLoading: loadingHackathon } = - trpc.hackathon.getById.useQuery( - { id: hackathonId }, - { enabled: !!hackathonId }, - ); - const { data: attendees, isLoading: loadingAttendees, refetch } = - trpc.hackathon.adminGetAttendees.useQuery( - { hackathonId }, - { enabled: !!hackathonId && !!portalContext?.isAdmin }, - ); - - const massAcceptMutation = trpc.hackathon.sendMassAcceptanceEmails.useMutation({ - onSuccess: (result) => { - setSelectedIds(new Set()); - refetch(); - // Show what the server actually did: ids that belong to another - // hackathon are skipped, and silently reporting success for them hides - // acceptances that never went out. - alert(result.message); - }, - onError: (e) => alert("Error: " + e.message) - }); - - if ( - authStatus === "loading" || - portalLoading || - loadingHackathon || - loadingAttendees - ) { - return ; - } - - if (!session || !portalContext?.isAdmin || !hackathon) { - router.push("/dashboard"); - return null; - } - - const handleSelectAll = () => { - if (attendees) { - if (selectedIds.size === attendees.length) { - setSelectedIds(new Set()); - } else { - setSelectedIds(new Set(attendees.map(a => a.id))); - } - } - }; - - const handleSelect = (id: string) => { - const next = new Set(selectedIds); - if (next.has(id)) next.delete(id); - else next.add(id); - setSelectedIds(next); - }; - - const handleMassAccept = () => { - if (selectedIds.size === 0) return; - if (confirm(`Are you sure you want to accept and send emails to ${selectedIds.size} participants?`)) { - massAcceptMutation.mutate({ - hackathonId, - participantIds: Array.from(selectedIds) - }); - } - }; - - return ( -
-
-

- {hackathon.name} Attendees -

- -
- - -
- - - - - - - - - - - - {attendees && attendees.length > 0 ? ( - attendees.map((attendee) => ( - - - - - - - - )) - ) : ( - - - - )} - -
- 0 && selectedIds.size === attendees.length} - onChange={handleSelectAll} - className="accent-accent" - /> - NameEmailStatusTeam
- handleSelect(attendee.id)} - className="accent-accent" - /> - {attendee.user?.name || "No Name"}{attendee.user?.email || "No Email"} - - {attendee.registrationStatus} - - {attendee.team?.name || "Solo"}
- No attendees found. -
-
-
-
- ); -} diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx index bc986257..7b6a7864 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx @@ -12,9 +12,16 @@ import { AttendeesTab } from "@/components/admin/hackathons/AttendeesTab"; import { AnalyticsTab } from "@/components/admin/hackathons/AnalyticsTab"; import { EventsTab } from "@/components/admin/hackathons/EventsTab"; import { JudgesTab } from "@/components/admin/hackathons/JudgesTab"; -import { Gavel } from "lucide-react"; +import { AnnouncementsTab } from "@/components/admin/hackathons/AnnouncementsTab"; +import { Gavel, Megaphone } from "lucide-react"; -type Tab = "events" | "scanner" | "attendees" | "analytics" | "judges"; +type Tab = + | "events" + | "scanner" + | "attendees" + | "analytics" + | "judges" + | "announcements"; export default function AdminHackathonDashboard() { const { status } = useSession(); @@ -52,6 +59,11 @@ export default function AdminHackathonDashboard() { icon: , }, { id: "judges", label: "Judges", icon: }, + { + id: "announcements", + label: "Email", + icon: , + }, ]; return ( @@ -177,6 +189,9 @@ export default function AdminHackathonDashboard() { )} {activeTab === "judges" && } + {activeTab === "announcements" && ( + + )} diff --git a/sites/mainweb/app/(portal)/admin/projects/page.tsx b/sites/mainweb/app/(portal)/admin/projects/page.tsx index d6cc88eb..85090b2e 100644 --- a/sites/mainweb/app/(portal)/admin/projects/page.tsx +++ b/sites/mainweb/app/(portal)/admin/projects/page.tsx @@ -24,6 +24,46 @@ export default function ProjectsPage() { selectedHackathon ? { hackathonId: selectedHackathon } : skipToken, ); + // The one project being repaired, and the field values being repaired to. + const [editing, setEditing] = useState(null); + const [form, setForm] = useState({ + name: "", + description: "", + githubUrl: "", + demoUrl: "", + videoUrl: "", + }); + const [actionError, setActionError] = useState(null); + const [withdrawing, setWithdrawing] = useState(null); + + const utils = trpc.useUtils(); + + const refresh = () => { + if (selectedHackathon) { + utils.hackathon.projects.invalidate({ hackathonId: selectedHackathon }); + } + }; + + const updateProject = trpc.hackathon.adminUpdateProject.useMutation({ + onSuccess: () => { + setEditing(null); + setActionError(null); + refresh(); + }, + onError: (error) => setActionError(error.message), + }); + + const withdrawProject = trpc.hackathon.adminWithdrawProject.useMutation({ + onSuccess: () => { + setWithdrawing(null); + setActionError(null); + refresh(); + }, + // A project already in judging comes back as CONFLICT with what that + // means; the second press confirms it. + onError: (error) => setActionError(error.message), + }); + if (status === "unauthenticated") { router.push("/login"); return null; @@ -167,6 +207,125 @@ export default function ProjectsPage() { Team: {project.team?.name || "Unknown"} + + {editing === project.id ? ( +
+ {( + [ + ["name", "Name"], + ["description", "Description"], + ["githubUrl", "Repo URL"], + ["demoUrl", "Demo URL"], + ["videoUrl", "Video URL"], + ] as const + ).map(([field, label]) => ( +
+ + + setForm((f) => ({ + ...f, + [field]: e.target.value, + })) + } + className="w-full px-3 py-2 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-xs font-mono focus:border-accent/50 focus:outline-none" + /> +
+ ))} +
+ + +
+
+ ) : ( +
+ + {project.status !== "draft" && ( + + )} +
+ )} + + {actionError && + (editing === project.id || + withdrawing === project.id) && ( +

+ {actionError} +

+ )} ))} diff --git a/sites/mainweb/app/(portal)/admin/setup/page.tsx b/sites/mainweb/app/(portal)/admin/setup/page.tsx index 1bc1be76..39c244f5 100644 --- a/sites/mainweb/app/(portal)/admin/setup/page.tsx +++ b/sites/mainweb/app/(portal)/admin/setup/page.tsx @@ -10,27 +10,6 @@ import { useRouter } from "next/navigation"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { SetupWizard } from "@/components/admin/setup/SetupWizard"; import { CreateHackathonStep } from "@/components/admin/setup/CreateHackathonStep"; -import { - ImportJudgesStep, - ImportProjectsStep, -} from "@/components/admin/setup/ImportDataStep"; - -type ParsedJudge = { name: string; email: string; track?: string }; -type ParsedProject = { - name: string; - teamMembers?: string; - mainTrack?: string; - extraTracks: string[]; - isCreateX: boolean; -}; - -function parseCSV(text: string): string[][] { - return text - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => line.split(",").map((cell) => cell.trim())); -} export default function AdminSetupPage() { const { data: session } = useSession(); @@ -46,13 +25,8 @@ export default function AdminSetupPage() { null, ); - // CSV data - const [judgesData, setJudgesData] = useState([]); - const [projectsData, setProjectsData] = useState([]); - // Status tracking - const [judgesImported, setJudgesImported] = useState(false); - const [projectsImported, setProjectsImported] = useState(false); + const [projectsSynced, setProjectsSynced] = useState(false); const [judgesAssigned, setJudgesAssigned] = useState(false); // Admin check @@ -75,20 +49,13 @@ export default function AdminSetupPage() { }, }); - const importJudges = trpc.judge.bulkImportJudges.useMutation({ - onSuccess: () => { - setJudgesImported(true); - setActiveStep(3); - }, - }); - - const importProjects = trpc.judge.bulkImportProjects.useMutation({ + const promoteSubmissions = trpc.judge.promoteSubmissions.useMutation({ onSuccess: (data) => { - // A run that created nothing leaves the hackathon with no projects to - // judge, so the wizard must not mark the step done and move on. - if (data.created === 0) return; - setProjectsImported(true); - setActiveStep(4); + // Nothing to judge means the step is not done, however cleanly the + // request succeeded — moving on would hand the assigner an empty list. + if (data.total === 0) return; + setProjectsSynced(true); + setActiveStep(3); }, }); @@ -100,63 +67,12 @@ export default function AdminSetupPage() { useEffect(() => setMounted(true), []); - // Parse judges CSV: name,email,track - const handleJudgesCSV = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = (ev) => { - const rows = parseCSV(ev.target?.result as string); - // Skip header row if it looks like headers - const start = rows[0]?.[0]?.toLowerCase() === "name" ? 1 : 0; - const parsed: ParsedJudge[] = rows - .slice(start) - .map((row) => ({ - name: row[0] || "", - email: row[1] || "", - track: row[2] || undefined, - })) - .filter((j) => j.name && j.email); - setJudgesData(parsed); - }; - reader.readAsText(file); - }; - - // Parse projects CSV: name,team_members,main_track,extra_tracks,is_create_x - const handleProjectsCSV = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = (ev) => { - const rows = parseCSV(ev.target?.result as string); - const start = rows[0]?.[0]?.toLowerCase() === "name" ? 1 : 0; - const parsed: ParsedProject[] = rows - .slice(start) - .map((row) => ({ - name: row[0] || "", - teamMembers: row[1] || undefined, - mainTrack: row[2] || undefined, - extraTracks: row[3] - ? row[3] - .split("|") - .map((s) => s.trim()) - .filter(Boolean) - : [], - isCreateX: row[4]?.toLowerCase() === "true", - })) - .filter((p) => p.name); - setProjectsData(parsed); - }; - reader.readAsText(file); - }; - if (!mounted) return null; const steps = [ { num: 1, label: "Create Hackathon", done: !!selectedHackathonId }, - { num: 2, label: "Import Judges", done: judgesImported }, - { num: 3, label: "Import Projects", done: projectsImported }, - { num: 4, label: "Assign Judges", done: judgesAssigned }, + { num: 2, label: "Sync Submissions", done: projectsSynced }, + { num: 3, label: "Assign Judges", done: judgesAssigned }, ]; return ( @@ -167,10 +83,10 @@ export default function AdminSetupPage() { Hackathon Hub

- Judging Data Import + Judging Setup

- Configure the event and import CSV files + Everything comes from the portal — nothing to upload

@@ -220,44 +136,80 @@ export default function AdminSetupPage() { /> )} - {/* Step 2: Import Judges */} + {/* Step 2: Sync submitted projects into judging */} {activeStep === 2 && ( - { - if (!selectedHackathonId) return; - importJudges.mutate({ - hackathonId: selectedHackathonId, - judges: judgesData, - }); - }} - /> - )} + +

+ Sync Submitted Projects +

+

+ Every submitted project becomes a judgeable entry with its own + table number, carrying the tracks and challenges the team picked. + Safe to run again as late submissions land — projects already + synced are left alone. +

- {/* Step 3: Import Projects */} - {activeStep === 3 && ( - { - if (!selectedHackathonId) return; - importProjects.mutate({ - hackathonId: selectedHackathonId, - projects: projectsData, - }); - }} - /> + + + {promoteSubmissions.error && ( +
+

+ {trpcErrorMessage( + promoteSubmissions.error, + "Could not sync submissions.", + )} +

+
+ )} + + {promoteSubmissions.data && + (promoteSubmissions.data.total === 0 ? ( +
+

+ No submitted projects yet. Teams submit from /submit — come + back once the deadline has passed. +

+
+ ) : ( +
+
+

+ {promoteSubmissions.data.created} newly synced |{" "} + {promoteSubmissions.data.alreadyPresent} already in + judging | {promoteSubmissions.data.total} total +

+
+ {/* Queues are a snapshot. Anything promoted after assignment + sits in nobody's queue and is silently never judged. */} + {promoteSubmissions.data.queuesNeedRebuild && ( +
+

+ Judge queues already exist. Re-run Assign Judges or the + newly synced projects will not appear in any queue. +

+
+ )} +
+ ))} +
)} - {/* Step 4: Auto-Assign Judges */} - {activeStep === 4 && ( + {/* Step 3: Auto-Assign Judges */} + {activeStep === 3 && (

Auto-Assign Judges to Projects @@ -267,6 +219,15 @@ export default function AdminSetupPage() { matching projects (randomized).

+
+

+ Judges sign themselves up at{" "} + /judge/register. Approve + their applications under the Judges tab of this hackathon before + assigning — only approved judges get a queue. +

+
+ + )} )} diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx index ea5f5c08..8769ee4b 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx @@ -66,6 +66,9 @@ export default function JudgeHackathonPage() { const [scores, setScores] = useState(BLANK); const [comment, setComment] = useState(""); const [error, setError] = useState(""); + /** Set when a skip had nowhere to rotate to — this is the judge's last + * uncompleted table, so "skip" cannot move them off it. */ + const [stranded, setStranded] = useState(false); const [done, setDone] = useState(false); const [startedAt, setStartedAt] = useState(() => Date.now()); @@ -137,6 +140,25 @@ export default function JudgeHackathonPage() { const skip = trpc.judge.skipProject.useMutation({ onSuccess: (res) => { + // Skipping the last uncompleted item hands back the same project: there + // is nothing to rotate to. Saying so is the difference between a button + // that looks broken and one that explains the only way out. + setStranded(res.skippedToEnd === true); + advance((res.project as Project) ?? null, res.queueId ?? null); + }, + onError: (e) => setError(e.message), + }); + + // The escape from a table nobody is standing at: marks it done without a + // score and hands the project to another judge, so it still gets seen. + const forceSkip = trpc.judge.forceSkipOvertime.useMutation({ + onSuccess: (res) => { + setStranded(false); + if (!res.reassigned) { + setError( + "Marked done, but no other judge was free to take it — flag this table to an organiser.", + ); + } advance((res.project as Project) ?? null, res.queueId ?? null); }, onError: (e) => setError(e.message), @@ -237,7 +259,8 @@ export default function JudgeHackathonPage() { scores.scoreSoundness; const project = current?.project; - const busy = complete.isPending || skip.isPending; + const busy = + complete.isPending || skip.isPending || forceSkip.isPending; return (
@@ -367,6 +390,33 @@ export default function JudgeHackathonPage() { />
+ {stranded && ( +
+

+ This is the last table left in your queue, so there is + nothing to skip to. If nobody is here, hand it to another + judge instead — it will still get scored. +

+ +
+ )} +

Total {total}{" "} diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/participants/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/participants/page.tsx deleted file mode 100644 index 98844a1c..00000000 --- a/sites/mainweb/app/(portal)/hackathons/[id]/participants/page.tsx +++ /dev/null @@ -1,349 +0,0 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { useSession } from "next-auth/react"; -import { trpc } from "@/lib/trpc"; -import { useRouter, useParams, useSearchParams } from "next/navigation"; -import { LiquidGlass } from "@/components/portal/LiquidGlass"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; -import Link from "next/link"; - -// Extracted Tab Components -import { InfoTab } from "@/components/hackathon/InfoTab"; -import { ScheduleTab } from "@/components/hackathon/ScheduleTab"; -import { ProjectsTab } from "@/components/hackathon/ProjectsTab"; -import { TeamsTab } from "@/components/hackathon/TeamsTab"; -import { HackathonUnavailable } from "@/components/hackathon/HackathonUnavailable"; - -function formatDate(d: Date | string) { - return new Date(d).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); -} - -function formatDateRange(start: Date | string, end: Date | string) { - const s = new Date(start); - const e = new Date(end); - if (s.getMonth() === e.getMonth() && s.getFullYear() === e.getFullYear()) { - return `${s.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${e.getDate()}, ${e.getFullYear()}`; - } - if (s.getFullYear() === e.getFullYear()) { - return `${s.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${e.toLocaleDateString("en-US", { month: "short", day: "numeric" })}, ${e.getFullYear()}`; - } - return `${formatDate(s)} - ${formatDate(e)}`; -} - -function statusConfig(s: string) { - const map: Record< - string, - { - label: string; - dot: string; - text: string; - bg: string; - border: string; - glow: string; - } - > = { - open: { - label: "Registering", - dot: "bg-emerald-400", - text: "text-accent", - bg: "bg-accent/10", - border: "border-accent/30", - glow: "shadow-[0_0_15px_rgba(52,211,153,0.6)]", - }, - in_progress: { - label: "Live Now", - dot: "bg-emerald-400", - text: "text-accent", - bg: "bg-accent/10", - border: "border-accent/30", - glow: "shadow-[0_0_15px_rgba(52,211,153,0.6)]", - }, - completed: { - label: "Completed", - dot: "bg-white/40", - text: "text-[var(--text-primary)]/60", - bg: "bg-white/5", - border: "border-[var(--border-subtle)]", - glow: "", - }, - closed: { - label: "Applications Closed", - dot: "bg-amber-400", - text: "text-amber-400", - bg: "bg-amber-400/10", - border: "border-amber-400/30", - glow: "", - }, - cancelled: { - label: "Cancelled", - dot: "bg-rose-500", - text: "text-rose-500", - bg: "bg-rose-500/10", - border: "border-rose-500/30", - glow: "", - }, - }; - return ( - map[s] ?? { - label: s, - dot: "bg-gray-500", - text: "text-text-muted", - bg: "bg-gray-500/10", - border: "border-gray-500/20", - glow: "", - } - ); -} - -type TabType = "INFO" | "SCHEDULE" | "PROJECTS" | "TEAMS"; - -export default function ParticipantHackathonPage() { - const { data: session, status: authStatus } = useSession(); - const router = useRouter(); - const params = useParams(); - const searchParams = useSearchParams(); - const hackathonId = params.id as string; - - const tabParam = searchParams.get("tab") as TabType | null; - const [tab, setTab] = useState( - tabParam && ["INFO", "SCHEDULE", "PROJECTS", "TEAMS"].includes(tabParam) - ? tabParam - : "INFO", - ); - - const { - data: hackathon, - isLoading, - error, - } = trpc.hackathon.getById.useQuery({ - id: hackathonId, - }); - const { data: myRegs } = trpc.hackathon.myRegistrations.useQuery(undefined, { - enabled: !!session, - }); - - useEffect(() => { - if (authStatus === "unauthenticated") router.push("/login"); - }, [authStatus, router]); - - if (authStatus === "loading" || isLoading) - return ; - if (!session) return null; - // A hackathon can be missing (bad link, deleted event) or hidden. Without - // this branch the loading guard above never clears and the page spins - // forever with nothing to click. - if (error || !hackathon) return ; - - const myReg = myRegs?.find((r) => r.hackathonId === hackathon.id); - const isRegistered = !!myReg; - const conf = statusConfig(hackathon.status); - const myTeamId = myReg?.team?.id ?? null; - - return ( -

- {/* Ambient Background Glows */} -
-
- -
- -
- - - -
- All Events - - - {/* Header Card */} - - {/* Header Background Gradient Overlay */} -
- -
- {/* Status Badge */} -
-
- - {conf.label} - -
- - {/* Registration Indicator */} - {isRegistered && ( -
- - - - - Registered - -
- )} - - {/* Theme */} - {hackathon.theme && ( - - {hackathon.theme} - - )} -
- -

- {hackathon.name} -

- -
-
-
- - - -
- - {formatDateRange(hackathon.startDate, hackathon.endDate)} - -
- - {hackathon.location && ( -
-
- - - -
- {hackathon.location} -
- )} - - {hackathon.maxParticipants && ( -
-
- - - -
- - {hackathon.currentParticipants} / {hackathon.maxParticipants}{" "} - Spots - -
- )} -
- - - {/* Tabs - Only for participants */} -
- {(["INFO", "SCHEDULE", "PROJECTS", "TEAMS"] as const).map((t) => ( - - ))} -
- - {/* Content - Participant-only */} -
- {tab === "INFO" ? ( - - ) : tab === "SCHEDULE" ? ( - - ) : tab === "PROJECTS" ? ( - - ) : ( - - )} -
-
-
- ); -} diff --git a/sites/mainweb/app/(portal)/judge/page.tsx b/sites/mainweb/app/(portal)/judge/page.tsx index 0f8690bb..e1ca8149 100644 --- a/sites/mainweb/app/(portal)/judge/page.tsx +++ b/sites/mainweb/app/(portal)/judge/page.tsx @@ -231,24 +231,6 @@ export default function JudgePage() { > Ready to Judge - - - - - ) : h.status === "open" || h.status === "in_progress" ? ( )} - - - - -
diff --git a/sites/mainweb/app/(portal)/login/page.tsx b/sites/mainweb/app/(portal)/login/page.tsx index 5f924a44..96e8d571 100644 --- a/sites/mainweb/app/(portal)/login/page.tsx +++ b/sites/mainweb/app/(portal)/login/page.tsx @@ -4,21 +4,7 @@ import React, { useState, useEffect } from "react"; import { useSession, signIn } from "next-auth/react"; import { useRouter, useSearchParams } from "next/navigation"; import { usePortalContext } from "@/lib/use-portal-context"; - -/** - * Where to send somebody after they sign in, when they arrived from a page that - * asked them to. - * - * Only a same-origin path is ever honoured. A bare `startsWith("/")` is not - * enough: `//evil.example` and `/\evil.example` are both protocol-relative and - * would hand an attacker a redirect off this origin carrying whatever the - * browser sends next. - */ -function safeCallback(raw: string | null): string | null { - if (!raw || !raw.startsWith("/")) return null; - if (raw.startsWith("//") || raw.startsWith("/\\")) return null; - return raw; -} +import { safeCallback } from "@/lib/safe-callback"; // DSGT Query - Premium Landing Page // Ultra-modern, standout UI/UX @@ -87,7 +73,13 @@ export default function Home() { } setEmailSent(true); - router.push(`/verify?email=${encodeURIComponent(email)}`); + // The destination has to ride along to /verify. The code flow finishes on + // that screen, not through NextAuth's own redirect, so dropping it here + // is what sent everybody to /dashboard no matter where they came from. + const next = callbackUrl + ? `&callbackUrl=${encodeURIComponent(callbackUrl)}` + : ""; + router.push(`/verify?email=${encodeURIComponent(email)}${next}`); } catch { setEmailSending(false); setEmailError("We could not send that link. Please try again."); diff --git a/sites/mainweb/app/(portal)/submit/page.tsx b/sites/mainweb/app/(portal)/submit/page.tsx index 0a7170b1..a4b88cd2 100644 --- a/sites/mainweb/app/(portal)/submit/page.tsx +++ b/sites/mainweb/app/(portal)/submit/page.tsx @@ -28,6 +28,11 @@ function SubmitPortalContent() { const [githubUrl, setGithubUrl] = useState(""); const [videoUrl, setVideoUrl] = useState(""); const [demoUrl, setDemoUrl] = useState(""); + // Judge routing filters on exactly these three. A submission without them + // reaches no track judge and no sponsor judge at all. + const [tracks, setTracks] = useState([]); + const [challenges, setChallenges] = useState([]); + const [isCreateX, setIsCreateX] = useState(false); const [error, setError] = useState(""); const [successMessage, setSuccessMessage] = useState(""); @@ -49,6 +54,28 @@ function SubmitPortalContent() { { enabled: !!session && !!selectedHackathonId }, ); + // The event's own track and challenge lists. Offering free text instead + // would be worse than offering nothing: judge assignment matches these + // strings exactly, so a typo silently removes a project from a judge's pool. + const { data: hackathonDetail } = trpc.hackathon.getById.useQuery( + { id: selectedHackathonId }, + { enabled: !!session && !!selectedHackathonId }, + ); + + const availableTracks = hackathonDetail?.tracks ?? []; + const availableChallenges = hackathonDetail?.challenges ?? []; + + const toggle = ( + value: string, + current: string[], + set: (next: string[]) => void, + ) => + set( + current.includes(value) + ? current.filter((v) => v !== value) + : [...current, value], + ); + // We get the specific registration / team context based on selected hackathon const currentReg = myRegs?.find((r) => r.hackathonId === selectedHackathonId); const hasSubmitted = @@ -147,6 +174,9 @@ function SubmitPortalContent() { setGithubUrl(p?.githubUrl ?? ""); setVideoUrl(p?.videoUrl ?? ""); setDemoUrl(p?.demoUrl ?? ""); + setTracks(p?.tracks ?? []); + setChallenges(p?.challenges ?? []); + setIsCreateX(p?.isCreateX ?? false); setPrefilledFor(selectedHackathonId); }, [ selectedHackathonId, @@ -455,6 +485,9 @@ function SubmitPortalContent() { githubUrl, demoUrl, videoUrl, + tracks, + challenges, + isCreateX, }); }} className="space-y-8 relative z-10" @@ -490,6 +523,88 @@ function SubmitPortalContent() {
+ {/* Tracks, challenges and CreateX — what judge assignment + routes on. Rendered only when the organisers configured + them, so an event without tracks shows nothing rather + than an empty box. */} + {(availableTracks.length > 0 || + availableChallenges.length > 0) && ( +
+

+ + Tracks & Challenges +

+

+ This decides which judges see your project. Pick + everything you are competing for. +

+ + {availableTracks.length > 0 && ( +
+

+ Tracks +

+
+ {availableTracks.map((track) => ( + + ))} +
+
+ )} + + {availableChallenges.length > 0 && ( +
+

+ Sponsor Challenges +

+
+ {availableChallenges.map((challenge) => ( + + ))} +
+
+ )} + + +
+ )} + {/* Links */}

diff --git a/sites/mainweb/app/(portal)/verify/page.tsx b/sites/mainweb/app/(portal)/verify/page.tsx index 038aaad1..e0d5738b 100644 --- a/sites/mainweb/app/(portal)/verify/page.tsx +++ b/sites/mainweb/app/(portal)/verify/page.tsx @@ -3,6 +3,7 @@ import React, { Suspense, useState, useRef, useEffect } from "react"; import { useSearchParams } from "next/navigation"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; +import { safeCallback } from "@/lib/safe-callback"; function VerifyContent() { const searchParams = useSearchParams(); @@ -12,6 +13,7 @@ function VerifyContent() { const inputRefs = useRef<(HTMLInputElement | null)[]>([]); const email = searchParams?.get("email") || ""; + const callbackUrl = safeCallback(searchParams?.get("callbackUrl")); // Auto-focus first input on mount useEffect(() => { @@ -90,8 +92,14 @@ function VerifyContent() { const data = await res.json(); if (data.success) { - // Redirect — session cookie is set by the API - window.location.href = data.redirectUrl || "/dashboard"; + // Redirect — session cookie is set by the API. + // + // The caller's destination wins. `data.redirectUrl` is hardcoded to + // /dashboard by the route, so the `||` below can never fall through to + // anything else — reading the query param first is what actually + // returns somebody to the page that sent them here. + window.location.href = + callbackUrl || data.redirectUrl || "/dashboard"; } else { setError(data.error || "Invalid code. Please try again."); setVerifying(false); diff --git a/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx new file mode 100644 index 00000000..7c436ae5 --- /dev/null +++ b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx @@ -0,0 +1,284 @@ +"use client"; + +import React, { useState } from "react"; +import { trpc } from "@/lib/trpc"; +import { LiquidGlass } from "@/components/portal/LiquidGlass"; +import { Megaphone } from "lucide-react"; + +const AUDIENCES = [ + { + id: "interested" as const, + label: "Interest list", + hint: "Signed up to hear when this edition opens", + }, + { + id: "registered" as const, + label: "All registered", + hint: "Pending, approved and checked in", + }, + { + id: "approved" as const, + label: "Accepted only", + hint: "Approved but not yet arrived", + }, + { + id: "checked_in" as const, + label: "On site", + hint: "Checked in at the door", + }, +]; + +type Audience = (typeof AUDIENCES)[number]["id"]; + +export function AnnouncementsTab({ hackathonId }: { hackathonId: string }) { + const [audience, setAudience] = useState("interested"); + const [subject, setSubject] = useState(""); + const [heading, setHeading] = useState(""); + const [body, setBody] = useState(""); + const [ctaLabel, setCtaLabel] = useState(""); + const [ctaUrl, setCtaUrl] = useState(""); + + const [sending, setSending] = useState(false); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + + const { data: counts } = trpc.hackathon.audienceCounts.useQuery({ + hackathonId, + }); + + const sendAnnouncement = trpc.hackathon.sendAnnouncement.useMutation(); + + const recipientCount = counts?.[audience] ?? 0; + const canSend = + subject.trim().length > 0 && + heading.trim().length > 0 && + body.trim().length > 0 && + recipientCount > 0 && + !sending; + + /** + * Walks the audience in server-sized batches until it reports done. + * + * Sequential rather than concurrent: this is one provider account being + * asked for thousands of sends, and firing batches in parallel is how a + * announcement gets throttled into a partial delivery nobody notices. + */ + const handleSend = async () => { + if ( + !window.confirm( + `Send "${subject}" to ${recipientCount} recipient(s)?\n\nThis cannot be unsent.`, + ) + ) + return; + + setSending(true); + setError(null); + let sent = 0; + let failed = 0; + let offset = 0; + + // Bounded rather than `while (true)`: a server that stopped advancing + // nextOffset would otherwise loop forever, mailing the same batch. + for (let guard = 0; guard < 100; guard++) { + try { + const result = await sendAnnouncement.mutateAsync({ + hackathonId, + audience, + subject: subject.trim(), + heading: heading.trim(), + body: body.trim(), + ctaLabel: ctaLabel.trim() || undefined, + ctaUrl: ctaUrl.trim() || undefined, + offset, + }); + + sent += result.sent; + failed += result.failed.length; + setProgress(`Sent ${sent} of ${result.totalRecipients}...`); + + if (result.done || result.nextOffset === offset) break; + offset = result.nextOffset; + } catch (e) { + setError(e instanceof Error ? e.message : "Announcement failed"); + break; + } + } + + setProgress( + `Done. ${sent} delivered${failed > 0 ? `, ${failed} failed` : ""}.`, + ); + setSending(false); + }; + + return ( +
+ +

+ + Send an Announcement +

+

+ Plain text only. Written exactly as typed — no HTML. +

+ +
+ + Audience + +
+ {AUDIENCES.map((option) => ( + + ))} +
+
+ +
+
+ + setSubject(e.target.value)} + maxLength={200} + placeholder="Registration for Hacklytics is now open" + className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono placeholder:text-gray-600 focus:border-accent/50 focus:outline-none transition-colors" + /> +
+ +
+ + setHeading(e.target.value)} + maxLength={200} + placeholder="Registration is open" + className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono placeholder:text-gray-600 focus:border-accent/50 focus:outline-none transition-colors" + /> +
+ +
+ +