diff --git a/.github/workflows/weekly-audit-log-cleanup.yml b/.github/workflows/weekly-audit-log-cleanup.yml deleted file mode 100644 index 029deba2..00000000 --- a/.github/workflows/weekly-audit-log-cleanup.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: Weekly Audit Log Cleanup - -on: - schedule: - - cron: "0 3 * * 1" - workflow_dispatch: - -jobs: - cleanup: - runs-on: ubuntu-latest - steps: - - name: Clear audit_logs - run: | - curl -sf -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \ - https://datasciencegt.org/api/cron/cleanup-audit-logs diff --git a/.gitignore b/.gitignore index 8754c83a..bcfb8f1e 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,4 @@ graphify-out/cost.json # `*.tsbuildinfo` above does not match these, so they were tracked and every # build dirtied the working tree. .cache/ +bash.exe.stackdump 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..1e22f039 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", @@ -333,7 +402,41 @@ describe("Hackathon admin management edge cases", () => { // BUG: content.projects is a publicProcedure with no status filter, unlike // its sibling getPublicProjects which exists precisely to hide drafts. + /** + * getById enforced the draft rule on the hackathon row, but its public + * children each queried by hackathonId with no such check — so anyone + * holding the uuid could read an unannounced edition's full schedule, + * gallery and results. NOT_FOUND rather than FORBIDDEN, because + * confirming a hidden edition exists is most of the leak. + */ + it("hides a draft edition's schedule, gallery and results from the public", async () => { + mockFindFirst.mockImplementation((table: string) => + table === "hackathons" ? { id: HACK_A, status: "draft" } : undefined, + ); + mockFindMany.mockReturnValue([]); + + const anon = publicCaller(); + + await expect( + anon.hackathon.getEvents({ hackathonId: HACK_A }), + ).rejects.toThrow(/not found/i); + await expect( + anon.hackathon.projects({ hackathonId: HACK_A }), + ).rejects.toThrow(/not found/i); + await expect( + anon.hackathon.getPublicProjects({ hackathonId: HACK_A }), + ).rejects.toThrow(/not found/i); + await expect( + anon.hackathon.getResults({ hackathonId: HACK_A }), + ).rejects.toThrow(/not found/i); + }); + it("hides in-progress project drafts and their scores from rivals", async () => { + // The gallery now refuses to serve a hackathon the caller cannot see, so + // a visible one has to exist before the project filter is reached. + mockFindFirst.mockImplementation((table: string) => + table === "hackathons" ? { id: HACK_A, status: "open" } : undefined, + ); mockFindMany.mockReturnValue([ { id: PROJECT, @@ -386,10 +489,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 +507,85 @@ describe("Hackathon admin management edge cases", () => { participantIds: [PART_A1, PART_A2, PART_B1], }); - expect(res.count).toBe(2); + expect(res.approved).toBe(2); + }); + + /** + * The recovery case. A mass send that died partway leaves everyone before + * the failure point already emailed; re-running is the obvious next move, + * and without reading the marker it congratulates them all again. An + * acceptance email cannot be unsent. + */ + it("does not email anyone who already received their acceptance", async () => { + const caller = adminCaller({ hackathons: { name: "Hacklytics 2027" } }); + mockFindMany.mockReturnValue([ + { + id: PART_A1, + hackathonId: HACK_A, + acceptanceEmailSentAt: new Date("2026-08-01"), + user: { email: "ada@example.com" }, + }, + { + id: PART_A2, + hackathonId: HACK_A, + acceptanceEmailSentAt: null, + user: { email: "alan@example.com" }, + }, + ]); + + const res = await caller.hackathon.sendMassAcceptanceEmails({ + hackathonId: HACK_A, + participantIds: [PART_A1, PART_A2], + }); + + const mailed = mockSendAcceptanceEmail.mock.calls.map((c) => c[0].email); + expect(mailed).toEqual(["alan@example.com"]); + expect(res).toMatchObject({ emailed: 1, alreadyEmailed: 1 }); + // Both are still approved — only the mail is skipped. + expect(res.approved).toBe(2); + }); + + // Deliberately resending is still possible; it just is not the default. + it("re-emails everyone when resend is asked for", async () => { + const caller = adminCaller({ hackathons: { name: "Hacklytics 2027" } }); + mockFindMany.mockReturnValue([ + { + id: PART_A1, + hackathonId: HACK_A, + acceptanceEmailSentAt: new Date("2026-08-01"), + user: { email: "ada@example.com" }, + }, + ]); + + const res = await caller.hackathon.sendMassAcceptanceEmails({ + hackathonId: HACK_A, + participantIds: [PART_A1], + resend: true, + }); + + expect(res.emailed).toBe(1); + }); + + // 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,18 +725,101 @@ 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 () => { - const caller = adminCaller({ hackathons: undefined }); + // super_admin: deleting an edition is deliberately the narrowest gate + // in the product. + const caller = adminCaller({ hackathons: undefined }, "super_admin"); // RETURNING names the rows the statement itself removed; against an id // that matches nothing that is the empty set. mockDelete.mockReturnValue([]); await expect( - caller.hackathon.delete({ hackathonId: HACK_B }), + caller.hackathon.delete({ + hackathonId: HACK_B, + confirmName: "Hacklytics 2027", + }), ).rejects.toThrow(/not found/i); }); + + /** + * The audit trail must never be the reason an organiser's action fails. + * A delete that succeeded and went unrecorded is bad; a delete refused + * because the logging table was busy is worse, and from the outside it is + * indistinguishable from the guard doing its job. + */ + it("still deletes when the audit write fails", async () => { + const caller = adminCaller( + { hackathons: { id: HACK_A, name: "Hacklytics 2027" } }, + "super_admin", + ); + mockDelete.mockReturnValue([{ id: HACK_A }]); + mockInsert.mockImplementation(() => { + throw new Error("audit_logs unavailable"); + }); + + await expect( + caller.hackathon.delete({ + hackathonId: HACK_A, + confirmName: "Hacklytics 2027", + }), + ).resolves.toMatchObject({ success: true }); + }); + + // 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" } }, + "super_admin", + ); + + 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..5da3cbaf 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", @@ -630,9 +628,6 @@ describe("Participant edge cases", () => { return callerFor("user_a"); }; - // BUG: createTeam/joinTeam/submitProject only assert that a participant row - // exists (team.ts:98, 170, 445) — registrationStatus is never inspected, - // unlike hackathon.scanParticipantPass. it.each(["rejected", "waitlisted"])( "keeps a %s applicant out of teams and out of judging", async (status) => { @@ -953,30 +948,26 @@ describe("Participant edge cases", () => { expect(res.daysRemaining).toBeNull(); }); - // BUG: getHackathonId (member.ts:20-27) resolves the default hackathon by - // `orderBy desc(startDate)` with no status or date filter, so a future draft - // hijacks every member lookup the moment staff create next year's event. - it("resolves the hackathon in progress, not next year's draft", async () => { - const catalogue = [ - { id: HACK_A, status: "open", startDate: new Date(Date.now() - DAY) }, - { - id: HACK_NEXT, - status: "draft", - startDate: new Date(Date.now() + 300 * DAY), - }, - ]; - mockFindFirst.mockImplementation((table, args) => { + /** + * A membership used to be keyed on (userId, hackathonId), so the day a new + * edition opened every read resolved to it, matched no row, and every + * paying member silently became a non-member. Membership status must not + * consult the hackathon table at all now. + */ + it("reports a member as a member even with a newer edition open", async () => { + const hackathonReads: unknown[] = []; + mockFindFirst.mockImplementation((table) => { if (table === "hackathons") { - if (args?.orderBy) - return [...catalogue].sort( - (a, b) => b.startDate.getTime() - a.startDate.getTime(), - )[0]; - return catalogue[0]; + hackathonReads.push(table); + return { + id: HACK_NEXT, + status: "open", + startDate: new Date(Date.now() + 300 * DAY), + }; } if (table === "members") return { id: "member_1", - hackathonId: HACK_A, isActive: true, memberType: "continuous", renewalCount: 1, @@ -988,8 +979,10 @@ describe("Participant edge cases", () => { const res = await callerFor("user_a").member.checkStatus(); expect(res.isMember).toBe(true); - // The cache key records which hackathon the lookup actually targeted. - expect(cache.get(`member:status:user_a:${HACK_A}`)).not.toBeNull(); + expect(hackathonReads).toHaveLength(0); + // The cache key is keyed on the person alone — nothing evicts an + // edition-scoped key, which is how a stale "not a member" survived. + expect(cache.get(`member:status:user_a`)).not.toBeNull(); }); }); @@ -1165,4 +1158,5 @@ describe("Participant edge cases", () => { await expect(caller.user.updateProfile({})).rejects.toThrow(); }); }); + }); diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts index fd6a2f2d..3c07a890 100644 --- a/packages/api/src/.internal-tests/qr-checkin.test.ts +++ b/packages/api/src/.internal-tests/qr-checkin.test.ts @@ -732,6 +732,11 @@ describe("QR check-in", () => { mockFindMany.mockImplementation((table: string) => table === "hackathonProjects" ? [{ ...project }] : [], ); + // The gallery refuses to serve a hackathon the caller cannot see, so a + // visible one has to exist before the column scrubbing is reached. + mockFindFirst.mockImplementation((table: string) => + table === "hackathons" ? { id: HACK_A, status: "open" } : undefined, + ); const anon = appRouter.createCaller(createMockCtx()); const listed: any[] = await anon.hackathon.projects({ @@ -894,5 +899,6 @@ describe("QR check-in", () => { expect(cache.deletePattern("events:list*")).toBe(2); expect(cache.has("events:list:public")).toBe(false); }); + }); }); diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts index 802c8384..5af01b77 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", @@ -652,8 +644,9 @@ describe("Router Integration and Access Control Verification Suite", () => { }); it("should ensure backslash escapes in sql queries are checked securely", () => { - // Drizzle handles parameterization automatically, so raw inputs are never interpolated directly. - // We test that inputs containing backslashes are sanitized/passed as single literals. + // Drizzle handles parameterization automatically, so raw inputs are never + // interpolated directly. We test that inputs containing backslashes are + // sanitized/passed as single literals. const dangerousValue = "value\\' OR \\'1\\'=\\'1"; const cleanValue = sanitizeInput(dangerousValue); expect(typeof cleanValue).toBe("string"); @@ -1085,17 +1078,31 @@ describe("Router Integration and Access Control Verification Suite", () => { expect(updated.status).toBe("open"); }); - it("should allow admin to delete a hackathon", async () => { + it("should allow a super admin to delete a hackathon", async () => { const ctx = createMockCtx("admin_user_id"); mockFindFirst.mockImplementation((table) => { if (table === "admins") { - return { id: "admin_1", userId: "admin_user_id", role: "admin", isActive: true }; + // Deleting an edition is super-admin only: isAdmin never checked + // role, so the default "admin" could destroy every participant, + // team, project and vote attached to it. + return { + id: "admin_1", + userId: "admin_user_id", + role: "super_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(); }); @@ -1291,7 +1298,7 @@ describe("Router Integration and Access Control Verification Suite", () => { describe("11. Member Registration, Renewal, and Status Tracking", () => { const hackathonId = "00000000-0000-0000-0000-000000000040"; - it("should register a user as a member for a hackathon", async () => { + it("should register a user as a member", async () => { const ctx = createMockCtx("user_member_1"); mockFindFirst.mockImplementation((table) => { @@ -1318,7 +1325,6 @@ describe("Router Integration and Access Control Verification Suite", () => { const caller = appRouter.createCaller(ctx); const member = await caller.member.register({ - hackathonId, firstName: "John", lastName: "Doe", phoneNumber: "+14045550123", @@ -1332,7 +1338,7 @@ describe("Router Integration and Access Control Verification Suite", () => { expect(mockInsert).toHaveBeenCalledTimes(1); }); - it("should reject duplicate member registration for the same hackathon", async () => { + it("should reject duplicate member registration", async () => { const ctx = createMockCtx("user_member_1"); mockFindFirst.mockImplementation((table) => { @@ -1344,11 +1350,10 @@ describe("Router Integration and Access Control Verification Suite", () => { const caller = appRouter.createCaller(ctx); await expect( caller.member.register({ - hackathonId, firstName: "John", lastName: "Doe", }), - ).rejects.toThrowError("You are already a member for this hackathon"); + ).rejects.toThrowError("You already have a member profile"); }); it("should return correct membership status and days remaining", async () => { @@ -1372,7 +1377,7 @@ describe("Router Integration and Access Control Verification Suite", () => { }); const caller = appRouter.createCaller(ctx); - const status = await caller.member.checkStatus({ hackathonId }); + const status = await caller.member.checkStatus(); expect(status.isMember).toBe(true); expect(status.isActive).toBe(true); diff --git a/packages/api/src/.internal-tests/stripe-payments.test.ts b/packages/api/src/.internal-tests/stripe-payments.test.ts index 395d5cfb..b18c26bb 100644 --- a/packages/api/src/.internal-tests/stripe-payments.test.ts +++ b/packages/api/src/.internal-tests/stripe-payments.test.ts @@ -20,6 +20,37 @@ import { MEMBERSHIP_CENTS, BOOTCAMP_ADDON_CENTS } from "../services/pricing"; const mockFindFirst = vi.fn(); const mockInsert = vi.fn(); +/** + * The Stripe SDK is stubbed so no test reaches the network. + * + * Without this, "refuses a mock intent id when not in mock mode" set a fake + * secret key and then genuinely called api.stripe.com — the request spent ~23 + * seconds on SDK retries and failed the whole suite whenever the machine was + * offline or slow, for reasons that had nothing to do with the assertion. + */ +/** Payment intents `reconcileMyPayments` should find. Set per test. */ +const mockSearchResults = vi.fn<() => unknown[]>(() => []); + +vi.mock("stripe", () => ({ + default: class { + paymentIntents = { + search: vi.fn(async () => ({ data: mockSearchResults() })), + retrieve: vi.fn(async (id: string) => { + throw new Error(`No such payment_intent: ${id}`); + }), + create: vi.fn(async () => ({ + id: "pi_stub", + client_secret: "pi_stub_secret", + })), + }; + checkout = { + sessions: { + create: vi.fn(async () => ({ id: "cs_stub", url: "https://stub" })), + }, + }; + }, +})); + vi.mock("@query/db", () => { const table = (name: string) => ({ findFirst: (...args: any[]) => mockFindFirst(name, ...args), @@ -34,6 +65,7 @@ vi.mock("@query/db", () => { members: table("members"), hackathons: table("hackathons"), stripePayments: table("stripePayments"), + membershipHistory: table("membershipHistory"), userAccountLinks: table("userAccountLinks"), admins: table("admins"), }, @@ -146,11 +178,58 @@ describe("Membership payments", () => { const result = await caller().stripe.createPaymentIntent(); - expect(result).toEqual({ + expect(result).toMatchObject({ clientSecret: "mock_pi_secret", publishableKey: "pk_test_local", isMock: true, }); + // A unique id per call, so two developers (or two runs) do not collide + // on confirmMembershipAfterPayment's idempotency check. + expect(result.mockPaymentIntentId).toMatch(/^pi_mock_[0-9a-f]{32}$/); + }); + + /** + * The whole point of mock mode. It previously returned a fake secret and + * wrote nothing, while the modal called onSuccess() directly — so the UI + * said "Access Granted" with no payment row and no member row anywhere, + * and the club half could not be developed locally at all. + * + * Asserting the returned shape (as the test above does) proves nothing + * about what was written, which is exactly how this survived the suite. + */ + it("grants a real membership through the production confirm path", async () => { + process.env.STRIPE_MOCK_MODE = "true"; + + const { mockPaymentIntentId } = await caller().stripe.createPaymentIntent(); + + await caller().stripe.confirmMembershipAfterPayment({ + paymentIntentId: mockPaymentIntentId!, + }); + + // This file mocks insert as mockInsert(valArgs), so the row is c[0][0]. + const written = mockInsert.mock.calls.map((c) => c[0]?.[0]); + // A payment row, recorded under the same synthetic session id the + // webhook uses so the two settle each other's race. + expect( + written.some((row) => row?.stripeSessionId === `pi_${mockPaymentIntentId}`), + ).toBe(true); + // And the membership itself. + expect(written.some((row) => row?.userId === USER && row?.firstName)).toBe( + true, + ); + }); + + // isMockMode() is false whenever NODE_ENV=production regardless of the + // flag, so the live site cannot be talked into minting free memberships. + it("refuses a mock intent id when not in mock mode", async () => { + delete process.env.STRIPE_MOCK_MODE; + process.env.STRIPE_SECRET_KEY = "sk_test_abc"; + + await expect( + caller().stripe.confirmMembershipAfterPayment({ + paymentIntentId: "pi_mock_deadbeefdeadbeefdeadbeefdeadbeef", + }), + ).rejects.toThrow(); }); it("falls back to a placeholder publishable key when none is set", async () => { @@ -290,4 +369,73 @@ describe("Membership payments", () => { expect(insertedAmount()).toBe(MEMBERSHIP_CENTS + BOOTCAMP_ADDON_CENTS); }); }); + + /** + * Reported by review on #316, and correct. + * + * The webhook records the payment first and grants the membership after, so + * a grant that throws leaves a payment row linked to the user with no + * membership behind it. Every recovery path skipped already-linked payments, + * which made that state permanent: charged customer, payment on file, + * nothing ever retrying. + */ + describe("recovering a payment whose membership grant failed", () => { + const PAID_AT = new Date("2026-03-01T12:00:00Z"); + + const paidIntent = { + id: "pi_stranded", + amount: MEMBERSHIP_CENTS, + currency: "usd", + status: "succeeded", + metadata: { type: "membership", userId: USER }, + }; + + const wire = (opts: { history?: unknown }) => { + process.env.STRIPE_SECRET_KEY = "sk_test_abc"; + mockSearchResults.mockReturnValue([paidIntent]); + mockFindFirst.mockImplementation((table: string) => { + if (table === "users") + return { id: USER, email: "member@gatech.edu", name: "Buzz Member" }; + if (table === "stripePayments") + return { + id: "pay_1", + stripePaymentIntentId: paidIntent.id, + linkedUserId: USER, + paymentStatus: "paid", + createdAt: PAID_AT, + }; + if (table === "members") return { id: "member_1" }; + if (table === "membershipHistory") return opts.history; + return undefined; + }); + }; + + it("grants the membership when no history row covers the payment", async () => { + wire({ history: undefined }); + + const res = await caller().stripe.reconcileMyPayments(); + + expect(res.recovered).toBe(1); + // A member row already exists (the profile), so the term is written as a + // renewal — what matters is that a history row records the grant at all. + const written = mockInsert.mock.calls.map((c) => c[0]?.[0]); + expect( + written.some((row) => row?.action === "renewed" || row?.action === "joined"), + ).toBe(true); + }); + + /** + * The other half of the rule: a membership granted a year ago and since + * lapsed must NOT be silently renewed off that old payment. The history row + * is what distinguishes "never honoured" from "honoured and expired". + */ + it("leaves an already-honoured payment alone", async () => { + wire({ history: { id: "hist_1" } }); + + const res = await caller().stripe.reconcileMyPayments(); + + expect(res.recovered).toBe(0); + expect(mockInsert).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/api/src/middleware/audit.ts b/packages/api/src/middleware/audit.ts new file mode 100644 index 00000000..7cbdd8c2 --- /dev/null +++ b/packages/api/src/middleware/audit.ts @@ -0,0 +1,115 @@ +import { auditLogs } from "@query/db"; +import { and, lt, ne } from "drizzle-orm"; +import type { DrizzleDB } from "@query/db"; + +/** + * How long a security or admin event is kept. + * + * Retention used to run from a GitHub Actions cron hitting a public route with + * a bearer secret. That is three moving parts — a schedule, a shared secret, + * and an internet-reachable endpoint whose only protection is that secret — + * for a job whose entire content is two DELETEs. If the workflow was disabled, + * the repo was renamed, or the secret rotated, retention stopped silently and + * nothing anywhere reported it. + * + * Retention is now tied to writes instead. Audit rows only accumulate when + * something writes them, so pruning on write is self-regulating: a busy period + * prunes often, an idle one has nothing to prune. No scheduler, no endpoint, + * no secret. + */ +const RETAIN_DAYS = 90; +/** Critical events outlive the routine window; they are the ones worth keeping. */ +const RETAIN_CRITICAL_DAYS = 365; + +/** At most one prune per process per interval, however many rows are written. */ +const PRUNE_INTERVAL_MS = 60 * 60 * 1000; + +let lastPruneAt = 0; +let pruneInFlight = false; + +const cutoff = (days: number) => + new Date(Date.now() - days * 24 * 60 * 60 * 1000); + +/** + * Deletes expired audit rows, at most hourly per process. + * + * Deliberately not awaited by callers and deliberately silent on failure: + * retention is housekeeping, and a full audit table is a much smaller problem + * than an admin action that fails because housekeeping did. + */ +export const maybePruneAuditLogs = (db: DrizzleDB) => { + const now = Date.now(); + if (pruneInFlight || now - lastPruneAt < PRUNE_INTERVAL_MS) return; + + // Stamped before the await, so concurrent requests in the same process do + // not all decide to prune at once. + lastPruneAt = now; + pruneInFlight = true; + + void (async () => { + try { + // Both bound on created_at, which audit_created_at_idx covers. + await db + .delete(auditLogs) + .where( + and( + lt(auditLogs.createdAt, cutoff(RETAIN_DAYS)), + ne(auditLogs.severity, "critical"), + ), + ); + + await db + .delete(auditLogs) + .where(lt(auditLogs.createdAt, cutoff(RETAIN_CRITICAL_DAYS))); + } catch (error) { + // eslint-disable-next-line no-console + console.error("[Audit] Retention prune failed:", error); + } finally { + pruneInFlight = false; + } + })(); +}; + +/** + * Records an administrative action. + * + * `audit_logs` already had a table, an admin reader and a severity enum, but + * its only writer was the security middleware's four rate-limit event types — + * so every guard on the destructive paths was the last line of defence with + * nothing behind it. When somebody forces past a confirmation at 2am, this is + * the only thing that can say who, what and when afterwards. + * + * Deliberately fire-and-forget: an audit write must never be the reason an + * organiser's action fails. A delete that succeeded and went unrecorded is bad; + * a delete that was refused because the logging table was busy is worse, and + * would be indistinguishable from the guard doing its job. + */ +export const recordAdminAction = async ( + db: DrizzleDB, + entry: { + userId: string | null | undefined; + action: string; + resourceId?: string | null; + /** `critical` for anything irreversible or forced past a refusal. */ + severity?: "info" | "warn" | "critical"; + metadata?: Record; + }, +) => { + try { + await db.insert(auditLogs).values({ + userId: entry.userId ?? null, + action: entry.action, + resourceId: entry.resourceId ?? null, + severity: entry.severity ?? "info", + metadata: entry.metadata ?? {}, + }); + + // Housekeeping rides along with the write that created the need for it. + maybePruneAuditLogs(db); + } catch (error) { + // Deliberate server-side logging: if the audit trail itself cannot be + // written, the console is the only remaining record that it was tried. + // eslint-disable-next-line no-console + console.error(`[Audit] Failed to record "${entry.action}":`, error); + } +}; diff --git a/packages/api/src/middleware/cache.ts b/packages/api/src/middleware/cache.ts index 8f7f6b96..74f9ffda 100644 --- a/packages/api/src/middleware/cache.ts +++ b/packages/api/src/middleware/cache.ts @@ -268,7 +268,10 @@ export const clearProjectLeaderCaches = (userId: string) => { * member being told to pay again. */ export const clearMembershipCaches = (userId: string) => { - cache.deletePattern(`${CacheKeys.member(userId)}*`); + // `member:*` is a shape nothing writes — member.me stores + // `member:me:`, so a webhook grant used to leave that entry stale and + // the member was told to pay for another minute. Evict what is written. + cache.deletePattern(`member:me:${userId}*`); cache.deletePattern(`member:status:${userId}*`); invalidatePortalContext(userId); }; 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/events.ts b/packages/api/src/routers/events.ts index 65544c3b..aa633032 100644 --- a/packages/api/src/routers/events.ts +++ b/packages/api/src/routers/events.ts @@ -5,8 +5,6 @@ import { events, eventCheckIns, members } from "@query/db"; import { eq, and, lt, sql } from "drizzle-orm"; import { randomUUID } from "crypto"; import { isAdmin } from "../middleware/procedures"; -import { resolveHackathonId } from "../services/portal-context"; -import type { DrizzleDB } from "@query/db"; /** * Postgres unique_violation. Drizzle wraps every driver error in a @@ -52,6 +50,64 @@ export const eventRouter = createTRPCRouter({ return newEvent; }), + /** + * Corrects a club event in place. + * + * Without this the only way to fix a typo in a title was to delete the event + * and make a new one — which destroys every check-in already collected + * against it, and mints a new QR code that the printed one no longer matches. + */ + update: isAdmin + .input( + z.object({ + eventId: z.string().uuid(), + title: z.string().min(1).max(200).optional(), + description: z.string().max(1000).nullable().optional(), + location: z.string().max(200).nullable().optional(), + eventDate: z.date().optional(), + /** Null removes the cap. */ + maxCheckIns: z.number().int().positive().nullable().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { eventId, ...fields } = input; + + const existing = await ( + ctx.db as NonNullable + ).query.events.findFirst({ + where: eq(events.id, eventId), + columns: { currentCheckIns: true }, + }); + + if (!existing) { + throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" }); + } + + // A cap below the number of people already scanned would make the counter + // read as over-full forever and refuse everyone at the door, with nothing + // saying why. + if ( + typeof fields.maxCheckIns === "number" && + fields.maxCheckIns < existing.currentCheckIns + ) { + throw new TRPCError({ + code: "CONFLICT", + message: `${existing.currentCheckIns} people have already checked in, so the cap cannot be lower than that.`, + }); + } + + const [updated] = await (ctx.db as NonNullable) + .update(events) + .set({ ...fields, updatedAt: new Date() }) + .where(eq(events.id, eventId)) + .returning(); + + ctx.cache.deletePattern(`event:${eventId}`); + ctx.cache.deletePattern("event*"); + + return updated; + }), + regenerateQR: isAdmin .input(z.object({ eventId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { @@ -268,21 +324,14 @@ export const eventRouter = createTRPCRouter({ .where(eq(events.id, event.id)) .for("update"); - // One membership row per edition, so an unscoped lookup can pick a - // lapsed earlier year. - const hackathonId = await resolveHackathonId( - tx as unknown as DrizzleDB, - ); - const [member, existingCheckIn] = await Promise.all([ - hackathonId - ? tx.query.members.findFirst({ - where: and( - eq(members.userId, ctx.userId as string), - eq(members.hackathonId, hackathonId), - ), - }) - : undefined, + // Club check-in no longer depends on a hackathon edition existing. + // It used to skip this lookup entirely when none resolved, and + // then refuse everyone at the door with "Must be a member" — at a + // club event that has nothing to do with any hackathon. + tx.query.members.findFirst({ + where: eq(members.userId, ctx.userId as string), + }), tx.query.eventCheckIns.findFirst({ where: and( eq(eventCheckIns.eventId, event.id), diff --git a/packages/api/src/routers/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts index b5b917f0..c3dbec6c 100644 --- a/packages/api/src/routers/hackathon/admin.ts +++ b/packages/api/src/routers/hackathon/admin.ts @@ -1,12 +1,15 @@ 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 { recordAdminAction } from "../../middleware/audit"; 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 +44,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)); } - return false; + + 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 and(...clauses); }; export const hackathonAdminRouter = createTRPCRouter({ @@ -61,33 +123,109 @@ 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, + }; + }), + + /** + * Just the ids matching the current filter. + * + * Exists so "select all" can mean every matching applicant rather than the + * fifty on screen. Pagination made the header checkbox select one page, and + * a bulk approve that silently covers 50 of 2000 while reporting success is + * worse than one that fails outright — the organiser moves on believing the + * queue is cleared. + * + * Ids rather than rows: 2000 uuids is a small payload, and keeping the + * mutation id-based means the set is fixed at the moment the organiser + * chose it, instead of re-evaluating a filter that may have moved. + */ + adminGetAttendeeIds: 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 }) => { + const rows = await (ctx.db as DrizzleDB) + .select({ id: hackathonParticipants.id }) + .from(hackathonParticipants) + .where(buildAttendeeWhere(input)) + // Matches the batch mutation's own cap, so a selection can always be + // acted on in a single call. + .limit(2500); + + return rows.map((row) => row.id); + }), + + /** + * 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 +274,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 +284,16 @@ 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), + /** Mail people who have already had their acceptance. Off by default: + * the ordinary reason to run this twice is that the first run died + * partway, and then everyone before the failure point is already + * done. */ + resend: z.boolean().default(false), }), ) .mutation(async ({ ctx, input }) => { @@ -180,49 +327,113 @@ 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; + let alreadyEmailed = 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; + + // The marker is read here, not just written below. Re-running this + // after a batch died partway through is the normal recovery, and + // without this check everyone before the failure point is congratulated + // a second time — which cannot be taken back. + if (participant.acceptanceEmailSentAt && !input.resend) { + alreadyEmailed++; + 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*"); + // Thousands of emails that cannot be unsent, in one action. + await recordAdminAction(db, { + userId: ctx.userId, + action: "hackathon.sendMassAcceptanceEmails", + resourceId: hackathonId, + severity: "warn", + metadata: { + approved: participants.length, + emailed, + alreadyEmailed, + failed: failedEmails.length, + resend: input.resend, + }, + }); + + 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, + alreadyEmailed, + failedEmails, + skipped, + message: `Approved ${participants.length} participant(s); ${emailed} acceptance email(s) sent.${alreadyEmailed > 0 ? ` ${alreadyEmailed} had already been emailed and were left alone.` : ""}${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 +441,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 +457,254 @@ 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.", + }); + } + + // Volunteers can reach this, so it is the widest-held destructive action + // in the product — worth a record of who undid which scan. + await recordAdminAction(db, { + userId: ctx.userId, + action: "hackathon.removeEventAttendance", + resourceId: input.participantId, + severity: "warn", + metadata: { + eventId: input.eventId, + hackathonId: input.hackathonId, + }, + }); + + 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 +806,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..8b31eda8 100644 --- a/packages/api/src/routers/hackathon/content.ts +++ b/packages/api/src/routers/hackathon/content.ts @@ -1,12 +1,16 @@ import { z } from "zod"; +import { TRPCError } from "@trpc/server"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { hackathonParticipants, hackathonProjects, - hackathonTeams, + hackathonResults, + judgingProjects, } 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 { recordAdminAction } from "../../middleware/audit"; +import { assertHackathonVisible } from "./visibility"; import type { DrizzleDB } from "@query/db"; // Same visibility rule as getPublicProjects: a project only becomes public once @@ -15,40 +19,178 @@ 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)); + + // The judging entry has to go with it, or the CONFLICT message above is + // a lie: judges keep being routed to the table, the votes keep counting, + // and the project can still be computed and published as a placing. + await db + .update(judgingProjects) + .set({ withdrawnAt: new Date() }) + .where(eq(judgingProjects.sourceProjectId, input.projectId)); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "hackathon.adminWithdrawProject", + resourceId: input.projectId, + // Pulling a project judges are actively scoring changes the results. + severity: existing.status === "judging" ? "critical" : "warn", + metadata: { + hackathonId: existing.hackathonId, + previousStatus: existing.status, + forced: input.force, + }, + }); + + 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, + await assertHackathonVisible(ctx, input.hackathonId); + + 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") })) .query(async ({ ctx, input }) => { + await assertHackathonVisible(ctx, input.hackathonId); + const fetchProjects = () => (ctx.db as DrizzleDB).query.hackathonProjects.findMany({ where: eq(hackathonProjects.hackathonId, input.hackathonId), @@ -124,30 +266,58 @@ 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, + await assertHackathonVisible(ctx, input.hackathonId); + + 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..b1310d3d 100644 --- a/packages/api/src/routers/hackathon/crud.ts +++ b/packages/api/src/routers/hackathon/crud.ts @@ -3,7 +3,16 @@ import { TRPCError } from "@trpc/server"; 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 { + callerIsAdmin, + isAdmin, + isSuperAdmin, +} from "../../middleware/procedures"; +import { + isForeignKeyViolation, + isUniqueViolation, +} from "../../middleware/db-errors"; +import { recordAdminAction } from "../../middleware/audit"; import { CacheKeys, VOLATILE_TTL } from "../../middleware/cache"; import type { DrizzleDB } from "@query/db"; @@ -233,12 +242,26 @@ export const hackathonCrudRouter = createTRPCRouter({ ), ) .mutation(async ({ ctx, input }) => { - const [newHackathon] = await (ctx.db as DrizzleDB) - .insert(hackathons) - .values({ - ...input, - }) - .returning(); + let newHackathon; + try { + [newHackathon] = await (ctx.db as DrizzleDB) + .insert(hackathons) + .values({ + ...input, + }) + .returning(); + } catch (error) { + // unique_hackathon_name. Admin URLs are built from the name, so a + // duplicate would make one of the two unreachable — worth saying + // plainly rather than surfacing a driver error. + if (isUniqueViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: `A hackathon named "${input.name}" already exists. Names are used in admin links, so they have to be distinct.`, + }); + } + throw error; + } ctx.cache.deletePattern("hackathons:*"); @@ -269,6 +292,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 +305,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(), }), ) @@ -331,14 +361,25 @@ export const hackathonCrudRouter = createTRPCRouter({ }); } - const [updatedHackathon] = await (ctx.db as DrizzleDB) - .update(hackathons) - .set({ - ...updateData, - updatedAt: new Date(), - }) - .where(eq(hackathons.id, id)) - .returning(); + let updatedHackathon; + try { + [updatedHackathon] = await (ctx.db as DrizzleDB) + .update(hackathons) + .set({ + ...updateData, + updatedAt: new Date(), + }) + .where(eq(hackathons.id, id)) + .returning(); + } catch (error) { + if (isUniqueViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: `Another hackathon is already named "${updateData.name}". Names are used in admin links, so they have to be distinct.`, + }); + } + throw error; + } ctx.cache.delete(CacheKeys.hackathon(id)); ctx.cache.deletePattern("hackathons:*"); @@ -347,20 +388,71 @@ export const hackathonCrudRouter = createTRPCRouter({ }), - delete: isAdmin - .input(z.object({ hackathonId: z.string().uuid() })) + /** + * Super-admin only. + * + * isAdmin never checks `role`, so the default "admin" and "moderator" both + * passed — every staff account could destroy an edition. Verified three + * active super_admin rows exist before narrowing this, because a gate with + * nobody behind it is an outage rather than a control. + */ + delete: isSuperAdmin + .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({ @@ -369,6 +461,15 @@ export const hackathonCrudRouter = createTRPCRouter({ }); } + await recordAdminAction(ctx.db as DrizzleDB, { + userId: ctx.userId, + action: "hackathon.delete", + resourceId: hackathonId, + severity: "critical", + // The name is recorded because the row it came from no longer exists. + metadata: { name: existing.name }, + }); + ctx.cache.delete(CacheKeys.hackathon(hackathonId)); ctx.cache.deletePattern("hackathons:*"); return { success: true }; diff --git a/packages/api/src/routers/hackathon/events.ts b/packages/api/src/routers/hackathon/events.ts index 814af0c5..49699004 100644 --- a/packages/api/src/routers/hackathon/events.ts +++ b/packages/api/src/routers/hackathon/events.ts @@ -4,9 +4,12 @@ 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 { recordAdminAction } from "../../middleware/audit"; +import { assertHackathonVisible } from "./visibility"; import type { DrizzleDB } from "@query/db"; export const hackathonEventsRouter = createTRPCRouter({ @@ -53,13 +56,13 @@ export const hackathonEventsRouter = createTRPCRouter({ description: input.description, type: input.type, location: input.location, + points: input.points, startTime: input.startTime, endTime: input.endTime, - points: input.points, }) .returning(); - ctx.cache.deletePattern("hackathon*"); + ctx.cache.delete(`hackathon:${input.hackathonId}:events`); return newEvent; }), @@ -113,7 +116,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 +128,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,37 +144,91 @@ 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*"); + await recordAdminAction(db, { + userId: ctx.userId, + action: "hackathon.deleteEvent", + resourceId: input.eventId, + // Forcing past the refusal destroys check-in records with no undo. + severity: checkIns > 0 ? "critical" : "info", + metadata: { + name: existing.name, + hackathonId: existing.hackathonId, + deletedCheckIns: checkIns, + forced: input.force, + }, + }); + + ctx.cache.delete(`hackathon:${existing.hackathonId}:events`); - return { success: true }; + return { success: true, deletedCheckIns: checkIns }; }), getEvents: publicProcedure .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") })) .query(async ({ ctx, input }) => { + // A draft edition's schedule is not public just because its uuid leaked. + await assertHackathonVisible(ctx, input.hackathonId); + 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/hackathon/registration.ts b/packages/api/src/routers/hackathon/registration.ts index 28ebca48..a45f65e1 100644 --- a/packages/api/src/routers/hackathon/registration.ts +++ b/packages/api/src/routers/hackathon/registration.ts @@ -149,11 +149,11 @@ export const hackathonRegistrationRouter = createTRPCRouter({ }); } + // A membership is annual and edition-independent, so it is keyed on + // the person alone; the edition clause used to be here and made a + // paying member read as a non-member the moment a new edition opened. const member = await tx.query.members.findFirst({ - where: and( - eq(members.userId, ctx.userId as string), - eq(members.hackathonId, input.hackathonId), - ), + where: eq(members.userId, ctx.userId as string), }); /** diff --git a/packages/api/src/routers/hackathon/visibility.ts b/packages/api/src/routers/hackathon/visibility.ts new file mode 100644 index 00000000..6a174c65 --- /dev/null +++ b/packages/api/src/routers/hackathon/visibility.ts @@ -0,0 +1,44 @@ +import { TRPCError } from "@trpc/server"; +import { hackathons } from "@query/db"; +import { eq } from "drizzle-orm"; +import type { DrizzleDB } from "@query/db"; +import { callerIsAdmin } from "../../middleware/procedures"; +import type { Context } from "../../context"; + +/** + * Statuses only staff may see. A draft edition is one nobody outside the team + * is meant to know exists yet. + */ +export const STAFF_ONLY_STATUSES: (typeof hackathons.$inferSelect)["status"][] = + ["draft"]; + +/** + * Refuses to serve anything belonging to a hackathon the caller cannot see. + * + * `getById` enforced this on the hackathon row itself, but its public children + * — the schedule, the project gallery, the results — each queried by + * hackathonId with no such check. Anyone holding the uuid could read an + * unannounced edition's full timetable and submissions, which is exactly the + * shape of leak that a "draft" status exists to prevent. + * + * NOT_FOUND rather than FORBIDDEN on purpose: telling an anonymous caller that + * a hidden edition exists is most of the leak. + */ +export const assertHackathonVisible = async ( + ctx: Context, + hackathonId: string, +) => { + const row = await (ctx.db as DrizzleDB).query.hackathons.findFirst({ + where: eq(hackathons.id, hackathonId), + columns: { id: true, status: true }, + }); + + if (!row) { + throw new TRPCError({ code: "NOT_FOUND", message: "Hackathon not found" }); + } + + if (!STAFF_ONLY_STATUSES.includes(row.status)) return; + if (await callerIsAdmin(ctx)) return; + + throw new TRPCError({ code: "NOT_FOUND", message: "Hackathon not found" }); +}; diff --git a/packages/api/src/routers/initiative.ts b/packages/api/src/routers/initiative.ts index 3d88f7b4..c1fa6875 100644 --- a/packages/api/src/routers/initiative.ts +++ b/packages/api/src/routers/initiative.ts @@ -12,7 +12,6 @@ import type { DrizzleDB, Initiative } from "@query/db"; import { createTRPCRouter, protectedProcedure } from "../trpc"; import { isAdmin, isProjectLeader } from "../middleware/procedures"; import { clearProjectLeaderCaches } from "../middleware/cache"; -import { resolveHackathonId } from "../services/portal-context"; const notFound = (message = "Initiative not found") => new TRPCError({ code: "NOT_FOUND", message }); @@ -76,17 +75,13 @@ function canManage( * live membership to check, which refuses rather than waving everyone through. */ async function requireActiveMember(db: Reader, userId: string) { - const hackathonId = await resolveHackathonId(db as DrizzleDB); - - const member = hackathonId - ? await db.query.members.findFirst({ - where: and( - eq(members.userId, userId), - eq(members.hackathonId, hackathonId), - ), - columns: { isActive: true, membershipEndDate: true }, - }) - : undefined; + // Initiatives were deliberately un-scoped from hackathons; membership now is + // too. This previously resolved a current edition and refused everyone when + // none existed, which is how the club half went dead outside event season. + const member = await db.query.members.findFirst({ + where: eq(members.userId, userId), + columns: { isActive: true, membershipEndDate: true }, + }); const active = !!( member?.isActive && @@ -444,7 +439,8 @@ export const initiativeRouter = createTRPCRouter({ } // Two leaders clicking the same button: the second is a no-op, so - // decidedAt keeps pointing at the real decision. + // decidedAt keeps pointing at the real decision — and no second email + // goes out, because there is no second decision. if (application.status === input.decision) { return { status: application.status }; } 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..a0c08f37 100644 --- a/packages/api/src/routers/judge/rankings.ts +++ b/packages/api/src/routers/judge/rankings.ts @@ -1,328 +1,529 @@ 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 , isNull } from "drizzle-orm"; import { isAdmin } from "../../middleware/procedures"; +import { recordAdminAction } from "../../middleware/audit"; import type { DrizzleDB } from "@query/db"; import { zNormalize } from "./helpers"; +/** + * The whole ranking pipeline, in one place. + * + * 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({ + // Withdrawn entries stop counting toward the ordering. + where: and( + eq(judgingProjects.hackathonId, hackathonId), + isNull(judgingProjects.withdrawnAt), + ), + with: { + votes: { + with: { + judge: { + with: { + user: { + columns: { name: true, email: true }, + }, + }, + }, + }, + }, + }, + }); + + const round2 = (n: number) => Math.round(n * 100) / 100; + + // ─── Step 1: 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); + + // A project nobody scored is not a placing. computeRanking gives every + // unjudged entry a weightedScore of 0, so including them would publish + // hundreds of rows tied at zero in arbitrary order below the real + // results — and "47th place" is a worse thing to tell a team than + // nothing at all. + const placed = rankings.filter((row) => row.voteCount > 0); + + if (placed.length === 0) { + return { computed: 0, unjudged: rankings.length }; + } + + await db + .insert(hackathonResults) + .values( + placed.map((row, index) => ({ + hackathonId: input.hackathonId, + projectId: row.project.id, + sourceProjectId: row.project.sourceProjectId ?? null, + // Never null — see the column comment. A NULL here silently + // defeats result_unique_placing and duplicates the ordering. + track: "overall", + placement: index + 1, + weightedScore: row.weightedScore.toFixed(2), + voteCount: row.voteCount, })), - }; - }); + ) + .onConflictDoUpdate({ + target: [ + hackathonResults.hackathonId, + hackathonResults.projectId, + hackathonResults.track, + ], + set: { + placement: sql`excluded.placement`, + weightedScore: sql`excluded.weighted_score`, + voteCount: sql`excluded.vote_count`, + computedAt: sql`now()`, + }, + }); - // ─── 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]); - } - }); + // Reported separately so an organiser can see that, say, 40 of 300 + // projects were never reached before they publish. + return { + computed: placed.length, + unjudged: rankings.length - placed.length, + }; + }), - 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, - }; + await recordAdminAction(ctx.db as DrizzleDB, { + userId: ctx.userId, + action: "judge.publishResults", + resourceId: input.hackathonId, + severity: "warn", + metadata: { placings: rows.length }, + }); - ctx.cache.set(cacheKey, result, 30); // 30 second cache for live rankings + ctx.cache.delete(`hackathon:${input.hackathonId}:results`); - return result; + return { published: rows.length }; + }), + + /** Takes results back down. The rows survive, so publishing is reversible + * rather than a one-way door on a wrong ordering. */ + unpublishResults: isAdmin + .input(z.object({ hackathonId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const rows = await (ctx.db as DrizzleDB) + .update(hackathonResults) + .set({ publishedAt: null }) + .where(eq(hackathonResults.hackathonId, input.hackathonId)) + .returning({ id: hackathonResults.id }); + + // Taking results back down after people have seen them. + await recordAdminAction(ctx.db as DrizzleDB, { + userId: ctx.userId, + action: "judge.unpublishResults", + resourceId: input.hackathonId, + severity: "critical", + metadata: { placings: rows.length }, + }); + + ctx.cache.delete(`hackathon:${input.hackathonId}:results`); + + return { unpublished: rows.length }; }), }); diff --git a/packages/api/src/routers/member.ts b/packages/api/src/routers/member.ts index 07d18bc7..ee0a6149 100644 --- a/packages/api/src/routers/member.ts +++ b/packages/api/src/routers/member.ts @@ -6,8 +6,10 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { members } from "@query/db"; import { eq, and } from "drizzle-orm"; import type { DrizzleDB } from "@query/db"; -import { invalidatePortalContext } from "../middleware/cache"; -import { resolveHackathonId } from "../services/portal-context"; +import { + clearMembershipCaches, + invalidatePortalContext, +} from "../middleware/cache"; // Letters from every script, plus the combining marks, spaces, hyphens and // apostrophes (straight and typographic) that real names are written with. @@ -24,20 +26,13 @@ const phoneSchema = z export const memberRouter = createTRPCRouter({ me: protectedProcedure - .input(z.object({ hackathonId: z.string().uuid().optional() }).optional()) - .query(async ({ ctx, input }) => { - const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input?.hackathonId); - if (!hackathonId) return null; - - const cacheKey = `member:me:${ctx.userId}:${hackathonId}`; + .query(async ({ ctx }) => { + const cacheKey = `member:me:${ctx.userId}`; const cached = ctx.cache.get(cacheKey); if (cached) return cached; const member = await (ctx.db as DrizzleDB).query.members.findFirst({ - where: and( - eq(members.userId, ctx.userId!), - eq(members.hackathonId, hackathonId), - ), + where: eq(members.userId, ctx.userId!), }); const result = member ?? null; @@ -48,7 +43,6 @@ export const memberRouter = createTRPCRouter({ register: protectedProcedure .input( z.object({ - hackathonId: z.string().uuid().optional(), firstName: nameSchema, lastName: nameSchema, phoneNumber: phoneSchema, @@ -63,27 +57,16 @@ export const memberRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input.hackathonId); - if (!hackathonId) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "No hackathon context found for registration", - }); - } - const existingMember = await ( ctx.db as DrizzleDB ).query.members.findFirst({ - where: and( - eq(members.userId, ctx.userId!), - eq(members.hackathonId, hackathonId), - ), + where: eq(members.userId, ctx.userId!), }); if (existingMember) { throw new TRPCError({ code: "BAD_REQUEST", - message: "You are already a member for this hackathon", + message: "You already have a member profile", }); } @@ -106,7 +89,6 @@ export const memberRouter = createTRPCRouter({ .insert(members) .values({ userId: ctx.userId!, - hackathonId, memberType: "new", firstName: input.firstName, lastName: input.lastName, @@ -153,7 +135,6 @@ export const memberRouter = createTRPCRouter({ update: protectedProcedure .input( z.object({ - hackathonId: z.string().uuid().optional(), firstName: nameSchema.optional(), lastName: nameSchema.optional(), phoneNumber: phoneSchema, @@ -168,35 +149,21 @@ export const memberRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input.hackathonId); - if (!hackathonId) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "No hackathon context found for update", - }); - } - const member = await (ctx.db as DrizzleDB).query.members.findFirst({ - where: and( - eq(members.userId, ctx.userId!), - eq(members.hackathonId, hackathonId), - ), + where: eq(members.userId, ctx.userId!), }); if (!member) { throw new TRPCError({ code: "NOT_FOUND", - message: "Member not found for this hackathon", + message: "Member not found", }); } - // Exclude hackathonId from update fields - const { hackathonId: _, ...updateFields } = input; - const result = await (ctx.db as DrizzleDB) .update(members) .set({ - ...updateFields, + ...input, updatedAt: new Date(), }) .where(eq(members.id, member.id)) @@ -211,28 +178,29 @@ export const memberRouter = createTRPCRouter({ }); } + // `me` caches for 60s; without this the form saves and re-reads the old + // values, which is indistinguishable from the save having failed. + clearMembershipCaches(ctx.userId!); + return updatedMember; }), list: publicProcedure .input( z.object({ - hackathonId: z.string().uuid().optional(), memberType: z.enum(["new", "continuous"]).optional(), limit: z.number().int().min(1).max(100).default(50), offset: z.number().int().min(0).max(10000).default(0), }), ) .query(async ({ ctx, input }) => { - const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input.hackathonId); - const cacheKey = `members:list:${hackathonId || "all"}:${input.memberType || "all"}:${input.limit}:${input.offset}`; + const cacheKey = `members:list:${input.memberType || "all"}:${input.limit}:${input.offset}`; const cached = ctx.cache.get(cacheKey); if (cached) return cached; const allMembers = await (ctx.db as DrizzleDB).query.members.findMany({ where: and( eq(members.isActive, true), - hackathonId ? eq(members.hackathonId, hackathonId) : undefined, input.memberType ? eq(members.memberType, input.memberType) : undefined, @@ -306,21 +274,9 @@ export const memberRouter = createTRPCRouter({ }), history: protectedProcedure - .input(z.object({ hackathonId: z.string().uuid().optional() }).optional()) - .query(async ({ ctx, input }) => { - const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input?.hackathonId); - if (!hackathonId) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "No hackathon context found for history lookup", - }); - } - + .query(async ({ ctx }) => { const member = await (ctx.db as DrizzleDB).query.members.findFirst({ - where: and( - eq(members.userId, ctx.userId!), - eq(members.hackathonId, hackathonId), - ), + where: eq(members.userId, ctx.userId!), columns: { id: true }, with: { membershipHistory: { @@ -331,29 +287,15 @@ export const memberRouter = createTRPCRouter({ }); if (!member) { - throw new TRPCError({ code: "NOT_FOUND", message: "Member not found for this hackathon" }); + throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" }); } return member.membershipHistory; }), checkStatus: protectedProcedure - .input(z.object({ hackathonId: z.string().uuid().optional() }).optional()) - .query(async ({ ctx, input }) => { - const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input?.hackathonId); - if (!hackathonId) { - return { - isMember: false, - isActive: false, - hasLapsed: false, - expiresAt: null, - daysRemaining: null, - memberType: null, - renewalCount: 0, - }; - } - - const cacheKey = `member:status:${ctx.userId}:${hackathonId}`; + .query(async ({ ctx }) => { + const cacheKey = `member:status:${ctx.userId}`; const cached = ctx.cache.get<{ isMember: boolean; isActive: boolean; @@ -366,10 +308,7 @@ export const memberRouter = createTRPCRouter({ if (cached) return cached; const member = await (ctx.db as DrizzleDB).query.members.findFirst({ - where: and( - eq(members.userId, ctx.userId!), - eq(members.hackathonId, hackathonId), - ), + where: eq(members.userId, ctx.userId!), }); if (!member) { @@ -415,4 +354,5 @@ export const memberRouter = createTRPCRouter({ return result; }), + }); diff --git a/packages/api/src/routers/stripe.ts b/packages/api/src/routers/stripe.ts index f0e45478..35801e2a 100644 --- a/packages/api/src/routers/stripe.ts +++ b/packages/api/src/routers/stripe.ts @@ -1,9 +1,15 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { stripePayments, userAccountLinks, users } from "@query/db"; +import { + members, + membershipHistory, + stripePayments, + userAccountLinks, + users, +} from "@query/db"; import type { DrizzleDB } from "@query/db"; -import { eq, and, isNull } from "drizzle-orm"; +import { eq, and, gte, isNull } from "drizzle-orm"; import { logSecurityEvent } from "../middleware/security"; import { clearMembershipCaches as clearMembershipCachesFor } from "../middleware/cache"; import { @@ -272,8 +278,14 @@ export const stripeRouter = createTRPCRouter({ // Checked before the key, matching createCheckoutSession, so local // development needs no Stripe key at all. if (isMockMode()) { + // A real, unique id so the mock flow goes through the SAME + // confirmMembershipAfterPayment path production uses — including its + // idempotency check on stripePaymentIntentId. A fixed placeholder + // would collide across runs and make the second developer's payment a + // silent no-op. return { clientSecret: "mock_pi_secret", + mockPaymentIntentId: `pi_mock_${crypto.randomUUID().replace(/-/g, "")}`, publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "pk_test_mock", isMock: true, }; @@ -354,18 +366,52 @@ export const stripeRouter = createTRPCRouter({ confirmMembershipAfterPayment: protectedProcedure .input(z.object({ paymentIntentId: z.string() })) .mutation(async ({ ctx, input }) => { - // No key-mode check here: this path hands no publishable key to the - // client, so the two cannot disagree. - const stripe = await getStripe(); - if (!stripe) { - throw new TRPCError({ - code: "SERVICE_UNAVAILABLE", - message: "Payment service unavailable.", - }); + // Mock mode grants the membership through this same procedure rather + // than a parallel branch, so local development exercises the production + // path: same idempotency check, same membership service, same cache + // eviction. Previously the modal called onSuccess() directly and the UI + // reported "Access Granted" with nothing written anywhere. + // + // isMockMode() is false whenever NODE_ENV=production regardless of the + // flag, so this cannot mint free memberships on the live site. + const mock = isMockMode() && input.paymentIntentId.startsWith("pi_mock_"); + + // Only the fields this procedure reads. Structural rather than Stripe's + // own type so the mock object can satisfy it without inventing the + // hundred properties a real PaymentIntent carries. + let pi: { + id: string; + status: string; + amount: number; + currency: string; + customer?: string | { id: string } | null; + receipt_email?: string | null; + metadata?: Record; + }; + + if (mock) { + pi = { + id: input.paymentIntentId, + status: "succeeded", + amount: priceForCents(false), + currency: "usd", + metadata: { userId: ctx.userId!, bootcamp: "false" }, + }; + } else { + // No key-mode check here: this path hands no publishable key to the + // client, so the two cannot disagree. + const stripe = await getStripe(); + if (!stripe) { + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Payment service unavailable.", + }); + } + + // Verify with Stripe that payment actually succeeded + pi = await stripe.paymentIntents.retrieve(input.paymentIntentId); } - // Verify with Stripe that payment actually succeeded - const pi = await stripe.paymentIntents.retrieve(input.paymentIntentId); if (pi.status !== "succeeded") { throw new TRPCError({ code: "BAD_REQUEST", @@ -571,7 +617,56 @@ export const stripeRouter = createTRPCRouter({ * strand it. Claim it and grant the membership instead. */ if (existing) { - if (existing.linkedUserId) continue; + // Somebody else's payment. Not ours to touch. + if (existing.linkedUserId && existing.linkedUserId !== ctx.userId) { + continue; + } + + /** + * Linked to this user, which is NOT proof the membership was granted. + * + * The webhook records the payment first and grants afterwards, on + * purpose — sharing a transaction meant a failed grant rolled the + * payment row back and lost the charge entirely. But that ordering + * leaves a real state where the row is linked and no membership + * exists, and skipping every linked payment here made that state + * permanent: the customer is charged, the payment is on file, and + * nothing ever retries. + * + * `membership_history` is what tells the two apart. Every grant writes + * a row, so a payment with no history row at or after its own + * timestamp was never honoured. That distinguishes a failed grant from + * a membership that was granted a year ago and has since lapsed — + * which must NOT be silently renewed off an old payment. + */ + if (existing.linkedUserId) { + const member = await ctx.db!.query.members.findFirst({ + where: eq(members.userId, ctx.userId!), + columns: { id: true }, + }); + + const honoured = member + ? await ctx.db!.query.membershipHistory.findFirst({ + where: and( + eq(membershipHistory.memberId, member.id), + gte(membershipHistory.createdAt, existing.createdAt), + ), + columns: { id: true }, + }) + : undefined; + + if (honoured) continue; + + const parts = (user?.name || "Member").trim().split(/\s+/); + await createOrUpdateMembership(ctx.db! as DrizzleDB, { + userId: ctx.userId!, + firstName: parts[0] || "Member", + lastName: parts.slice(1).join(" ") || "Member", + bootcampMember: pi.metadata?.bootcamp === "true", + }); + recovered += 1; + continue; + } await ctx.db!.transaction(async (tx) => { const claimed = await tx diff --git a/packages/api/src/routers/team.ts b/packages/api/src/routers/team.ts index bef558f1..dd0fd457 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; @@ -41,6 +42,32 @@ export function computeTeamWindow(baseTime: Date, now: Date) { }; } +/** + * The submission window, as three moments and the state between them. + * + * Exported and used by `submitProject` itself, so the page and the procedure + * cannot disagree: /submit rendered no window state at all, which meant an + * attendee could write a full description and learn it was refused only when + * they pressed submit. + */ +export function computeSubmissionWindow(baseTime: Date, now: Date) { + const at = (hours: number) => new Date(baseTime.getTime() + hours * HOUR); + + const opensAt = at(TEAM_WINDOW_OPEN_HOURS); + /** After this, an existing submission is frozen — new ones still land. */ + const editsCloseAt = at(TEAM_WINDOW_CLOSE_HOURS); + const closesAt = at(SUBMISSION_HARD_DEADLINE_HOURS); + + return { + opensAt, + editsCloseAt, + closesAt, + isOpen: now >= opensAt && now <= closesAt, + notYetOpen: now < opensAt, + canEditExisting: now >= opensAt && now <= editsCloseAt, + }; +} + async function loadTeamWindow(db: DrizzleDB, hackathonId: string) { const hackathon = await db.query.hackathons.findFirst({ where: eq(hackathons.id, hackathonId), @@ -560,6 +587,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") @@ -621,15 +651,10 @@ export const teamRouter = createTRPCRouter({ const now = new Date(); const baseTime = hackathon.hackingStartTime ?? hackathon.startDate; - const startSubmission = new Date( - baseTime.getTime() + 12 * 60 * 60 * 1000, - ); - const devpostFinalDeadline = new Date( - baseTime.getTime() + 34 * 60 * 60 * 1000, - ); - const hardDeadline = new Date(baseTime.getTime() + 36 * 60 * 60 * 1000); + const window = computeSubmissionWindow(baseTime, now); + const devpostFinalDeadline = window.editsCloseAt; - if (now < startSubmission) { + if (window.notYetOpen) { throw new TRPCError({ code: "FORBIDDEN", message: @@ -637,7 +662,7 @@ export const teamRouter = createTRPCRouter({ }); } - if (now > hardDeadline) { + if (now > window.closesAt) { throw new TRPCError({ code: "FORBIDDEN", message: @@ -718,10 +743,20 @@ export const teamRouter = createTRPCRouter({ technologies: input.technologies || [], tracks: input.tracks || [], challenges: input.challenges || [], + isCreateX: input.isCreateX ?? false, githubUrl, demoUrl, videoUrl, - status: "submitted", + // Only ever forward, never backwards. Writing "submitted" + // unconditionally let a team editing a demo link after + // promotion knock their project out of "judging" — which + // re-opened withdrawProject's status guard and let them + // withdraw a project judges were actively scoring. + status: + existingProject.status === "judging" || + existingProject.status === "winner" + ? existingProject.status + : "submitted", submittedAt: new Date(), }) .where(eq(hackathonProjects.id, existingProject.id)) @@ -740,6 +775,7 @@ export const teamRouter = createTRPCRouter({ technologies: input.technologies || [], tracks: input.tracks || [], challenges: input.challenges || [], + isCreateX: input.isCreateX ?? false, githubUrl, demoUrl, videoUrl, @@ -863,37 +899,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; }), @@ -903,6 +960,39 @@ export const teamRouter = createTRPCRouter({ * a solo hacker sees a blank form over a live submission, and saving a typo * fix silently wipes the links they had already filed. */ + /** + * The submission window for one edition, so /submit can say whether it is + * open before somebody fills the form in. Same computation the mutation + * enforces with, so the two cannot drift. + */ + submissionWindow: protectedProcedure + .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") })) + .query(async ({ ctx, input }) => { + const hackathon = await (ctx.db as DrizzleDB).query.hackathons.findFirst({ + where: eq(hackathons.id, input.hackathonId), + columns: { + hackingStartTime: true, + startDate: true, + status: true, + }, + }); + + if (!hackathon) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hackathon not found.", + }); + } + + const baseTime = hackathon.hackingStartTime ?? hackathon.startDate; + const window = computeSubmissionWindow(baseTime, new Date()); + + return { + ...window, + cancelled: hackathon.status === "cancelled", + }; + }), + mySubmission: protectedProcedure .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") })) .query(async ({ ctx, input }) => { diff --git a/packages/api/src/services/portal-context.ts b/packages/api/src/services/portal-context.ts index 138ff3d6..a3932199 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"; @@ -104,11 +104,10 @@ export async function fetchPortalContext( db: DrizzleDB, userId: string, ): Promise { - const [admin, hackathonId, judgeRecord, leaderRecord] = await Promise.all([ + const [admin, judgeRecord, leaderRecord] = await Promise.all([ db.query.admins.findFirst({ where: and(eq(admins.userId, userId), eq(admins.isActive, true)), }), - resolveHackathonId(db), db.query.judges.findFirst({ where: and(eq(judges.userId, userId), eq(judges.isActive, true)), columns: { id: true, name: true }, @@ -124,23 +123,21 @@ export async function fetchPortalContext( }), ]); - let member = EMPTY_MEMBER_CONTEXT; - - // Membership is still scoped to the edition, so it waits for one to resolve. - if (hackathonId) { - const memberRecord = await db.query.members.findFirst({ - where: and( - eq(members.userId, userId), - eq(members.hackathonId, hackathonId), - ), - }); - member = buildMemberContext(memberRecord ?? null); - } + // Membership no longer depends on an edition resolving, so the portal knows + // who is a member even when no hackathon is running. + const memberRecord = await db.query.members.findFirst({ + where: eq(members.userId, userId), + }); + const member = buildMemberContext(memberRecord ?? null); 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, @@ -148,8 +145,10 @@ export async function fetchPortalContext( judgeName: judgeRecord?.name ?? null, // Admins cover for leaders, and the middleware agrees — so the tab has to // appear for them too or staff see a page they are allowed to use but - // cannot reach. - isProjectLeader: isProjectLeader || !!admin, + // cannot reach. isStaffRole, not `!!admin`: a volunteer holds an admins + // row but isProjectLeader (procedures.ts) rejects them, so the bare truthy + // check advertised /lead to the one role that cannot open it. + isProjectLeader: isProjectLeader || isStaffRole(admin?.role), member, }; } 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/ddl/2026-08-08-membership-decouple.sql b/packages/db/ddl/2026-08-08-membership-decouple.sql new file mode 100644 index 00000000..311880e6 --- /dev/null +++ b/packages/db/ddl/2026-08-08-membership-decouple.sql @@ -0,0 +1,56 @@ +-- W1 + W18: a membership is annual and belongs to a person, not to a hackathon +-- edition. Apply once, against the database `packages/db/src/schemas` describes. +-- +-- Run this BEFORE deploying the code that drops the column from the schema, and +-- run it as written — `drizzle-kit push` offers to TRUNCATE when it adds the +-- unique constraint, which would delete every membership. +-- +-- Safe to apply only while no user holds two member rows. Check first: +-- +-- select user_id, count(*) from member group by user_id having count(*) > 1; +-- +-- At the time this was written production had 6 member rows and zero duplicates. +-- If that query returns anything, merge those rows by hand first: keep the one +-- with the latest membership_end_date, and add a membership_history row for each +-- term the merge discards. + +begin; + +-- The prior term of every existing member, so dropping the edition column does +-- not destroy the only record of which year they joined. membership_history was +-- empty until now (nothing ever wrote it), so there is nothing to reconcile. +insert into membership_history (member_id, action, start_date, end_date, notes) +select + id, + 'joined', + membership_start_date, + membership_end_date, + 'backfilled when membership was decoupled from the hackathon edition' +from member +where not exists ( + select 1 from membership_history h where h.member_id = member.id +); + +alter table member drop constraint if exists unique_member_per_hackathon; +drop index if exists member_hackathon_id_idx; +alter table member drop column if exists hackathon_id; +alter table member add constraint unique_member_per_user unique (user_id); + +commit; + +-- THIS FILE IS NOT THE WHOLE MIGRATION. +-- +-- It covers only the change `drizzle-kit push` cannot be trusted with — adding +-- the unique constraint, where push offers to TRUNCATE. The same release also +-- changes the judging tables (a NOT NULL UNIQUE qr_code with a default, a +-- withdrawn_at flag, judging_project.source_project_id moving from ON DELETE +-- CASCADE to SET NULL, arrival tracking on the queue and the results snapshot). +-- Those are additive or FK-only and push applies them safely. +-- +-- So the order is: +-- 1. this file, by hand +-- 2. `pnpm --filter @query/db migrate:push` for the rest +-- 3. run it once more — only NOW must it report "No changes detected" +-- +-- Applying step 1 and deploying without step 2 leaves the judging code +-- querying columns that do not exist. diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 99263a05..a7e0211f 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -1 +1,5 @@ -{ "version": "7", "dialect": "postgresql", "entries": [] } +{ + "version": "7", + "dialect": "postgresql", + "entries": [] +} diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index ff6ceea9..bcb10000 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -19,9 +19,34 @@ if (DATABASE_URL) { new Pool({ connectionString: DATABASE_URL, allowExitOnIdle: true, - connectionTimeoutMillis: 10000, // 10s timeout + /** + * Fail fast rather than sit on a Cloud Run request slot. + * + * At concurrency 80 against `max` connections, a saturated pool queues + * the rest. Waiting ten seconds for a checkout means each waiter holds + * its request slot for ten seconds and then surfaces a masked "an + * unexpected error occurred" anyway — so the instance spends its + * capacity on requests that were always going to fail. Three seconds + * returns the slot while a retry can still succeed. + */ + connectionTimeoutMillis: Number( + process.env.DB_CONNECTION_TIMEOUT_MS ?? 3000, + ), idleTimeoutMillis: 10000, // 10s idle timeout - max: 10, // Increased from 1 to 10 to prevent starvation in dev/HMR + /** + * Kept warm. pg-pool's reaper drains to `min` (0 by default), so raising + * idleTimeoutMillis alone does nothing — every burst after a quiet spell + * paid a fresh connection handshake before it could run a query. + */ + min: 2, + /** + * Deliberately NOT raised past 10 yet: 10 instances x max is the ceiling + * against Postgres, and whether that is safe depends on the connection + * string pointing at Neon's pooled endpoint rather than the direct one. + * Env-tunable so it can be raised from config once that is confirmed, + * without a redeploy of anything but the variable. + */ + max: Number(process.env.DB_POOL_MAX ?? 10), ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: true } 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..90967f50 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,32 @@ 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. + // set null, not cascade. judge_vote and hackathon_result both cascade off + // judging_project.id, so cascading here would make one DELETE on a + // submission also erase every score judges gave it and its frozen + // published placing. hackathonResults.sourceProjectId is already set null + // for the same reason. + sourceProjectId: uuid("source_project_id").references( + () => hackathonProjects.id, + { onDelete: "set null" }, + ), + /** + * The code on the team's table card. + * + * A judge scans this on arrival, which is what starts their scoring clock + * — being handed a table in a queue is not the same as standing in front + * of it, and the walk between them was previously counted as judging time. + * Scanning also proves the judge reached the right table. + * + * Lives on the judging entry rather than the team because this is exactly + * one physical table: a solo submission has no team row, and a team has no + * table until its project is promoted. + */ + qrCode: uuid("qr_code").defaultRandom().notNull().unique(), name: text("name").notNull(), description: text("description"), tableNumber: integer("table_number").notNull(), @@ -95,11 +122,34 @@ export const judgingProjects = pgTable( tracks: text("tracks").array(), // Enum: Sports, Entertainment, Finance, Healthcare, databricks, sphinx, growth factor, figma, actian, safety kit, GEN-AI, CYBER, NONE challenges: text("challenges").array(), // Enum: AGG, ASSURANT, AWS, CAPONE, GROWTH, MLH_MONGODB, MLH_STREAMLIT, MLH_TECH, MLH_CLOUDFLARE, MLH_REACH_CAPITAL isCreateX: boolean("is_create_x").default(false), + /** + * Set when an organiser pulls the submission out of the event. + * + * A flag rather than a delete: judge_vote cascades off this row, so + * deleting would erase scores judges actually gave, and the z-score + * normalisation over the remaining votes would shift every other + * project. The entry stops being served and stops counting; the record of + * what happened survives. + */ + withdrawnAt: timestamp("withdrawn_at"), createdAt: timestamp("created_at").defaultNow().notNull(), }, (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 +184,64 @@ 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. "overall" is the main ranking. + * + * NOT NULL deliberately. Postgres unique indexes treat NULLs as distinct, + * so a nullable track would make result_unique_placing below match nothing + * — every recompute would append a second full ordering instead of + * upserting, and nothing in the product deletes result rows. + */ + track: text("track").notNull().default("overall"), + 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 @@ -173,6 +267,15 @@ export const judgeQueue = pgTable( // (JUDGE_CLAIM_MINUTES) — a judge who closes the tab releases the table on // their own rather than blocking it until an admin steps in. startedAt: timestamp("started_at"), + /** + * When the judge scanned the table's QR and actually began. + * + * Distinct from startedAt, which is the claim stamped when the queue hands + * the table over. The gap between them is walking, queueing behind another + * judge, and finding the table — none of which is time spent judging, and + * all of which used to be counted as it. + */ + arrivedAt: timestamp("arrived_at"), }, (table) => [ index("queue_judge_id_idx").on(table.judgeId), @@ -244,12 +347,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..95c40339 100644 --- a/packages/db/src/schemas/members.ts +++ b/packages/db/src/schemas/members.ts @@ -10,7 +10,6 @@ import { } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; import { users } from "./auth"; -import { hackathons } from "./hackathons"; export const userProfiles = pgTable( "user_profile", @@ -36,9 +35,6 @@ export const members = pgTable( userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), - hackathonId: uuid("hackathon_id") - .notNull() - .references(() => hackathons.id, { onDelete: "cascade" }), memberType: text("member_type", { enum: ["new", "continuous"] }) .notNull() .default("new"), @@ -69,10 +65,17 @@ export const members = pgTable( }, (table) => [ index("member_user_id_idx").on(table.userId), - index("member_hackathon_id_idx").on(table.hackathonId), // Optimized for "Active Members" directory listing index("member_active_type_idx").on(table.isActive, table.memberType), - unique("unique_member_per_hackathon").on(table.userId, table.hackathonId), + // One membership per person, full stop. + // + // This was unique(userId, hackathonId), which welded a membership to a + // hackathon edition: the day the next edition opened, every read resolved + // to it, found no row, and every paying member silently became a + // non-member. A membership is an annual subscription defined by its own + // start and end dates — the edition contributed nothing to that meaning. + // Which YEAR somebody was a member is recorded in membership_history. + unique("unique_member_per_user").on(table.userId), ], ); @@ -117,10 +120,6 @@ export const membersRelations = relations(members, ({ one, many }) => ({ fields: [members.userId], references: [users.id], }), - hackathon: one(hackathons, { - fields: [members.hackathonId], - references: [hackathons.id], - }), membershipHistory: many(membershipHistory), })); diff --git a/packages/db/src/services/membership.test.ts b/packages/db/src/services/membership.test.ts index 4f8813ac..6074719b 100644 --- a/packages/db/src/services/membership.test.ts +++ b/packages/db/src/services/membership.test.ts @@ -5,6 +5,7 @@ import { splitName, } from "./membership"; import type { DrizzleDB } from "../client"; +import { membershipHistory } from "../schemas/members"; const DAY = 24 * 60 * 60 * 1000; @@ -61,6 +62,7 @@ function fakeHackathons(rows: Record[]) { function fakeDb(existingMember: Record | undefined) { const updates: Record[] = []; const inserts: Record[] = []; + const historyInserts: Record[] = []; const db = { query: { @@ -74,14 +76,28 @@ function fakeDb(existingMember: Record | undefined) { }, }), }), - insert: () => ({ - values: async (values: Record) => { - inserts.push(values); + // Which table an insert targets decides which recorder it lands in, so a + // test can assert the membership_history row separately from the member + // row. Identity against the imported table objects, because the service + // passes them straight through. + insert: (table: unknown) => ({ + values: (values: Record) => { + (table === membershipHistory ? historyInserts : inserts).push(values); + // `.returning()` on the member insert is what gives the history row its + // memberId, so the fake has to be both awaitable and returning-able. + const rows = [{ id: "member_new" }]; + return { + returning: async () => rows, + then: ( + resolve: (v: typeof rows) => unknown, + reject: (e: unknown) => unknown, + ) => Promise.resolve(rows).then(resolve, reject), + }; }, }), } as unknown as DrizzleDB; - return { db, updates, inserts }; + return { db, updates, inserts, historyInserts }; } describe("resolveCurrentHackathonId", () => { @@ -200,6 +216,54 @@ describe("createOrUpdateMembership", () => { const end = inserts[0]?.membershipEndDate as Date; expect(end.getTime()).toBeGreaterThan(Date.now() + 360 * DAY); expect(inserts[0]?.renewalCount).toBe(0); + // No edition column any more: a membership is annual and belongs to the + // person, so nothing here may name a hackathon. + expect(inserts[0]).not.toHaveProperty("hackathonId"); + }); + + /** + * membership_history is the only record of which years somebody was a member + * now that the hackathon column is gone — the table existed for a long time + * with nothing ever writing to it. + */ + it("records a joined history row for a new member", async () => { + const { db, historyInserts } = fakeDb(undefined); + + await createOrUpdateMembership(db, { + userId: "u1", + firstName: "Ada", + lastName: "Lovelace", + }); + + expect(historyInserts).toHaveLength(1); + expect(historyInserts[0]?.action).toBe("joined"); + expect(historyInserts[0]?.memberId).toBe("member_new"); + expect(historyInserts[0]?.endDate).toBeInstanceOf(Date); + }); + + it("records a renewed history row spanning the new term", async () => { + const existingEnd = new Date(Date.now() + 100 * DAY); + const { db, historyInserts } = fakeDb({ + id: "m1", + renewalCount: 1, + membershipEndDate: existingEnd, + phoneNumber: null, + }); + + await createOrUpdateMembership(db, { + userId: "u1", + firstName: "Ada", + lastName: "Lovelace", + }); + + expect(historyInserts).toHaveLength(1); + expect(historyInserts[0]?.action).toBe("renewed"); + expect(historyInserts[0]?.memberId).toBe("m1"); + // The renewal overwrites membershipEndDate in place, so the history row is + // what preserves where the new term started. + expect((historyInserts[0]?.startDate as Date).getTime()).toBe( + existingEnd.getTime(), + ); }); /** diff --git a/packages/db/src/services/membership.ts b/packages/db/src/services/membership.ts index b67b34bd..c8fb7652 100644 --- a/packages/db/src/services/membership.ts +++ b/packages/db/src/services/membership.ts @@ -1,6 +1,6 @@ import { and, eq, isNull } from "drizzle-orm"; import type { DrizzleDB } from "../client"; -import { members } from "../schemas/members"; +import { members, membershipHistory } from "../schemas/members"; import { PRE_CURRENT_STATUSES } from "../schemas/hackathons"; import { stripePayments, userAccountLinks } from "../schemas/stripe"; @@ -121,18 +121,15 @@ export async function createOrUpdateMembership( bootcampMember?: boolean; }, ) { - const hackathonId = - opts.hackathonId ?? (await resolveCurrentHackathonId(db)); - - if (!hackathonId) { - throw new Error("No hackathon found for membership assignment"); - } - + // Keyed on the person, not the edition. + // + // This used to resolve a "current hackathon" and look for (userId, + // hackathonId) — so on the day the next edition opened, an existing member + // matched nothing, took the insert branch below, and had their remaining + // months silently replaced by a fresh term starting today. It also meant a + // payment could not be honoured at all when no edition was open. const existing = await db.query.members.findFirst({ - where: and( - eq(members.userId, opts.userId), - eq(members.hackathonId, hackathonId), - ), + where: eq(members.userId, opts.userId), }); const now = new Date(); @@ -165,22 +162,44 @@ export async function createOrUpdateMembership( updatedAt: now, }) .where(eq(members.id, existing.id)); + + // The renewal overwrites membershipEndDate in place, so without this row + // the previous term leaves no trace at all. Since a membership is no + // longer scoped to an edition, this table is the only record of which + // years somebody was a member. + await db.insert(membershipHistory).values({ + memberId: existing.id, + action: "renewed", + startDate: termStart, + endDate: termEnd, + }); return; } - await db.insert(members).values({ - userId: opts.userId, - hackathonId, - firstName: opts.firstName, - lastName: opts.lastName, - memberType: "new", - isActive: true, - membershipStartDate: now, - membershipEndDate: termEnd, - renewalCount: 0, - phoneNumber: opts.phoneNumber ?? null, - bootcampMember: !!opts.bootcampMember, - }); + const [created] = await db + .insert(members) + .values({ + userId: opts.userId, + firstName: opts.firstName, + lastName: opts.lastName, + memberType: "new", + isActive: true, + membershipStartDate: now, + membershipEndDate: termEnd, + renewalCount: 0, + phoneNumber: opts.phoneNumber ?? null, + bootcampMember: !!opts.bootcampMember, + }) + .returning({ id: members.id }); + + if (created) { + await db.insert(membershipHistory).values({ + memberId: created.id, + action: "joined", + startDate: now, + endDate: termEnd, + }); + } } export type LinkOutcome = diff --git a/sites/hacklytics2027/lib/links.ts b/sites/hacklytics2027/lib/links.ts index 7ab58dac..bfe7b504 100644 --- a/sites/hacklytics2027/lib/links.ts +++ b/sites/hacklytics2027/lib/links.ts @@ -11,9 +11,21 @@ /** The portal origin. Matches BASE_URL / NEXTAUTH_URL in apphosting.yaml. */ export const PORTAL_ORIGIN = "https://datasciencegt.org"; +/** Where somebody ends up after signing in. */ +const INTEREST_PATH = "/hacklytics"; + /** - * The announced-edition landing page and interest form. Signing in is required - * to join the list, so the address behind it is verified — this link goes to - * the page that explains that, not straight into a login screen. + * The interest form, entered through sign-in. + * + * Joining the list requires an account so the address on it is verified, and + * asking for that up front beats asking halfway through the form. The + * callbackUrl carries the destination through the whole login chain — + * including the email-code path, which hands off through /verify — so people + * land on the form itself rather than on a dashboard they did not ask for. + * + * Encoded because it is a query-parameter value; the portal only honours + * same-origin paths, so this has to arrive intact to be accepted. */ -export const INTEREST_URL = `${PORTAL_ORIGIN}/hacklytics`; +export const INTEREST_URL = `${PORTAL_ORIGIN}/login?callbackUrl=${encodeURIComponent( + INTEREST_PATH, +)}`; 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)/api/auth/verify-email/route.ts b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts index df8e6807..ba2e89c6 100644 --- a/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts +++ b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts @@ -7,16 +7,14 @@ import { accounts, stripePayments, userAccountLinks, - members, verificationTokens, } from "@query/db"; -import { eq, and, isNull } from "drizzle-orm"; import { - rateLimit, - cache, - resolveClientIp, - resolveHackathonId, -} from "@query/api"; + createOrUpdateMembership, + paidForBootcamp, +} from "@query/db/services/membership"; +import { eq, and, isNull } from "drizzle-orm"; +import { rateLimit, cache, resolveClientIp } from "@query/api"; import type { DrizzleDB } from "@query/db"; /** @@ -194,52 +192,16 @@ export async function POST(request: NextRequest) { }) .where(eq(stripePayments.id, payment.id)); - // Create/Update membership - const now = new Date(); - const oneYearFromNow = new Date(now); - oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1); - - // Same rule as every other membership read. - const hackathonId = await resolveHackathonId( - tx as unknown as DrizzleDB, - ); - - if (!hackathonId) { - throw new Error("No hackathon found for membership assignment"); - } - - const existingMember = await tx.query.members.findFirst({ - where: and( - eq(members.userId, user.id), - eq(members.hackathonId, hackathonId), - ), + // One shared implementation rather than a fourth copy: this one + // used to restart the term from today on renewal (discarding + // remaining months), write no membership_history row, and refuse + // outright when no hackathon edition was open. + await createOrUpdateMembership(tx as unknown as DrizzleDB, { + userId: user.id, + firstName, + lastName, + bootcampMember: paidForBootcamp(payment.metadata), }); - - if (existingMember) { - await tx - .update(members) - .set({ - isActive: true, - membershipStartDate: now, - membershipEndDate: oneYearFromNow, - renewalCount: existingMember.renewalCount + 1, - memberType: "continuous", - updatedAt: now, - }) - .where(eq(members.id, existingMember.id)); - } else { - await tx.insert(members).values({ - userId: user.id, - hackathonId, - firstName, - lastName, - memberType: "new", - isActive: true, - membershipStartDate: now, - membershipEndDate: oneYearFromNow, - renewalCount: 0, - }); - } } } } catch (linkError) { diff --git a/sites/mainweb/app/(portal)/api/cron/cleanup-audit-logs/route.ts b/sites/mainweb/app/(portal)/api/cron/cleanup-audit-logs/route.ts deleted file mode 100644 index b8d0fc6d..00000000 --- a/sites/mainweb/app/(portal)/api/cron/cleanup-audit-logs/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; -import { and, lt, ne } from "drizzle-orm"; -import { db, auditLogs } from "@query/db"; - -/** - * How long a security event is kept. This ran weekly as an unqualified - * `db.delete(auditLogs)` — a truncate, not a cleanup — so the effective - * retention for every injection attempt, auth failure and rate-limit trip was - * however long it had been since Monday. An investigation into anything older - * than that had nothing left to read. - */ -const RETAIN_DAYS = 90; -/** Critical events outlive the routine window; they are the ones worth keeping. */ -const RETAIN_CRITICAL_DAYS = 365; - -export async function GET(req: NextRequest) { - const auth = req.headers.get("authorization"); - if ( - !process.env.CRON_SECRET || - auth !== `Bearer ${process.env.CRON_SECRET}` - ) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - if (!db) - return NextResponse.json({ error: "DB not initialized" }, { status: 500 }); - - const cutoff = (days: number) => - new Date(Date.now() - days * 24 * 60 * 60 * 1000); - - // Both bound on created_at, which audit_created_at_idx covers. - const routine = await db - .delete(auditLogs) - .where( - and( - lt(auditLogs.createdAt, cutoff(RETAIN_DAYS)), - ne(auditLogs.severity, "critical"), - ), - ) - .returning({ id: auditLogs.id }); - - const critical = await db - .delete(auditLogs) - .where(lt(auditLogs.createdAt, cutoff(RETAIN_CRITICAL_DAYS))) - .returning({ id: auditLogs.id }); - - return NextResponse.json({ - ok: true, - deleted: routine.length + critical.length, - }); -} diff --git a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts index 4b6b02a2..7e370abb 100644 --- a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts +++ b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts @@ -134,21 +134,29 @@ export async function POST(req: NextRequest) { existingPayment.paymentStatus !== "paid" && session.payment_status === "paid" ) { - await db.transaction(async (tx) => { - await tx - .update(stripePayments) - .set({ paymentStatus: "paid", updatedAt: new Date() }) - .where(eq(stripePayments.id, existingPayment.id)); - - if (existingPayment.linkedUserId) { - await createOrUpdateMembership(tx as unknown as DrizzleDB, { + // Status upgrade commits first; the grant is best-effort after it. + // Sharing a transaction meant a failed grant reverted the row to + // "unpaid", losing the settlement Stripe just told us about. + await db + .update(stripePayments) + .set({ paymentStatus: "paid", updatedAt: new Date() }) + .where(eq(stripePayments.id, existingPayment.id)); + + if (existingPayment.linkedUserId) { + try { + await createOrUpdateMembership(db as unknown as DrizzleDB, { userId: existingPayment.linkedUserId, ...splitName(customerName), phoneNumber, bootcampMember: session.metadata?.bootcamp === "true", }); + } catch (e) { + console.error( + `[Stripe webhook] Payment ${existingPayment.id} marked paid, membership grant failed:`, + e, + ); } - }); + } } if (existingPayment.linkedUserId) { @@ -179,42 +187,51 @@ export async function POST(req: NextRequest) { }); } - // Execute in transaction - await db.transaction(async (tx) => { - await tx.insert(stripePayments).values({ - stripeSessionId: session.id, - stripeCustomerId: session.customer as string | null, - stripePaymentIntentId: session.payment_intent as string | null, - customerEmail, // Normalized - customerName, - amountTotal: session.amount_total, - currency: session.currency || "usd", - paymentStatus: session.payment_status as - | "paid" - | "unpaid" - | "no_payment_required", - linkedUserId: targetUser?.id || null, - linkedAt: targetUser ? new Date() : null, - metadata: session.metadata ? JSON.stringify(session.metadata) : null, - }); + // The payment record commits on its own, BEFORE any membership work. + // + // These used to share one transaction. createOrUpdateMembership throws + // when no hackathon edition is open, and that throw rolled back the + // payment row too — so the customer was charged and nothing anywhere + // recorded it. Stripe then retried into the same failure until it gave + // up, and none of the recovery paths (attemptAutoLink, + // linkPaidPaymentByVerifiedEmail, reconcileMyPayments) could help, + // because they all look for a payment row that was never written. + await db.insert(stripePayments).values({ + stripeSessionId: session.id, + stripeCustomerId: session.customer as string | null, + stripePaymentIntentId: session.payment_intent as string | null, + customerEmail, // Normalized + customerName, + amountTotal: session.amount_total, + currency: session.currency || "usd", + paymentStatus: session.payment_status as + | "paid" + | "unpaid" + | "no_payment_required", + linkedUserId: targetUser?.id || null, + linkedAt: targetUser ? new Date() : null, + metadata: session.metadata ? JSON.stringify(session.metadata) : null, + }); - // If user exists and paid, create/update membership - if (targetUser && session.payment_status === "paid") { - await createOrUpdateMembership(tx as unknown as DrizzleDB, { + // Membership is a separate, best-effort step. A failure here leaves a + // recorded payment that the link paths can still turn into a membership; + // failing the whole webhook would lose the payment instead. + if (targetUser && session.payment_status === "paid") { + try { + await createOrUpdateMembership(db as unknown as DrizzleDB, { userId: targetUser.id, ...splitName(customerName), phoneNumber, bootcampMember: session.metadata?.bootcamp === "true", }); - - // Invalidate cache - try { - clearMembershipCaches(targetUser.id); - } catch (e) { - console.warn("Failed to invalidate cache inside webhook", e); - } + clearMembershipCaches(targetUser.id); + } catch (e) { + console.error( + `[Stripe webhook] Payment ${session.id} recorded, membership grant failed:`, + e, + ); } - }); + } } catch (error) { console.error("Error processing checkout session:", error); return NextResponse.json({ error: "Processing failed" }, { status: 500 }); @@ -278,13 +295,16 @@ export async function POST(req: NextRequest) { return NextResponse.json({ received: true }); } - await db.transaction(async (tx) => { - // Same synthetic session id confirmMembershipAfterPayment writes, so - // the existing unique on stripeSessionId settles the race between this - // webhook and the client callback: whichever lands second inserts - // nothing and leaves the first one's membership alone. - const inserted = await tx - .insert(stripePayments) + // Same synthetic session id confirmMembershipAfterPayment writes, so + // the existing unique on stripeSessionId settles the race between this + // webhook and the client callback: whichever lands second inserts + // nothing and leaves the first one's membership alone. + // + // Not in a transaction with the grant below: onConflictDoNothing already + // makes the insert idempotent, and wrapping the two together meant a + // membership failure rolled back the payment record as well. + const inserted = await db + .insert(stripePayments) .values({ stripeSessionId: `pi_${pi.id}`, stripeCustomerId: @@ -304,22 +324,22 @@ export async function POST(req: NextRequest) { .onConflictDoNothing({ target: stripePayments.stripeSessionId }) .returning({ id: stripePayments.id }); - if (inserted.length === 0) return; - - if (targetUser) { - await createOrUpdateMembership(tx as unknown as DrizzleDB, { + // Another writer got there first and has already granted the membership. + if (inserted.length > 0 && targetUser) { + try { + await createOrUpdateMembership(db as unknown as DrizzleDB, { userId: targetUser.id, ...splitName(targetUser.name), bootcampMember: pi.metadata?.bootcamp === "true", }); - - try { - clearMembershipCaches(targetUser.id); - } catch (e) { - console.warn("Failed to invalidate cache inside webhook", e); - } + clearMembershipCaches(targetUser.id); + } catch (e) { + console.error( + `[Stripe webhook] Payment pi_${pi.id} recorded, membership grant failed:`, + e, + ); } - }); + } } catch (error) { console.error("Error processing payment intent:", error); return NextResponse.json({ error: "Processing failed" }, { status: 500 }); 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]/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx index 7f3ffef3..5f4f943c 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx @@ -12,6 +12,7 @@ import Link from "next/link"; import { InfoTab } from "@/components/hackathon/InfoTab"; import { ScheduleTab } from "@/components/hackathon/ScheduleTab"; import { ProjectsTab } from "@/components/hackathon/ProjectsTab"; +import { ResultsTab } from "@/components/hackathon/ResultsTab"; import { TeamsTab } from "@/components/hackathon/TeamsTab"; import { HackathonUnavailable } from "@/components/hackathon/HackathonUnavailable"; @@ -108,13 +109,14 @@ function statusConfig(s: string) { ); } -type TabType = "INFO" | "SCHEDULE" | "PROJECTS" | "TEAMS"; +type TabType = "INFO" | "SCHEDULE" | "PROJECTS" | "TEAMS" | "RESULTS"; const TABS: { id: TabType; label: string }[] = [ { id: "INFO", label: "Info & Register" }, { id: "SCHEDULE", label: "Schedule & QR" }, { id: "PROJECTS", label: "Project Gallery" }, { id: "TEAMS", label: "Find Teams" }, + { id: "RESULTS", label: "Results" }, ]; export default function HackathonDetailPage() { @@ -364,6 +366,8 @@ export default function HackathonDetailPage() { hackathonId={hackathon.id} isRegistered={isRegistered} /> + ) : tab === "RESULTS" ? ( + ) : tab === "PROJECTS" ? ( ) : ( 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..852f6fd6 100644 --- a/sites/mainweb/app/(portal)/login/page.tsx +++ b/sites/mainweb/app/(portal)/login/page.tsx @@ -1,24 +1,10 @@ "use client"; import React, { useState, useEffect } from "react"; -import { useSession, signIn } from "next-auth/react"; +import { useSession, signIn, getProviders } 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 @@ -36,10 +22,33 @@ export default function Home() { const [emailError, setEmailError] = useState(""); const { data: portalContext } = usePortalContext(); + /** + * Which providers the server actually registered. + * + * GitHub is only added to the provider list when GITHUB_CLIENT_ID and + * GITHUB_CLIENT_SECRET are set (packages/auth/src/config.ts), so a + * deployment without them was rendering a GitHub button that called + * signIn("github") against a provider that did not exist — a dead button + * with no explanation. Asking the server what it supports means a missing + * provider hides its button instead of failing when pressed. + */ + const [providers, setProviders] = useState | null>( + null, + ); + useEffect(() => { setMounted(true); + getProviders() + .then((p) => setProviders(p ?? {})) + // A failed lookup must not hide every sign-in button. Falling back to + // "assume configured" keeps the page usable and lets the provider's own + // error surface instead. + .catch(() => setProviders(null)); }, []); + // null means "we could not ask" — show it and let signIn report the truth. + const hasProvider = (id: string) => providers === null || id in providers; + useEffect(() => { if (session) { const redirectTimeout = setTimeout(() => { @@ -87,7 +96,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."); @@ -194,21 +209,23 @@ export default function Home() { - + + Sign in with GitHub + + )} {!showEmailInput ? (
+ {/* 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/app/docs/DocsPageClient.tsx b/sites/mainweb/app/docs/DocsPageClient.tsx index b1a71095..aeaaf973 100644 --- a/sites/mainweb/app/docs/DocsPageClient.tsx +++ b/sites/mainweb/app/docs/DocsPageClient.tsx @@ -682,8 +682,14 @@ export default function DocsPageClient() { /> + +

@@ -1219,7 +1231,7 @@ export default function DocsPageClient() {

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" + /> +
+ +
+ +